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.
Hay UI que no cabe donde nace. Un modal atrapado dentro de un contenedor con overflow: hidden se recorta; un tooltip bajo un ancestro con transform hereda un nuevo contexto de apilamiento y su z-index deja de valer; un toast debería vivir sobre todo lo demás, no anidado en la tarjeta que lo disparó. Portal resuelve esto rompiendo la correspondencia entre el árbol de componentes y el árbol del DOM: los hijos se crean donde los escribes, pero se insertan en otro nodo del documento.
- Mover contenido fuera del subárbol del padre con
Portaly elegir el destino conmount. - Conocer qué envuelve
Portaly las opcionesuseShadoweisSVG. - Comprobar que el contexto de Solid sigue disponible dentro del
Portal. - Entender que los eventos delegados suben por el árbol de componentes, no por el del DOM.
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.
Modales y diálogos
Ocupan la ventana entera y capturan el foco; no pueden quedar atrapados por el overflow de una tarjeta.
Tooltips y popovers
Se posicionan junto a un ancla pero deben desbordar su contenedor sin recortarse.
Toasts y avisos
Viven en una capa global sobre todo el layout, ajenos a dónde se dispararon.
Menús y desplegables
Se abren desde un botón, pero su lista debe flotar por encima del resto de la interfaz.
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—:
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 |
mount se resuelve cuando el Portal se crea. Si apuntas a un nodo que aún no está en el documento —un contenedor que renderiza otro componente más abajo— obtienes null y falla. En SSR no hay document: el contenido del Portal se emite y se hidrata en el cliente, así que todo acceso directo como mount debe protegerse o diferirse a onMount. Para el body por defecto no hay problema; para destinos propios, garantiza su existencia antes.
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.
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.
flowchart TD ROOT[App root] --> PANEL[Panel con overflow hidden] PANEL --> MARCA[Portal marcador logico] BODY[document body] --> CAJA[div contenedor del Portal] CAJA --> MODAL[Modal visible fuera del panel] MODAL -.el onClick sube por el arbol de Solid.-> PANEL style CAJA fill:#89b4fa,color:#11111b style MODAL fill:#a6e3a1,color:#11111b
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.
Solid delega un conjunto de eventos comunes en la raíz del documento y los reemite por el árbol de componentes; por eso cruzan el Portal. Un addEventListener manual sobre un nodo, en cambio, obedece la propagación real del DOM. Si un manejador se comporta distinto según lo pongas como onClick o como listener manual, no estás loco: estás viendo los dos árboles a la vez.
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:
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.
La idea que corona este nivel es que en Solid la posición en el DOM y la posición en el grafo reactivo son dos coordenadas independientes, y Portal es la herramienta que las desacopla a voluntad. Un portal del DOM crudo te reubica el nodo y te abandona: pierdes el contexto, reconectas listeners a mano, gestionas la limpieza tú. El Portal de Solid reubica solo la coordenada física —el nodo se inserta en body para escapar de overflow, de z-index y de transform— mientras preserva intacta la coordenada lógica: el hijo sigue siendo hijo en el árbol de owners, hereda su context, participa de su ciclo de vida y devuelve los eventos delegados a sus ancestros reactivos. Esta dualidad explica de un plumazo los tres fenómenos que confunden a quien llega: por qué el useContext funciona donde “no debería”, por qué un onClick externo caza clics del modal, y por qué al cerrar el Portal la limpieza ocurre sola. No son excepciones ni magia: son la consecuencia directa de que crear e insertar son actos distintos, y de que el árbol que importa para la reactividad nunca fue el del DOM.
- Mete un modal dentro de un contenedor con
overflow: hiddenytransform; observa cómo se recorta. Envuélvelo enPortaly comprueba que ahora se muestra completo sobre la página. - Crea un
<div id="capa-toasts">en elbodyy monta ahí tus toasts conmount; verifica que aparecen agrupados fuera del flujo. - Pon un
Providerde context arriba y consúmelo desde un componente dentro delPortal; confirma que recibe el valor. - Coloca un
onClicken el panel que envuelve alPortaly otro dentro del contenido portado; observa cómo el clic interno también dispara el externo. - Repite el punto anterior registrando el manejador externo con
addEventListenermanual y comprueba que ahí el clic del portal ya no lo alcanza.