Ir para o conteúdo

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 & Toast na gallery

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

AppBar · Tabs · Tooltip · Drawer na gallery

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

Alert · Timeline · BottomSheet na gallery

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.

SignaturePad · Lightbox · AvatarGroup na gallery

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+Tab circulam 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}) — ver Escape fecha 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).