Designer view
Drawer
An edge-anchored panel that slides in from a viewport edge to surface contextual content alongside (or temporarily replacing) the underlying view. Distinct from Modal in two ways: positioning is edge-anchored rather than centered, and the modal/non-modal behaviour is a property rather than the defining trait. Common uses: filter panels, detail-view side-sheets, mobile navigation, settings flyouts.
When to use
Use
When contextual content should surface alongside (or temporarily replacing) the underlying view from a viewport edge — filter panels, detail-view side-sheets, mobile navigation, mobile bottom-sheet pickers. Drawer is preferred over Modal when the relationship to the underlying content matters and the user benefits from continued visual context.
Avoid
For blocking decisions or destructive confirmations — that is `Modal[role=alertdialog]`. For contextual content tied to a specific trigger — that is `Popover`. For non-blocking inline notifications — that is `Toast` or `Banner`. For a permanent column that is part of the layout — that is a layout primitive, not a Drawer.
Versus related
- modal
`Modal` always centres and is always modal; `Drawer` is edge-anchored and can be modal or non-modal. Drawer preserves spatial relationship to the underlying content (the page is still partly visible); Modal severs that relationship.
- popover
`Popover` is anchored to a *trigger element* and floats near it; `Drawer` is anchored to a *viewport edge*. Popover is always non-modal and dismissable on outside click without ceremony; Drawer can be modal and may require explicit dismissal. Use Popover for content the user reads and dismisses; Drawer for content the user *acts in*.
- alert
`Alert` is a non-blocking inline message announced via `aria-live`. Drawer is a navigable surface; Alert is a static message. The `Drawer[variant=navigation]` is similar to a SidebarNav, not to Alert.
Figma anatomy
| Slot | Figma type | Hint |
|---|---|---|
backdrop | frame | Full-bleed frame with semi-transparent fill; conditional on modal variant |
container | frame | Edge-anchored auto-layout frame; size variant drives inline-size for left/right, block-size for top/bottom |
header | frame | Auto-layout horizontal frame at the inline-start edge of the container |
title | text | Heading text style; bound to a component property for content |
close-button | instance | Icon button instance; bound to component slot |
body | frame | Auto-layout vertical frame; min-block-size drives "comfortable" density |
footer | frame | Auto-layout horizontal frame; right-aligned actions in LTR |
handle | rectangle | 4×40px pill at the leading edge; visibility bound to "swipeable" property |
Token usage per slot
backdrop- color
- background
color.surface.scrim
- background
container- spacing
- padding
spacing.comfortable
- padding
- radius
- corner
radius.lg
- corner
- color
- background
color.surface.raised - border
color.border.subtle
- background
- elevation
- shadow
elevation.overlay
- shadow
header- spacing
- padding
spacing.compact - gap
spacing.compact
- padding
title- color
- foreground
color.text.primary
- foreground
- typography
- size
text.lg - weight
weight.semibold - lineHeight
leading.tight
- size
close-button- spacing
- padding
spacing.tight
- padding
- radius
- corner
radius.sm
- corner
- color
- foreground
color.text.muted - ring
color.border.focus
- foreground
body- spacing
- padding
spacing.comfortable
- padding
- color
- foreground
color.text.primary
- foreground
- typography
- size
text.md - lineHeight
leading.normal
- size
footer- spacing
- padding
spacing.compact - gap
spacing.compact
- padding
handle- radius
- corner
radius.pill
- corner
- color
- background
color.border.subtle
- background
Figma ↔ Code property map
| Figma | Type | Code | Notes |
|---|---|---|---|
Variant | Variant | variant | Maps modal / non-modal / navigation. Drives ARIA role. |
Side | Variant | side | start / end / top / bottom. Encoded as Variant for preview-time positioning review. |
Size | Variant | size | sm / md / lg / full. Drives container inline-size (left/right) or block-size (top/bottom). |
Dismissible | Boolean | dismissible | Toggles close-button visibility plus Escape and backdrop-click handling. Some host libraries combine the three; canonical treats them as one switch. |
Swipeable | Boolean | swipeable | Toggles handle visibility plus swipe-gesture wiring. Generally true on mobile breakpoints, false on desktop. |
Has Backdrop | Boolean | backdrop | Derived in canon from `variant: modal` (true) vs `non-modal` (false); Figma may expose it as a separate Boolean for design preview. |
Title | Text | title | — |
Has Header | Boolean | header | — |
Body | Instance Swap | body | Swaps the body content slot (form, prose, list, custom layout). |
Has Footer | Boolean | footer | — |
Footer | Instance Swap | footer | — |
Motion
| Transition | Duration token |
|---|---|
open | motion.duration.base |
close | motion.duration.base |
backdrop | motion.duration.fast |
dragSnap | motion.duration.fast |
Responsive behaviour
| Breakpoint | Change |
|---|---|
breakpoint.sm | At and below, all drawer variants render as the `bottom` side with `size: full` regardless of authored `side` and `size` properties — narrow viewports do not afford left/right edge anchoring without crowding the underlying content. Navigation drawers may further degrade to a fullscreen overlay (`size: full`, suppressed handle). |
breakpoint.md | Above this width, all variants render as authored. The `navigation` variant typically pins as a permanent column on desktop (non-modal, `size: md` minimum); below `md` it collapses back to the modal-with-trigger pattern. |
Internationalisation
RTL · mirroring
The `side` property is logical, not physical: `start` slides from the inline-start edge (visual right in RTL), `end` from the inline-end (visual left in RTL). Implemented via `inset-inline-start` / `inset-inline-end`, not `left` / `right`. Top and bottom sides are direction-neutral. Close-button positioning inside the header follows the existing logical pattern (close on inline-end). Swipe-to-dismiss gesture direction reverses for `start` and `end` sides under RTL.
Text expansion
Drawer width is sized in CSS (`size: sm | md | lg | full`); long titles and labels grow naturally within the inline-size. For `size: sm`, German and Russian titles risk wrap or truncation — `size: md` is the safer canonical default in long-text locales. Body content scrolls; footer buttons may wrap to two rows under heavy expansion.
Variants, properties, states
Variants
Structurally different versions of the component.
modalnon-modalnavigation Properties
The same component, parameterised.
| Property | Type |
|---|---|
side | start | end | top | bottom |
size | sm | md | lg | full |
dismissible | boolean |
swipeable | boolean |
States
Browser/user-driven (interactive) vs. app-driven (data).
| Kind | States |
|---|---|
interactive | focus-visiblehover |
data | closedopeningopenclosingdragging |
State transitions
| From | To | Trigger |
|---|---|---|
closed | opening | User activates the trigger that owns the drawer (button click, link activation, programmatic `open()`). For modal drawers the previously-focused element is captured for restoration on close; for non-modal, focus does not move automatically. |
opening | open | The slide-in animation completes (or, under `prefers-reduced-motion: reduce`, immediately after `closed → opening`). Modal: focus moves into the drawer, focus trap engages, siblings become `inert`. Non-modal: drawer is ready, focus stays where it was. |
open | closing | User dismisses via the close button, Escape (when `dismissible: true`), backdrop click (modal + dismissible), or swipe gesture (when `swipeable: true`); or the primary action commits and programmatically requests close. |
closing | closed | The slide-out animation completes (or immediately under reduced motion). Modal: drawer is removed from the AT tree, `inert` is released, focus restores to the captured trigger. Non-modal: drawer is removed; focus stays where it was. |
open | dragging | User initiates a swipe gesture on the handle or container edge (`swipeable: true`). The drawer follows the pointer position with reduced damping; release-velocity above threshold triggers `dragging → closing`, below resets to `dragging → open`. |
dragging | open | User releases the swipe with insufficient velocity / distance to dismiss; drawer animates back to its open position. |
dragging | closing | User releases the swipe with sufficient velocity / distance to dismiss; drawer animates the rest of the way out via the standard close path. |
Figma↔Code mismatches
- 01 Figma
A drawer drawn as a static side panel that's just always there
CodeA portal-mounted panel with focus trap, escape handling, slide animation, and `inert` toggling
ConsequenceThe Figma artifact captures the visual end-state but encodes none of the open/close lifecycle, the modality, or the focus contract. Designers approximating the canonical drawer from the Figma file may not realise modal drawers need `inert`, escape handling, and focus trap; developers may ship something that looks like a drawer but escapes attention semantics.
CorrectDocument the open/close lifecycle and modality contract in the canonical reference. The Figma file captures visual states (closed, opening, open, dragging); the canonical reference and a11y notes document the portal mount, focus trap, escape, and `inert` for modal drawers.
- 02 Figma
Modal drawer and non-modal drawer drawn as one variant set
CodeTwo distinct accessibility contracts (focus trap + inert vs none) driven by the variant prop
ConsequenceDesigners may pick "drawer" without realising the modality choice determines whether keyboard users can tab past the drawer; the canonical contract for `role` (`dialog` vs `region`) differs between the two. Developers shipping non-modal-styled-as-modal lock keyboard focus inside a panel that looks dismissable just by clicking outside.
CorrectTreat modal vs non-modal as a structural variant (separate ARIA role, separate keyboard contract). Designers and developers both consult the canon to confirm which variant they're using; the Figma component carries a Variant property that surfaces both.
- 03 Figma
Swipe-to-dismiss drawn as a static "drag handle" with no animation
CodeA pointer-event-driven gesture handler tracking velocity and distance, with kinematic close-or-snap-back
ConsequenceDesigners see a static handle and may assume it's just a visual affordance; developers either ship without swipe support (touch-input feels broken) or invent the gesture interaction from scratch. Animation curves and dismiss thresholds are reinvented per implementation.
CorrectDocument swipe-to-dismiss as a `swipeable: true` property with explicit canonical states (`dragging`) and transitions. Specify threshold conventions in the canonical reference (e.g. "release velocity > 800px/s OR distance > 30% closes").
- 04 Figma
Side `start` vs `end` drawn as two separate components
CodeA single component with a `side` property toggling logical positioning
ConsequenceDesigners and developers count drawers differently — designers see four components (left, right, top, bottom drawers); developers see one with a property. Variant explosion (4 sides × 3 modal-modes × 4 sizes = 48 variants) makes the Figma file unmaintainable.
CorrectModel `side` as a property, not as separate components. Use Figma's Variant property type for the four side values; the anatomy is shared across all four.
Common mistakes
#drawer-no-focus-trap-on-modal
Modal drawer with no focus trap
The drawer is styled to feel modal (backdrop, scrim) but pressing Tab from the last focusable inside the drawer moves focus to the next element in the page beneath the backdrop. Visually obscured but technically focused.
Implement a focus trap when `modal: true`. Modern primitives (Radix Dialog, React Aria Modal, vaul) include this; never roll your own without `inert` on the rest of the document.
#drawer-no-focus-restore
Focus is lost after the drawer closes
After dismissal, focus lands on `<body>`. Keyboard and screen-reader users have to re-orient from the top of the page.
Capture the previously-focused element on open (modal variants). Restore focus to it on close. If the trigger no longer exists, focus a stable landmark (page heading, parent toolbar).
#drawer-swipe-without-keyboard-equivalent
Swipe-to-dismiss with no keyboard equivalent
Mobile drawers are dismissable via swipe but keyboard users on desktop have no equivalent. Either the drawer cannot be closed from the keyboard at all, or the close button is missing on the assumption "users will swipe".
Every dismissible drawer carries a visible close-button slot reachable by keyboard. Escape dismisses when `dismissible: true` regardless of input modality. Swipe is an additive convenience, not a replacement.
#drawer-side-not-rtl-aware
Side `start` hard-coded to `left`
The drawer slides from the left in LTR — and also in RTL, breaking RTL users' expectation that "start" means visual right. Implementations using `left: 0` or `transform: translateX(-100%)` hard-code direction.
Use logical properties: `inset-inline-start: 0`, `transform: translateX(calc(-1 * 100%))` only when paired with `:dir(rtl)` overrides, or better, use logical-property-aware transforms via a CSS variable. The drawer's `side: start` slides from inline-start in any direction.
#drawer-stacking-with-modal
Drawer opened on top of an existing Modal
Both surfaces install their own focus traps and `inert` handling. The two systems fight: focus may bounce between the modal and the drawer; assistive tech reads stale content from the now-inert modal beneath.
Forbid stacking by canon: a Drawer and a Modal cannot be open simultaneously. Either dismiss the modal before opening the drawer, or model the drawer's content as a step inside the modal flow. Stacking is a redesign signal, not a layering concern.
Accessibility hints
| Slot | Accessibility hint | |
|---|---|---|
backdrop | Backdrop is presentational; do not put `role` on it. Clicking the backdrop dismisses the drawer when `dismissible: true`. Keyboard users always have an explicit close affordance because they cannot click the backdrop. | |
container | For modal drawers, apply `role="dialog"` and `aria-modal="true"`, with focus trap and `inert` on siblings. For non-modal drawers, `role="region"` with `aria-labelledby` pointing at the title is the canonical choice — focus may escape the drawer naturally. Always label via `aria-labelledby` regardless of modality. | |
header | Header is a layout region, not a heading. Heading semantics live on the title element inside. | |
title | Use a real heading element of an appropriate level. The drawer's container references this element via `aria-labelledby`. Hidden titles (visually-hidden but accessible) are valid for drawers that do not visually display a title. | |
close-button | Provide an accessible name ("Close" or "Close filters"). The Escape key must do the same thing as clicking this button when `dismissible: true`. | |
body | Body retains its native semantics (forms stay forms, lists stay lists). For scroll-on-overflow, ensure the scrollable region is keyboard-reachable (`tabindex="0"` on the scroll container) so keyboard users can scroll without an interactive child. | |
footer | Buttons keep native semantics. Document the default focus target (usually the primary commit button); destructive commits should default focus to the cancel action. | |
handle | Decorative when only providing a visual cue. If the handle also accepts pointer drag for resize, expose it as `role="separator"` with `aria-orientation` and ARIA range values, and provide keyboard equivalents (ArrowKeys to resize). |