Overlay
Componentes de overlay interrompem o fluxo principal para focar a atenção numa tarefa isolada — eles aparecem por cima da página, com backdrop, e capturam o foco até serem fechados. Use-os quando o usuário precisa lidar com algo (editar um registro, confirmar, escolher uma opção) sem perder o contexto da tela de fundo, mas sem poder ignorá-lo.
Os três compartilham o mesmo motor (portal para document.body + backdrop + Esc + focus trap + scroll lock) e diferem só na ancoragem e na vocação:
Modal— centralizado, propósito geral.Drawer— ancorado a uma borda, painel lateral.BottomSheet— ancorado embaixo, mobile-first.
Tudo é portalado
Os três renderizam em document.body, fora da árvore do componente que os invoca. Isso evita problemas de overflow: hidden / z-index de ancestrais, mas significa que estilos com escopo no pai não vazam para dentro do overlay.
Modal
Seção modal da gallery — rode localmente para interagir.
Quando usar: um fluxo central que pausa o contexto — criar/editar um registro, um wizard curto, um form que exige atenção total.
Portal + backdrop + Esc + focus trap + scroll lock.
import { useState } from "react";
import { Button, FormActions, Modal } from "tempest-react-sdk";
export function EditarPerfil({ save }: { save: () => void }) {
const [open, setOpen] = useState(false);
return (
<Modal
open={open}
onClose={() => setOpen(false)}
title="Editar perfil"
size="md"
footer={
<FormActions>
<Button variant="ghost" onClick={() => setOpen(false)}>
Cancelar
</Button>
<Button onClick={save}>Salvar</Button>
</FormActions>
}
>
<p>O formulário do perfil entra aqui.</p>
</Modal>
);
}
| Prop | Tipo | Default |
|---|---|---|
open |
boolean |
— |
onClose |
() => void |
— |
title |
ReactNode |
— |
size |
"sm" \| "md" \| "lg" \| "xl" \| "2xl" \| "3xl" |
"md" |
footer |
ReactNode |
— |
fullscreen |
boolean (ocupa 100dvh independente do size) |
false |
fullscreenOnMobile |
boolean (vira fullscreen abaixo de 640px) |
false |
closeOnBackdrop |
boolean |
true |
closeOnEsc |
boolean |
true |
Safe-area em fullscreen
Em fullscreen o Modal aplica env(safe-area-inset-*) em todos os edges, respeitando notch e barra de gestos. Use fullscreenOnMobile para um modal denso virar tela cheia abaixo de 640px em vez de espremer num cartão minúsculo.
A11y: role="dialog" + aria-modal="true" + aria-labelledby quando title é string. O foco fica preso dentro do dialog e volta ao trigger ao fechar.
Drawer
Seção navigation da gallery — rode localmente para interagir.
Quando usar: um painel lateral persistente que complementa a tela de fundo — filtros, detalhes de um item, navegação secundária. Encosta numa borda em vez de centralizar.
Side drawer. placement: left/right/top/bottom. Auto-switch pra bottom-sheet em mobile via mobilePlacement.
import { useState } from "react";
import { Button, Drawer } from "tempest-react-sdk";
export function GavetaDeFiltros({ apply }: { apply: () => void }) {
const [open, setOpen] = useState(false);
return (
<Drawer
open={open}
onClose={() => setOpen(false)}
placement="right"
mobilePlacement="bottom"
title="Filtros"
showHandle
footer={<Button onClick={apply}>Aplicar</Button>}
>
<p>O formulário do perfil entra aqui.</p>
</Drawer>
);
}
| Prop | Tipo | Default |
|---|---|---|
open |
boolean |
— |
onClose |
() => void |
— |
placement |
"left" \| "right" \| "top" \| "bottom" |
"right" |
mobilePlacement |
"left" \| "right" \| "top" \| "bottom" (override em mobile) |
— |
title |
ReactNode |
— |
footer |
ReactNode |
— |
showHandle |
boolean (drag indicator estilo bottom-sheet) |
false |
hideCloseButton |
boolean |
false |
closeOnBackdrop |
boolean |
true |
closeOnEsc |
boolean |
true |
Drawer dimensiona pelo conteúdo, não por size
Diferente do Modal, o Drawer não tem prop size — a largura/altura segue o conteúdo (e o CSS do placement). Para um painel mobile-first com largura total e altura limitada, prefira BottomSheet ou mobilePlacement="bottom".
BottomSheet
Seção feedback-extra da gallery — rode localmente para interagir.
Quando usar: ações ou escolhas mobile-first que sobem do rodapé — menu de compartilhar, opções de um item, seletor curto. É o padrão nativo de iOS/Android.
Modal ancorado na borda inferior — slide-up via animation. Otimizado pra mobile.
import { useState } from "react";
import { BottomSheet, Button, Stack } from "tempest-react-sdk";
import { Link, Mail, MessageCircle } from "lucide-react";
export function Compartilhar() {
const [open, setOpen] = useState(false);
return (
<BottomSheet open={open} onClose={() => setOpen(false)} title="Compartilhar">
<Stack gap={3}>
<Button leftIcon={<MessageCircle size={16} />}>WhatsApp</Button>
<Button leftIcon={<Mail size={16} />}>Email</Button>
<Button leftIcon={<Link size={16} />}>Copiar link</Button>
</Stack>
</BottomSheet>
);
}
| Prop | Tipo | Default |
|---|---|---|
open |
boolean |
— |
onClose |
() => void |
— |
title |
ReactNode |
— |
showHandle |
boolean |
true |
dismissOnBackdrop |
boolean |
true |
dismissOnEsc |
boolean |
true |
Safe-area automática
O BottomSheet adiciona padding-bottom respeitando env(safe-area-inset-bottom), então os controles não ficam escondidos atrás da barra de gestos em iPhones/Androids modernos.
Diferença vs Drawer: BottomSheet é sempre slide-up + max-height 90dvh + drag handle. Use Drawer quando precisa de placement variável (lateral/topo) ou de comportamento diferente entre desktop e mobile.
Cuidado ao desligar closeOnBackdrop/dismissOnBackdrop
Desabilitar o dismiss por backdrop ou Esc prende o usuário no overlay até concluir a tarefa. Faça isso só em forms verdadeiramente críticos (perda de dados) — caso contrário sempre ofereça uma saída clara, ou a navegação por teclado vira uma armadilha.
ModalsManager
Quando usar: quando você quer abrir modais e confirmações de forma imperativa — direto de um handler, sem montar
<Modal open={...}>controlado por estado local em cada lugar. Ideal para confirmações de exclusão e diálogos pontuais.
<ModalsProvider> monta uma vez perto da raiz e gerencia uma pilha de modais; useModals() expõe a API imperativa sobre os componentes Modal e ConfirmDialog já existentes.
import { ModalsProvider, useModals, Button } from "tempest-react-sdk";
// raiz do app
<ModalsProvider>
<App />
</ModalsProvider>;
// em qualquer componente abaixo do provider
function DeleteButton({ id }: { id: string }) {
const modals = useModals();
return (
<Button
variant="danger"
onClick={() =>
modals.confirm({
title: "Excluir item",
message: "Esta ação não pode ser desfeita. Continuar?",
confirmLabel: "Excluir",
danger: true,
onConfirm: async () => {
await fetch(`/api/items/${id}`, { method: "DELETE" });
},
})
}
>
Excluir
</Button>
);
}
useModals() |
Assinatura | O que faz |
|---|---|---|
open |
(options: OpenModalOptions) => string |
Empilha um modal de conteúdo; retorna o id. |
confirm |
(options: ConfirmModalOptions) => string |
Empilha um ConfirmDialog; retorna o id. |
close |
(id: string) => void |
Remove o modal com aquele id. |
closeAll |
() => void |
Remove todos os modais da pilha. |
Construído sobre os componentes existentes
open renderiza um Modal e confirm renderiza um ConfirmDialog — você herda focus trap, scroll lock, Esc e backdrop sem configurar nada. O onConfirm pode ser async: o dialog mostra loading até a promise resolver e fecha sozinho ao terminar.
Precisa do <ModalsProvider> acima
useModals() lança um erro se chamado fora de um <ModalsProvider>. Monte o provider uma única vez perto da raiz do app.
Lightbox
Seção capture-media da gallery — rode localmente para interagir.
Quando usar: visualizar foto em tela cheia com navegação — galeria de imóvel, anexos de uma ocorrência, fotos de vistoria.
Overlay role="dialog" aria-modal com foco preso dentro e rolagem da página travada. Só a imagem atual é montada; as vizinhas são pré-carregadas via Image(), então apertar → não pisca um quadro vazio.
import { Lightbox } from "tempest-react-sdk";
import { useState } from "react";
export function GaleriaDaVistoria({ fotos }: { fotos: { url: string; descricao: string }[] }) {
const [aberto, setAberto] = useState(false);
const [indice, setIndice] = useState(0);
return (
<>
<div className="tempest-grid-auto">
{fotos.map((foto, i) => (
<button key={foto.url} type="button" onClick={() => { setIndice(i); setAberto(true); }}>
<img src={foto.url} alt={foto.descricao} className="tempest-aspect-square" />
</button>
))}
</div>
<Lightbox
open={aberto}
items={fotos.map((f) => ({ src: f.url, alt: f.descricao }))}
index={indice}
onIndexChange={setIndice}
onClose={() => setAberto(false)}
/>
</>
);
}
| Prop | Tipo | Default | O que faz |
|---|---|---|---|
items |
LightboxItem[] |
— | Imagens da galeria. |
open |
boolean |
— | Controla a visibilidade. |
index |
number |
0 |
Índice exibido. |
onIndexChange |
(index: number) => void |
— | Passar isso torna o índice controlado. |
onClose |
() => void |
— | Chamado no Esc e no botão fechar. |
showThumbnails |
boolean |
true se > 1 item |
Faixa de miniaturas. |
showCounter |
boolean |
true |
Contador 3 / 12. |
loop |
boolean |
true |
Circula nas pontas. |
LightboxItem = { src, alt, caption?, thumbnail? } — alt é obrigatório: galeria de imagem sem rótulo é inutilizável em leitor de tela.
Teclado: Esc fecha · ←/→ navegam · Home/End vão pras pontas.
loop é true de propósito
Em visualizador de foto, esbarrar num fim morto na última imagem é lido como bug mais vezes do que como limite. Passe loop={false} quando a ordem tem significado (um passo-a-passo, por exemplo) — aí os botões de navegação desabilitam nas pontas.
Tela cheia
Todo overlay do SDK monta por portal, e o alvo do portal segue o elemento em
tela cheia quando existe um — Modal, Drawer, BottomSheet,
ToastProvider, Command, ContextMenu, DropdownMenu, Popover, Tooltip, HoverCard, Combobox,
MultiSelect, NavigationMenu, Menubar e o
<Portal> genérico.
Isso não é conveniência: é correção. Enquanto a página está em tela cheia, o
browser pinta apenas a subárvore do elemento em tela cheia, e document.body
está fora dela. Um overlay montado em body existe no DOM, tem caixa medida, e
não é visto nem clicado — elementFromPoint no centro dele devolve o que está
atrás. Nada lança e nada aparece no console.
import { useRef, useState } from "react";
import { Button, Modal } from "tempest-react-sdk";
export function Chamada() {
const palco = useRef<HTMLDivElement>(null);
const [aberto, setAberto] = useState(false);
return (
<div ref={palco}>
<Button onClick={() => palco.current?.requestFullscreen()}>Tela cheia</Button>
<Button onClick={() => setAberto(true)}>Áudio e vídeo</Button>
<Modal open={aberto} onClose={() => setAberto(false)} title="Áudio e vídeo">
O diálogo aparece dentro do elemento em tela cheia.
</Modal>
</div>
);
}
O alvo acompanha, não é lido uma vez
Entrar ou sair de tela cheia com um diálogo já aberto move o diálogo: o
host é estado e escuta fullscreenchange (mais o webkitfullscreenchange
que o WebKit ainda emite). O diálogo continua montado e não perde estado.
container continua mandando
<Portal container={…}> fixa o alvo e ignora a tela cheia. Use quando você
precisa de um destino específico — um root de layout, um nó fora de um
overflow.
A11y geral
- Focus trap (
Modal,Drawer,BottomSheet): o foco entra no dialog ao abrir,Tab/Shift+Tabcirculam só dentro dele — inclusive pelos painéis em portal abertos de dentro — e o foco volta ao gatilho ao fechar. Ver O foco fica dentro, painéis em portal incluídos. - Scroll lock:
body.overflow = "hidden"enquanto aberto. - Esc fecha a camada de cima (
Modal/Drawer:closeOnEsc={false};BottomSheet:dismissOnEsc={false}) — verEscapefecha uma camada por vez. aria-modal="true"indica para leitores de tela que o resto da página está bloqueado.- Backdrop: clicks fecham (
Modal/Drawer:closeOnBackdrop={false};BottomSheet:dismissOnBackdrop={false}).
O foco fica dentro, painéis em portal incluídos
Aberto, o Modal põe o foco no primeiro elemento focável (no exemplo, o botão
Fechar do cabeçalho). Daí em diante, Tab e Shift+Tab só andam dentro do
diálogo:
import { useState } from "react";
import { Button, Input, Modal, Popover } from "tempest-react-sdk";
export function Filtros({ aplicar }: { aplicar: () => void }) {
const [aberto, setAberto] = useState(false);
return (
<>
<Button onClick={() => setAberto(true)}>Abrir filtros</Button>
<Modal
open={aberto}
onClose={() => setAberto(false)}
title="Filtros"
footer={<Button onClick={aplicar}>Aplicar</Button>}
>
<Popover trigger={<Button variant="secondary">Período</Button>}>
<Input aria-label="De" />
<Input aria-label="Até" />
</Popover>
</Modal>
</>
);
}
Com o Popover aberto, o painel mora no fim do body, fora do diálogo — e mesmo
assim entra no ciclo logo depois do gatilho: Período → De → Até → Aplicar →
Fechar → Período. Um Modal aberto de dentro de outro prende o foco até
fechar e devolve ao botão que o abriu, dentro do de baixo. Ao fechar, o foco
volta a Abrir filtros.
Antes da 0.68.0, Modal, Drawer e BottomSheet declaravam aria-modal="true"
sem prender o foco. Medido no Chrome, com tecla real, na seção modal da
gallery:
| Momento | Antes | Agora |
|---|---|---|
| Logo depois de abrir | foco no botão que abriu, fora do diálogo | primeiro focável do diálogo |
Tab ×5 |
5 paradas na página atrás do backdrop | circula entre os focáveis do diálogo |
| Ao fechar | onde o Tab tinha deixado |
o botão que abriu |
O leitor de tela ouvia que a página estava bloqueada enquanto o teclado
continuava nela. O Command já prendia o foco, mas ao fechar o devolvia ao
body: o autoFocus do campo de busca tomava o foco antes de o trap anotar de
onde ele vinha.
Diálogo sem nada focável
Com hideCloseButton e conteúdo só de texto, o foco vai para o próprio
diálogo (tabIndex={-1}), e o Tab fica parado nele em vez de escapar.
Escape fecha uma camada por vez
Um Escape fecha só a camada aberta mais recentemente. O próximo Escape
fecha a de baixo:
import { useState } from "react";
import { Button, DropdownMenu, Modal } from "tempest-react-sdk";
export function EditarPedido({ cancelar }: { cancelar: () => void }) {
const [aberto, setAberto] = useState(true);
return (
<Modal open={aberto} onClose={() => setAberto(false)} title="Editar pedido">
<DropdownMenu
trigger={<Button variant="ghost">Mais ações</Button>}
items={[{ type: "item", id: "cancelar", label: "Cancelar pedido", onSelect: cancelar }]}
/>
</Modal>
);
}
Com o menu aberto, o primeiro Escape fecha o menu e devolve o foco ao gatilho
dentro do modal. O segundo fecha o modal.
Antes da 0.67.0, cada overlay escutava keydown no window e fechava em
qualquer Escape, então uma tecla fechava tudo o que estava aberto. Medido no
Chrome, com tecla real:
| Dentro de | Camada interna | Antes | Agora |
|---|---|---|---|
Modal |
DropdownMenu, Combobox, MultiSelect, Popover, ContextMenu, célula editável do DataTable |
fechava as duas | fecha só a interna |
Modal |
outro Modal |
fechava os dois | fecha só o de cima |
Drawer |
Popover |
fechava os dois | fecha só o Popover |
A regra vale para Modal, Drawer, BottomSheet, Lightbox, Popover,
ContextMenu, NavigationMenu, Menubar e Tooltip. Quem trata Escape no
próprio elemento (a lista do Combobox, o editor de célula, o DropdownMenu)
consome a tecla antes, e o overlay de fora não reage.
closeOnEsc={false} bloqueia, não deixa passar
Um Modal (ou Drawer) com Escape desligado continua no topo da pilha e
engole a tecla. Sem isso, o Escape passaria direto e fecharia o modal
de baixo, com o de cima ainda aberto.
Seu handler de Escape convive com a pilha
Se um componente seu trata Escape dentro de um overlay, chame
event.preventDefault(). A pilha ignora tecla já consumida, e o overlay de
fora continua aberto.
Resumo
| Componente | Ancoragem | Vocação | Prop de dismiss |
|---|---|---|---|
Modal |
centralizado | fluxos centrais (criar/editar) | closeOnBackdrop/closeOnEsc |
Drawer |
borda (variável) | painéis laterais persistentes | closeOnBackdrop/closeOnEsc |
BottomSheet |
borda inferior | ações mobile-first (compartilhar) | dismissOnBackdrop/dismissOnEsc |
ModalsManager |
pilha (imperativa) | abrir modais/confirmações via código | useModals().close/closeAll |
Para confirmação destrutiva pré-montada, use o ConfirmDialog (actions), construído sobre o Modal. Para abrir modais imperativamente (sem estado local), use <ModalsProvider> + useModals().
Relacionados: actions (ConfirmDialog, botões no footer) · inputs (forms dentro do overlay) · navigation (Drawer como nav secundária).



