Drawer
ProntoPainel lateral overlayed no canto direito (520px). Para edição/detalhe sem perder contexto da listagem por trás.
Usar quando
Editar item de tabela sem trocar de página. Detalhe de processo ao clicar linha. Configurações relacionadas. Filtros avançados em mobile.
Não usar quando
Conteúdo amplo (use modal ou nova página). Confirmação simples (use Modal). Toast de feedback (use Toast).
Variantes
Microinterações
| Microinteração | Disparada por | Comportamento | Timing |
|---|---|---|---|
| Slide in | open | translateX 100% → 0 do painel + opacity 0→0.4 do overlay | 300ms cubic-bezier(0.32,0.72,0,1) |
| Slide out | close (Esc, click overlay, X) | Inverso do slide in | 250ms cubic-bezier(0.4,0,1,1) |
| Body scroll lock | open | overflow:hidden no <body> (não desliza fundo) | instant |
| Focus trap | open | Tab/Shift+Tab circulam apenas dentro do drawer | instant |
| Restore focus | close | Foco volta ao trigger que abriu | instant |
| Mobile full | viewport <640px | Drawer ocupa 100% da largura | instant |
Acessibilidade
Acessibilidade — checklist
Teclado
| Esc | Fecha drawer |
| Tab / Shift+Tab | Navega entre elementos focáveis dentro do drawer |
| Enter | Submete o form principal (se houver) |
ARIA esperado
- role="dialog" aria-modal="true"
- aria-labelledby="drawer-title"
- inert no <main> de fundo (HTML5 inert attribute ou aria-hidden)
- Botão fechar: aria-label="Fechar"
Notas
- Focus trap obrigatório — Tab não escapa do drawer.
- Esc fecha + foco volta ao trigger.
- Click no overlay fecha (overlay é botão "fechar" implícito).
- Body scroll lock para não rolar fundo.
- Em mobile, drawer 100% width (não 520px).
Código
'use client';
import { useEffect } from 'react';
import { X } from 'lucide-react';
import { T, MOTION, transition } from '@/lib/tokens';
interface DrawerProps { open: boolean; onClose: () => void; title: string; children: React.ReactNode; width?: number; side?: 'left' | 'right' }
export function Drawer({ open, onClose, title, children, width = 520, side = 'right' }: DrawerProps) {
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="drawer-title" style={{ position: 'fixed', inset: 0, zIndex: 50 }}>
<button type="button" onClick={onClose} aria-label="Fechar"
style={{ position: 'absolute', inset: 0, background: 'rgba(10,10,10,0.40)', cursor: 'default', border: 'none' }} />
<aside style={{
position: 'absolute', top: 0, [side]: 0, height: '100%',
width, maxWidth: '100vw',
background: T.surface, boxShadow: T.shadowLg,
animation: `lb-drawer-in 300ms cubic-bezier(0.32,0.72,0,1)`,
}}>
<header><h2 id="drawer-title">{title}</h2><button onClick={onClose}><X /></button></header>
{children}
</aside>
</div>
);
}Regras
Faça
- ✓Largura padrão 520px (média entre 480 e 560).
- ✓Overlay 40% black (rgba(10,10,10,0.40)).
- ✓Focus trap obrigatório.
- ✓Esc fecha; click overlay fecha; X fecha.
- ✓Body scroll lock enquanto aberto.
- ✓Mobile: 100% width.
Não faça
- ✗Não use drawer para conteúdo amplo (use página ou modal grande).
- ✗Não esconda botão X.
- ✗Não permita scroll do fundo.
- ✗Não esqueça focus trap (a11y crítico).
- ✗Não force animação rápida (>150ms ou <500ms).
Tokens usados
| Token | Valor | Papel |
|---|---|---|
T.surface | #FFFFFF | background do painel |
T.shadowLg | 0 16px 40px ... | sombra do drawer |
T.border | #E3DFD2 | borda do header |
MOTION.slow | 400ms | slide in/out |