Modal
ProntoDiá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
Microinterações
| Microinteração | Disparada por | Comportamento | Timing |
|---|---|---|---|
| Open scale + fade | open | Modal scale 0.95→1 + opacity 0→1 + overlay fade | 250ms cubic-bezier(0.32,0.72,0,1) |
| Close | Esc/overlay/X/cancel | Inverso do open | 200ms ease |
| Body scroll lock | open | overflow:hidden no <body> | instant |
| Focus trap | open | Foco circula apenas dentro do modal | instant |
| Auto-focus action principal | open | Foco vai pro botão Cancelar (não destrutivo) | após 50ms |
| Shake on outside click (variante crítica) | click overlay em modal destrutivo | Modal "treme" lembrando confirmar deliberado | 300ms |
Acessibilidade
Acessibilidade — checklist
Teclado
| Esc | Fecha modal (cancela) |
| Tab / Shift+Tab | Navega elementos focáveis dentro |
| Enter | Aciona 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
| Token | Valor | Papel |
|---|---|---|
T.surface | #FFFFFF | background do modal |
T.shadowLg | 0 16px 40px ... | sombra |
T.carmim | #A32E2E | destruktive button |
RADIUS.xl | 24 | borderRadius |