Drawer
ImplementedSide panel dialog (portal, scrim, Escape, scroll-lock). Always renders a header row — title <h2> left, close IconButton right — above a scrolling body (flex column, flex-1 overflow-y-auto). Placements left/right, sizes. Real implementation; the organisms/drawer stories twin was removed per ADR-035.
components/molecules/Drawer/Drawer.tsxSide-anchored dialog molecule: portal, scrim, Escape, scroll lock. Placements left/right; sizes sm 288 / md 384 / lg 448 / full.
Drawer
Feedback & Overlay — Family 4
Controlled overlay: pass open + onClose; always set title or ariaLabel so the dialog is named. Traps focus (Tab/Shift+Tab cycle) and returns focus to the trigger on close. Appears instantly — no animation (tracked).
This component supports light and dark mode.
Usage
Drawer is a controlled component — pair open with a piece of local state and wire onClose so the scrim click and the Escape key dismiss it. The panel portals to document.body, locks body scroll while open, and restores it on close. Always pass title or ariaLabel so the dialog is named (WCAG 4.1.2).
import { Drawer, Button } from '@epasslive/design-system'
const [open, setOpen] = useState(false)
<Button variant="primary" onClick={() => setOpen(true)}>Open cart</Button>
<Drawer
open={open}
onClose={() => setOpen(false)}
placement="right"
size="lg"
title="Your cart"
>
<p>Cart contents go here.</p>
</Drawer>Drawer vs Modal vs CartDrawer
- Blocking, centered task (confirm, short form) → Modal (max-w sizes fit small viewports).
- Side-anchored navigation or secondary content (filters, cart, details) → Drawer (this molecule).
- A complete cart UI (lines, totals, checkout CTA, fr/en labels) → CartDrawer — an organism that composes this Drawer; bring your own chrome for anything else.
- App-level navigation on mobile → Sidebar(nav-specific chrome: localized close label and an 85vw mobile width guard).
- Drawer now traps focus and returns it to the trigger on close (mol51 step 6); Modal does not yet — if keyboard containment is required in a centered overlay, wire it via composition until Modal adopts the same fixes.
Accessibility
- Renders
role="dialog"witharia-modal="true", portalled todocument.body; named viatitle(h2 wired througharia-labelledby) orariaLabel(takes precedence). If neither is passed the dialog is unnamed — always set one (WCAG 4.1.2). - Escape closes (document keydown, stopPropagation); scrim is a real
<button>whose accessible name comes fromcloseLabel— default"Close drawer"(English); pass a localized string for non-English apps. - Body scroll is locked while open and restored on close. On open the panel container receives initial focus; while open, a focus trap cycles Tab/Shift+Tab within the panel (WCAG 2.4.3); on close, focus returns to the trigger (the element focused when the drawer opened).
- Known gap (fix tracked): no open/close animation — the panel appears and disappears instantly. Top/bottom placement is not implemented (left/right only); panels carry a
max-w-fullguard so sm/md/lg never exceed the viewport width. - Color contrast meets WCAG AA in both themes (token-driven surface/border/heading colors).
Props
open*booleanDrawer open state. Renders nothing (empty) when false.
onClose*() => voidCalled on scrim click and on Escape.
placement'left' | 'right'rightSide the panel anchors to. Physical values only (no logical start/end; no RTL mirroring; top/bottom not implemented).
size'sm' | 'md' | 'lg' | 'full'mdPanel width: sm = 288px, md = 384px, lg = 448px, full = 100vw. Panels carry a max-w-full guard — sm/md/lg never exceed the viewport width.
titlestringRenders an h2 inside the panel; its id keys aria-labelledby.
ariaLabelstringAccessible name override; takes precedence over title. If neither is set the dialog has NO accessible name (known gap).
closeLabelstring"Close drawer"Accessible name for the scrim close button. Pass a localized string (e.g. "Fermer") so screen readers announce it in the app language.
children*React.ReactNodePanel content.
idstringStable id prefix for the title element (aria-labelledby wiring); defaults to `drawer-<useId>`.
Props
Drawer
openboolean—Whether the element is open/expanded.
onClose() => void—Called when the drawer should close (Escape, scrim click, or the header close button).
titlestring—Header text. Rendered as an <h2> on the left of the header row and wired to the dialog's aria-labelledby. Omitted, the header row shows only the close button.
closeLabelstring'Close drawer'Accessible name shared by the header close IconButton and the scrim button — pass a translated string.
placementDrawerPlacement'right'Which edge the panel slides in from — 'left' | 'right'.
sizeDrawerSize'md'Panel width preset — 'sm' | 'md' | 'lg' | 'full'.
ariaLabelstring—Overrides the dialog's accessible name when no title is given.
childrenReact.ReactNode—Body content, rendered inside the scrolling region under the header. Keep scroll/overflow styling out of children — the body already scrolls.
idstringautoOverrides the generated drawer id prefix.
<Drawer />Accessibility
a11y
1 ruleKeyboard
1 ruleUsage
Import
import { Drawer } from "@epasslive/design-system"Example 1
<Drawer open={open} onClose={close} title="Your cart" closeLabel="Close cart">…</Drawer>Anti-patterns
— Do not render a second header or close control inside children
— Do not add max-h/overflow wrappers around children — the body already scrolls
— Avoid inline styles on atoms