Drawer

Pronto

Painel 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

Padrão (520px à direita)
Esquerda (filtros, navegação)
Largura grande (760px — detalhe de processo)

Microinterações

MicrointeraçãoDisparada porComportamentoTiming
Slide inopentranslateX 100% → 0 do painel + opacity 0→0.4 do overlay300ms cubic-bezier(0.32,0.72,0,1)
Slide outclose (Esc, click overlay, X)Inverso do slide in250ms cubic-bezier(0.4,0,1,1)
Body scroll lockopenoverflow:hidden no <body> (não desliza fundo)instant
Focus trapopenTab/Shift+Tab circulam apenas dentro do drawerinstant
Restore focuscloseFoco volta ao trigger que abriuinstant
Mobile fullviewport <640pxDrawer ocupa 100% da largurainstant

Acessibilidade

Acessibilidade — checklist

Teclado
EscFecha drawer
Tab / Shift+TabNavega entre elementos focáveis dentro do drawer
EnterSubmete 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

TokenValorPapel
T.surface#FFFFFFbackground do painel
T.shadowLg0 16px 40px ...sombra do drawer
T.border#E3DFD2borda do header
MOTION.slow400msslide in/out