Busque em todas as páginas da documentação
🤖 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 ação</h2>
<p>Tem certeza?</p>
</Modal>Quando usar isso: Quando um componente precisa ser renderizado visualmente fora da árvore DOM de seu pai (modais, dropdowns, tooltips, toasts), mas ainda assim participar da árvore de eventos e contexto do React.
import { createPortal } from "react-dom";
import { useState, useEffect, useRef, useCallback, type ReactNode } from "react";
// --- Modal acessível com armadilha 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);
// Prende o foco e lida com a tecla 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">
{/* Plano de fundo */}
<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="Fechar diálogo"
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>Configurações</h1>
<Tooltip content="Isso excluirá todos os seus dados">
<button
onClick={() => setShowConfirm(true)}
className="text-red-600 underline"
>
Excluir Conta
</button>
</Tooltip>
<Modal
isOpen={showConfirm}
onClose={() => setShowConfirm(false)}
title="Excluir Conta"
>
<p className="mb-4">Esta ação não pode ser desfeita. Tem certeza?</p>
<div className="flex gap-2 justify-end">
<button onClick={() => setShowConfirm(false)} className="btn-secondary">
Cancelar
</button>
<button className="btn-danger">Excluir</button>
</div>
</Modal>
</div>
);
}O que isso demonstra:
role="dialog", aria-modal, aria-label)createPortal(children, domNode) renderiza children em domNode em vez do nó DOM do pai.overflow: hidden, contêineres z-index) que cortariam overlays.| Parâmetro | Tipo | Propósito |
|---|---|---|
children | ReactNode | Os elementos React a serem renderizados no portal |
domNode | Element ou DocumentFragment | O nó DOM para renderizar |
key (opcional) | string | Chave única para o portal |
| Valor de retorno | ReactPortal | Um elemento React especial representando o portal |
Contêiner de portal dinâmico - crie e limpe um nó 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 compatível com servidor - proteja contra document ausente:
function ClientPortal({ children }: { children: ReactNode }) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return createPortal(children, document.body);
}createPortal retorna ReactPortal, que é um ReactNode válido.Element ou DocumentFragment, não null. Proteja com uma verificação condicional ou de estado.document não está disponível durante o SSR.Herança CSS quebrada - Estilos herdados de elementos DOM pais (fonte, cor) não se aplicam ao conteúdo portado, pois ele reside em outro lugar no DOM. Correção: Certifique-se de que o conteúdo portado defina seus próprios estilos base ou use um wrapper de reset CSS.
Erros de SSR - document.body não existe durante a renderização do lado do servidor. Correção: Proteja a renderização do portal com uma verificação de montagem acionada por useEffect ou use "use client".
Múltiplos portais e guerras de z-index - Vários portais renderizando para document.body podem empilhar de forma imprevisível. Correção: Use uma raiz de portal dedicada com um contexto de empilhamento, ou gerencie o z-index através de um gerenciador de portais.
Confusão no borbulhamento de eventos - Eventos borbulham através da árvore React, não da árvore DOM. Um clique dentro de um portal modal borbulha para o pai React, o que pode acionar manipuladores não intencionais. Correção: Use e.stopPropagation() no conteúdo do portal se o pai tiver manipuladores de clique concorrentes.
Gerenciamento de foco - Abrir um portal sem mover o foco deixa os usuários de teclado presos. Correção: Mova o foco para dentro do portal ao abrir e restaure-o ao fechar, como mostrado no exemplo de trabalho.
| Abordagem | Contrapartida |
|---|---|
createPortal | Controle total; gerenciamento manual de foco e acessibilidade |
Elemento HTML <dialog> | Modal nativo com armadilha de foco integrada; estilo limitado |
| Radix Dialog / Headless UI | Acessibilidade completa integrada; dependência extra |
CSS position: fixed sem portal | Mais simples; quebra dentro de pais com overflow: hidden ou transform |
API Popover (atributo popover) | API nativa do navegador; suporte limitado do navegador e integração React |
createPortal(children, domNode) renderiza filhos React em um nó DOM fora da hierarquia DOM do pai.overflow: hidden, contêineres z-index) que cortariam overlays.document.body).e.stopPropagation() se necessário.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), portanto, não herda estilos pais como fonte, cor ou altura da linha.document.body não existe durante o SSR.useEffect ou use "use client" no Next.js.const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) return null;
return createPortal(children, document.body);createPortal retorna ReactPortal, que é um ReactNode válido.Element ou DocumentFragment, não null.null.Element | DocumentFragment.HTMLElement | null e renderizar 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() nele na função de limpeza.document.body podem empilhar de forma imprevisível porque compartilham o mesmo contexto de empilhamento.Revisado por Chris St. John·Última atualização: 10 de jul. de 2026
🤖 Read the SystemsArchitect.io Blog for over 100+ cloud architecture articles 🔥