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
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.
const [open, setOpen] = useState(false);
<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>
}
>
<ProfileForm />
</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 |
dismissOnBackdrop |
boolean |
true |
dismissOnEsc |
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
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.
<Drawer
open={open}
onClose={() => setOpen(false)}
placement="right"
mobilePlacement="bottom" // vira bottom sheet em mobile
title="Filtros"
showHandle // drag indicator visual quando vira bottom-sheet
footer={<Button onClick={apply}>Aplicar</Button>}
>
<FilterForm />
</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
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.
<BottomSheet open={open} onClose={() => setOpen(false)} title="Compartilhar">
<Stack gap={3}>
<Button leftIcon={<MessageCircle />}>WhatsApp</Button>
<Button leftIcon={<Mail />}>Email</Button>
<Button leftIcon={<Link />}>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
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.
A11y geral
- Focus trap: Tab circula apenas dentro do dialog. Restaura o foco no trigger ao fechar.
- Scroll lock:
body.overflow = "hidden"enquanto aberto. - Esc fecha (
Modal/BottomSheet:dismissOnEsc={false};Drawer:closeOnEsc={false}). aria-modal="true"indica para leitores de tela que o resto da página está bloqueado.- Backdrop: clicks fecham (
Modal/BottomSheet:dismissOnBackdrop={false};Drawer:closeOnBackdrop={false}).
Resumo
| Componente | Ancoragem | Vocação | Prop de dismiss |
|---|---|---|---|
Modal |
centralizado | fluxos centrais (criar/editar) | dismissOnBackdrop/dismissOnEsc |
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).