Busca en todas las páginas de la documentación
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥
import { createPortal } from "react-dom";
function Modal({ isOpen, onClose, children }: {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
}) {
if (!isOpen) return null;
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div className="fixed inset-0 bg-black/50" onClick={onClose} />
<div className="relative bg-white rounded-lg p-6 max-w-md w-full">
{children}
</div>
</div>,
document.body
);
}
// Uso
<Modal isOpen={showModal} onClose={() => setShowModal(false)}>
<h2>Confirmar acción</h2>
<p>¿Estás seguro?</p>
</Modal>Cuándo usarlo: Cuando un componente necesita renderizarse visualmente fuera del árbol DOM de su padre (modales, dropdowns, tooltips, toasts) pero aún así participar en el árbol de eventos y contexto de React.
import { createPortal } from "react-dom";
import { useState, useEffect, useRef, useCallback, type ReactNode } from "react";
// --- Modal accesible con atrapamiento de foco ---
function Modal({
isOpen,
onClose,
title,
children,
}: {
isOpen: boolean;
onClose: () => void;
title: string;
children: ReactNode;
}) {
const dialogRef = useRef<HTMLDivElement>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
// Atrapar foco y manejar escape
useEffect(() => {
if (!isOpen) return;
previousFocusRef.current = document.activeElement as HTMLElement;
dialogRef.current?.focus();
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
onClose();
return;
}
if (e.key === "Tab" && dialogRef.current) {
const focusable = dialogRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last?.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first?.focus();
}
}
};
document.addEventListener("keydown", handleKeyDown);
document.body.style.overflow = "hidden";
return () => {
document.removeEventListener("keydown", handleKeyDown);
document.body.style.overflow = "";
previousFocusRef.current?.focus();
};
}, [isOpen, onClose]);
if (!isOpen) return null;
return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Telón de fondo */}
<div
className="fixed inset-0 bg-black/50 animate-fade-in"
onClick={onClose}
aria-hidden="true"
/>
{/* Diálogo */}
<div
ref={dialogRef}
role="dialog"
aria-modal="true"
aria-label={title}
tabIndex={-1}
className="relative bg-white rounded-xl shadow-2xl p-6 max-w-lg w-full mx-4 animate-scale-in"
>
<div className="flex justify-between items-center mb-4">
<h2 className="text-xl font-semibold">{title}</h2>
<button
onClick={onClose}
aria-label="Close dialog"
className="p-1 rounded hover:bg-gray-100"
>
X
</button>
</div>
{children}
</div>
</div>,
document.body
);
}
// --- Tooltip usando portal ---
function Tooltip({
children,
content,
}: {
children: ReactNode;
content: string;
}) {
const [visible, setVisible] = useState(false);
const [coords, setCoords] = useState({ top: 0, left: 0 });
const triggerRef = useRef<HTMLSpanElement>(null);
const show = useCallback(() => {
if (triggerRef.current) {
const rect = triggerRef.current.getBoundingClientRect();
setCoords({
top: rect.top - 8 + window.scrollY,
left: rect.left + rect.width / 2 + window.scrollX,
});
}
setVisible(true);
}, []);
return (
<>
<span
ref={triggerRef}
onMouseEnter={show}
onMouseLeave={() => setVisible(false)}
onFocus={show}
onBlur={() => setVisible(false)}
>
{children}
</span>
{visible &&
createPortal(
<div
role="tooltip"
className="absolute -translate-x-1/2 -translate-y-full px-2 py-1 bg-gray-900 text-white text-sm rounded pointer-events-none"
style={{ top: coords.top, left: coords.left }}
>
{content}
</div>,
document.body
)}
</>
);
}
// --- Uso ---
function SettingsPage() {
const [showConfirm, setShowConfirm] = useState(false);
return (
<div className="p-8">
<h1>Configuración</h1>
<Tooltip content="Esto eliminará todos tus datos">
<button
onClick={() => setShowConfirm(true)}
className="text-red-600 underline"
>
Eliminar Cuenta
</button>
</Tooltip>
<Modal
isOpen={showConfirm}
onClose={() => setShowConfirm(false)}
title="Eliminar Cuenta"
>
<p className="mb-4">Esta acción no se puede deshacer. ¿Estás seguro?</p>
<div className="flex gap-2 justify-end">
<button onClick={() => setShowConfirm(false)} className="btn-secondary">
Cancelar
</button>
<button className="btn-danger">Eliminar</button>
</div>
</Modal>
</div>
);
}Lo que esto demuestra:
role="dialog", aria-modal, aria-label)createPortal(children, domNode) renderiza children en domNode en lugar del nodo DOM padre.overflow: hidden, contenedores z-index) que recortarían las superposiciones.| Parámetro | Tipo | Propósito |
|---|---|---|
children | ReactNode | Los elementos React a renderizar en el portal |
domNode | Element o DocumentFragment | El nodo DOM en el que renderizar |
key (opcional) | string | Clave única para el portal |
| Valor de retorno | ReactPortal | Un elemento React especial que representa el portal |
Contenedor portal dinámico - crear y limpiar un nodo DOM dedicado:
function usePortalContainer(id: string) {
const [container, setContainer] = useState<HTMLElement | null>(null);
useEffect(() => {
let element = document.getElementById(id);
if (!element) {
element = document.createElement("div");
element.id = id;
document.body.appendChild(element);
}
setContainer(element);
return () => {
if (element && element.childNodes.length === 0) {
element.remove();
}
};
}, [id]);
return container;
}
function ToastContainer({ children }: { children: ReactNode }) {
const container = usePortalContainer("toast-root");
if (!container) return null;
return createPortal(children, container);
}Portal compatible con servidor - protegerse contra document faltante:
function ClientPortal({ children }: { children: ReactNode }) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return createPortal(children, document.body);
}createPortal devuelve ReactPortal, que es un ReactNode válido.Element o DocumentFragment, no null. Protégete con un condicional o verificación de estado.document no está disponible durante SSR.La herencia CSS se rompe - Los estilos heredados de elementos DOM padres (fuente, color) no se aplican al contenido portalizado ya que vive en otro lugar del DOM. Solución: Asegúrate de que el contenido portalizado defina sus propios estilos base o usa un envoltorio de reinicio de CSS.
Errores de SSR - document.body no existe durante la renderización del lado del servidor. Solución: Protege el renderizado del portal con una verificación de montaje impulsada por useEffect o usa "use client".
Múltiples portales y guerras de z-index - Varios portales renderizándose a document.body pueden apilarse de forma impredecible. Solución: Usa una raíz de portal dedicada con un contexto de apilamiento, o gestiona z-index a través de un gestor de portales.
Confusión de burbujeo de eventos - Los eventos burbujean a través del árbol de React, no del árbol DOM. Un clic dentro de un portal modal burbuja al padre React, lo que puede desencadenar manejadores no deseados. Solución: Usa e.stopPropagation() en el contenido del portal si el padre tiene manejadores de clics en competencia.
Gestión del foco - Abrir un portal sin mover el foco deja a los usuarios de teclado varados. Solución: Mueve el foco al portal al abrir y restáuralo al cerrar, como se muestra en el ejemplo funcional.
| Enfoque | Compensación |
|---|---|
createPortal | Control total; gestión manual de foco y accesibilidad |
Elemento HTML <dialog> | Modal nativa con atrapamiento de foco integrado; estilo limitado |
| Radix Dialog / Headless UI | Accesibilidad integrada completa; dependencia extra |
CSS position: fixed sin portal | Más simple; se rompe dentro de padres overflow: hidden o transform |
Popover API (atributo popover) | API de navegador nativa; compatibilidad limitada del navegador e integración con React |
createPortal(children, domNode) renderiza elementos hijos React en un nodo DOM fuera de la jerarquía DOM padre.overflow: hidden, contenedores z-index) que recortarían las superposiciones.document.body).e.stopPropagation() si es necesario.const focusable = dialogRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last?.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first?.focus();
}document.body), por lo que no hereda estilos padres como fuente, color o altura de línea.document.body no existe durante SSR.useEffect o usa "use client" en Next.js.const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return createPortal(children, document.body);createPortal devuelve ReactPortal, que es un ReactNode válido.Element o DocumentFragment, no null.null.Element | DocumentFragment.HTMLElement | null y renderiza condicionalmente.const [container, setContainer] = useState<HTMLElement | null>(null);function usePortalContainer(id: string) {
const [container, setContainer] = useState<HTMLElement | null>(null);
useEffect(() => {
let el = document.getElementById(id);
if (!el) {
el = document.createElement("div");
el.id = id;
document.body.appendChild(el);
}
setContainer(el);
return () => { if (el && el.childNodes.length === 0) el.remove(); };
}, [id]);
return container;
}.focus() en él en la función de limpieza.document.body pueden apilarse de forma impredecible porque comparten el mismo contexto de apilamiento.Revisado por Chris St. John·Última actualización: 10 jul 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥