Saltar al contenido

Portal: renderizar fuera del árbol del padre

Portal saca contenido del subarbol DOM del componente y lo monta en otro punto del documento, el body por defecto, para modales, tooltips y toasts que escapan de overflow, z-index y transform, sin perder el contexto ni el arbol de eventos de Solid.

A dónde monta

Portar tiene sentido para un puñado de patrones recurrentes, todos con el mismo problema de fondo: un elemento que debe pintarse por encima o fuera del recorte de sus ancestros.

Portal vive en solid-js/web. Sin más props, crea un <div> contenedor, lo añade al final de document.body e inserta ahí sus hijos. Con la prop mount eliges otro nodo destino —un contenedor dedicado a toasts, por ejemplo—:

TSX
import { Portal } from "solid-js/web";function Toast(props: { children: JSX.Element }) {  return (    <Portal mount={document.getElementById("capa-toasts")!}>      <div role="status" class="toast">{props.children}</div>    </Portal>  );}

Dos opciones afinan el comportamiento. useShadow monta los hijos dentro de un shadow root del contenedor, aislando estilos del resto de la página. isSVG hace que el contenedor sea un <g> en vez de un <div>, necesario cuando portas nodos dentro de un <svg>. Un ref sobre el Portal te da el nodo contenedor.

Prop Efecto
mount nodo destino; por defecto document.body
useShadow encierra los hijos en un shadow root aislado
isSVG usa un contenedor <g> para contextos SVG

El contexto viaja con el Portal

Aquí está la propiedad que hace al Portal de Solid superior a un portal ingenuo del DOM: aunque el nodo aterrice en document.body, los hijos se crean dentro del owner donde escribiste el Portal. Por tanto conservan todo lo que depende del árbol reactivo: useContext, el tema, el idioma, la inyección de dependencias. No hay que reconectar nada.

TSX
const Tema = createContext<"claro" | "oscuro">("claro");function App() {  return (    <Tema.Provider value="oscuro">      <Portal>        <Dialogo />      </Portal>    </Tema.Provider>  );}// dentro de Dialogo, useContext(Tema) devuelve "oscuro" pese a vivir en el body

Ese Dialogo, pese a colgar del body, lee el mismo context que sus hermanos del árbol lógico. La separación es puramente espacial en el DOM; en el grafo de Solid siguen siendo padre e hijo.

Los eventos suben por el árbol de Solid

El corolario más contraintuitivo: los eventos delegados de Solid —onClick, onInput y demás— se propagan siguiendo la jerarquía de componentes, no la del DOM. Un clic dentro del contenido portado burbujea hacia los manejadores de los componentes ancestros en el árbol lógico, aunque físicamente el nodo cuelgue del body.

En la práctica, un onClick puesto en un componente que envuelve al Portal recibe los clics de dentro del portal. Casi siempre es lo que quieres —cerrar un menú al pulsar en cualquier sitio—, pero puede provocar cierres o navegaciones fantasma si no lo prevés. Los eventos nativos no delegados —los que registras con addEventListener— sí siguen el DOM real, y ahí el contenido portado está en body, ajeno a tus paneles. Distinguir ambos planos es la clave para no perseguir bugs imposibles.

Posicionar lo portado

Un modal se centra con CSS, pero un tooltip o un popover deben aparecer junto a su ancla, y el ancla vive en tu árbol mientras el contenido vive en body. El puente es leer la geometría del ancla con getBoundingClientRect y aplicarla como posición fija al contenido portado:

TSX
import { Portal } from "solid-js/web";import { createSignal, Show } from "solid-js";function ConTooltip(props: { children: JSX.Element; texto: string }) {  const [rect, setRect] = createSignal<DOMRect>();  let ancla!: HTMLSpanElement;  return (    <>      <span        ref={ancla}        onMouseEnter={() => setRect(ancla.getBoundingClientRect())}        onMouseLeave={() => setRect(undefined)}      >        {props.children}      </span>      <Show when={rect()}>        {(r) => (          <Portal>            <div role="tooltip" style={{ position: "fixed", top: `${r().bottom}px`, left: `${r().left}px` }}>              {props.texto}            </div>          </Portal>        )}      </Show>    </>  );}

Como el tooltip se monta en body, position: fixed lo sitúa respecto a la ventana con las coordenadas del ancla, sin que ningún overflow intermedio lo recorte. Para casos serios —colisiones con los bordes, scroll de la página, volteo automático— se delega en una librería de posicionamiento como Floating UI, pero el mecanismo de fondo es exactamente este: leer el rect del ancla y proyectarlo sobre el nodo portado.