Menu

Portals no React: createPortal para modais e tooltips

createPortal renderiza parte de um componente em outro nó do DOM, como document.body, enquanto ele continua no mesmo lugar da árvore do React. Use-o para modais, tooltips e menus que precisam escapar de overflow hidden e do empilhamento de z-index.

Esta página tem editores executáveis - edite, execute e veja a saída na hora.

Um portal renderiza parte de um componente em outro lugar do DOM, normalmente document.body, enquanto ele continua no mesmo lugar da árvore do React. Você cria um com createPortal(children, domNode) de react-dom. Portals são a forma de modais, tooltips e menus dropdown escaparem de um pai que tem overflow: hidden ou um contexto de empilhamento próprio.

A caixa abaixo corta tudo o que sai dela. Abra as duas dicas.

A dica normal é cortada na borda tracejada. A dica com portal aparece inteira, porque o nó DOM dela é filho de <body>, não da caixa. Apague overflow: 'hidden' da caixa e a dica normal deixa de ser cortada.

A sintaxe

import { createPortal } from 'react-dom';

createPortal(children, domNode, key?)
  • children é qualquer JSX: um elemento, um fragment, um componente.
  • domNode é um elemento do DOM que já existe, como document.body ou document.getElementById('modal-root'). Ele precisa existir quando o portal renderiza.
  • key é opcional, para quando você renderiza uma lista de portals.

createPortal retorna algo que você coloca no JSX como qualquer elemento. Ele não renderiza nada na posição do próprio pai no DOM.

Um modal em document.body

Um modal é o caso clássico. Dentro de um card com transform, um overlay com position: fixed é posicionado em relação a esse card em vez da janela, e o overflow: hidden do card então o corta. Renderizado em document.body, ele cobre toda a viewport.

Abra o modal e depois aperte Escape ou clique no overlay escuro para fechá-lo. O foco vai para o botão Close quando ele abre. Agora retorne o overlay sem createPortal (tire a chamada e o argumento document.body): o overlay encolhe para o tamanho do card, porque o transform faz do card o bloco de contenção para elementos fixos.

Os eventos sobem pela árvore do React

Um portal muda onde o nó do DOM mora, não onde o componente mora. Os eventos do React sobem pela árvore do React, então um clique dentro de um portal chega ao onClick do componente que o renderizou, mesmo que no DOM o botão seja filho de <body>.

Listeners nativos são diferentes. Um listener adicionado com addEventListener segue a árvore do DOM e nunca vê o clique.

Clique em "Inside the wrapper": tanto o handler do React quanto o listener nativo registram. Clique em "In a portal": só o handler do React registra. Na tela, o botão do portal fica fora do wrapper tracejado, mas o React ainda o trata como filho.

Normalmente é isso que você quer: um onClick no pai de um menu vê os cliques nos itens do portal do menu, e os providers de context acima do componente valem dentro do portal também. Isso pode surpreender em uma lógica de "clicar fora para fechar". Um onClick em um wrapper que fecha um menu também dispara para cliques dentro do portal do menu, então interrompa a propagação dentro do menu (e.stopPropagation(), tratado na página de eventos) ou use um listener nativo em document e confira se o alvo está dentro do nó DOM do portal.

Portals no seu próprio contêiner

document.body é o alvo mais simples. Alguns apps adicionam um contêiner dedicado ao index.html para que todos os overlays compartilhem um lugar e uma ordem de empilhamento:

<body>
    <div id="root"></div>
    <div id="modal-root"></div>
</body>
createPortal(<Modal />, document.getElementById('modal-root'));

Com renderização no servidor (Next.js e outros frameworks), document não existe no servidor. Renderize o portal só depois que o componente montar, por exemplo atrás de um estado mounted que um efeito define como true.

Quando o portal precisa ser posicionado ao lado do elemento que o abre, meça esse elemento antes. O primeiro exemplo o mede no handler de clique; para um tooltip que não pode piscar, meça em useLayoutEffect, que executa antes de o navegador pintar a tela.

Modais acessíveis

Mover a marcação para document.body não faz nada, sozinho, por quem usa teclado e leitor de tela. Um modal também precisa de:

  • role="dialog" e aria-modal="true", com aria-labelledby apontando para o título.
  • Foco levado para dentro do diálogo quando ele abre (o exemplo foca o Close) e de volta para o botão que o abriu quando ele fecha.
  • Escape para fechar.
  • Foco mantido dentro enquanto ele está aberto, para que o Tab não ande pela página por trás. Definir o atributo inert na raiz do app enquanto o modal está aberto bloqueia o foco e os cliques ali.

O elemento nativo <dialog> cuida da maior parte disso por você. Aberto com dialogRef.current.showModal(), ele é desenhado na camada superior do navegador, acima de qualquer z-index, torna o resto da página inerte e fecha com Escape. Ele não precisa de portal, então é um bom padrão para diálogos simples de confirmação; os portals continuam sendo a ferramenta para tooltips, menus e overlays personalizados.

Por que só z-index não basta

Muita gente recorre a um portal depois que um z-index alto não consegue colocar um menu acima do resto da página. O motivo são os contextos de empilhamento. Um elemento com position e um z-index, uma opacity menor que 1, um transform, um filter ou isolation: isolate inicia um novo contexto de empilhamento, e os valores de z-index dos filhos dele só competem entre si lá dentro. Um filho com z-index: 9999 dentro de um card com z-index: 1 continua abaixo de um card irmão com z-index: 2.

Um portal tira o elemento de todos esses ancestrais. Como filho de <body>, o z-index dele é comparado com os elementos de nível superior da página, então um valor modesto como 1000 basta para overlays.

Erros comuns

Usar como alvo um nó que ainda não existe. document.getElementById('modal-root') retorna null se o elemento não existe, e createPortal lança "Target container is not a DOM element". Confira o HTML ou use document.body.

Criar o nó de destino durante a renderização. Escrever document.createElement('div') no corpo do componente cria um nó novo a cada renderização. Crie-o uma vez em um efeito ou use um contêiner fixo.

Popups que não acompanham o elemento que os abre. Um tooltip posicionado a partir de getBoundingClientRect() não acompanha o elemento quando a página rola ou é redimensionada. Recalcule em scroll e resize (e remova esses listeners na limpeza do efeito) ou feche o tooltip quando a página rolar.

Perguntas frequentes

O que é um portal no React?

Um jeito de renderizar filhos em um nó do DOM fora do elemento DOM do componente pai. Você cria um com createPortal(children, domNode) de react-dom. Os filhos mantêm o lugar na árvore do React, então props, estado e context funcionam normalmente.

Quando devo usar um portal?

Quando algo precisa aparecer acima ou fora do seu contêiner: modais, tooltips, menus dropdown, notificações. Um pai com overflow: hidden, um transform ou um contexto de empilhamento próprio iria, do contrário, cortá-lo ou escondê-lo.

Os eventos saem de um portal?

Sim, pela árvore do React. Um clique dentro de um portal chega aos handlers de onClick dos pais no React, mesmo que o nó do DOM more em document.body. Listeners nativos adicionados com addEventListener seguem a árvore do DOM.

O context funciona dentro de um portal?

Sim. O context, como os eventos, segue a árvore do React. Um modal renderizado em document.body continua lendo o tema ou o usuário dos providers acima do componente que o criou.

Preciso de um portal para um modal?

Nem sempre. O elemento nativo <dialog> aberto com showModal() é desenhado na camada superior do navegador, acima de qualquer z-index, com o controle de foco embutido. Um portal é a escolha usual para modais personalizados e para tooltips e menus.

Ilustração das linguagens de programação do Coddy

Aprenda a programar com o Coddy

COMEÇAR