Modal

Pronto

Diálogo central bloqueante com overlay. Para confirmações destrutivas, decisões críticas, formulários focados que precisam atenção total.

Usar quando

Confirmar ação destrutiva (excluir, encerrar). Decisão importante (cancelar contrato). Form curto que precisa atenção total.

Não usar quando

Edição com contexto da listagem (use Drawer). Feedback rápido (use Toast). Seleção de opção (use Select/Combobox).

Variantes

Confirmação destrutiva
Decisão importante (info)
Form modal (largo)

Microinterações

MicrointeraçãoDisparada porComportamentoTiming
Open scale + fadeopenModal scale 0.95→1 + opacity 0→1 + overlay fade250ms cubic-bezier(0.32,0.72,0,1)
CloseEsc/overlay/X/cancelInverso do open200ms ease
Body scroll lockopenoverflow:hidden no <body>instant
Focus trapopenFoco circula apenas dentro do modalinstant
Auto-focus action principalopenFoco vai pro botão Cancelar (não destrutivo)após 50ms
Shake on outside click (variante crítica)click overlay em modal destrutivoModal "treme" lembrando confirmar deliberado300ms

Acessibilidade

Acessibilidade — checklist

Teclado
EscFecha modal (cancela)
Tab / Shift+TabNavega elementos focáveis dentro
EnterAciona botão primário
ARIA esperado
  • role="dialog" aria-modal="true"
  • aria-labelledby="modal-title"
  • aria-describedby="modal-desc"
  • <main> de fundo: inert (HTML5) ou aria-hidden="true"
  • Botão X: aria-label="Fechar"
Notas
  • Auto-focus em "Cancelar" (não em "Excluir") para evitar destruição acidental.
  • Em modais destrutivos, considere requerer texto de confirmação ("Digite EXCLUIR pra confirmar").
  • Esc cancela. Click overlay cancela. X cancela. Enter confirma SE foco no primary.
  • Focus restore ao trigger no fechamento.

Código

'use client';
import { useEffect } from 'react';
import { T, RADIUS } from '@/lib/tokens';

interface ModalProps { open: boolean; onClose: () => void; title: string; children: React.ReactNode; primary?: { label: string; onClick: () => void; destructive?: boolean } }

export function Modal({ open, onClose, title, children, primary }: ModalProps) {
  useEffect(() => {
    if (!open) return;
    document.body.style.overflow = 'hidden';
    const onEsc = (e: KeyboardEvent) => e.key === 'Escape' && onClose();
    window.addEventListener('keydown', onEsc);
    return () => { document.body.style.overflow = ''; window.removeEventListener('keydown', onEsc); };
  }, [open, onClose]);
  if (!open) return null;
  return (
    <div role="dialog" aria-modal="true" aria-labelledby="modal-title" style={{ position: 'fixed', inset: 0, zIndex: 50, display: 'grid', placeItems: 'center' }}>
      <button onClick={onClose} aria-label="Fechar" style={{ position: 'absolute', inset: 0, background: 'rgba(10,10,10,0.45)', border: 'none' }} />
      <div style={{
        position: 'relative', background: T.surface, borderRadius: RADIUS.xl, boxShadow: T.shadowLg,
        maxWidth: 480, width: '90vw', padding: 24,
        animation: 'lb-modal-in 250ms cubic-bezier(0.32,0.72,0,1)',
      }}>
        <h2 id="modal-title">{title}</h2>
        {children}
      </div>
    </div>
  );
}

Regras

Faça

  • Largura: 480px sm (confirmação), 640px md (form), 800px lg (relatório).
  • Overlay 45% black (mais opaco que drawer pq foco maior).
  • Auto-focus em Cancelar (não em destruktive).
  • Esc / overlay / X / Cancelar fecham.
  • Em destruktive, considere texto de confirmação obrigatório.
  • Focus trap obrigatório.

Não faça

  • Não use modal para feedback (use Toast).
  • Não use modal para edição contextual (use Drawer).
  • Não auto-focus em botão destruktive (clique acidental).
  • Não permita scroll do fundo.
  • Não tenha múltiplos modais abertos (anti-pattern).
  • Não use modal só pra mostrar info — considere Tooltip ou Popover.

Tokens usados

TokenValorPapel
T.surface#FFFFFFbackground do modal
T.shadowLg0 16px 40px ...sombra
T.carmim#A32E2Edestruktive button
RADIUS.xl24borderRadius