Bridge 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↔Code mismatches
Where designer and developer worlds typically misalign on this component.
- 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.
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 |
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 | — |
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 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 |
Code anatomy
| Slot | Code slot | Semantic |
|---|---|---|
backdrop | backdrop | presentational-overlay |
container | container | dialog-or-region |
header | header | heading-region |
title | title | heading |
close-button | close | button |
body | body | prose-or-form-or-list |
footer | footer | button-group |
handle | handle | presentational-or-resize-affordance |
Cross-framework expression
| Framework | Structure mechanism | Variant mechanism |
|---|---|---|
| Web Components | A `<ui-drawer>` host with named slots for `header`, `body`, `footer`, `handle`; modal variant uses native `<dialog>` internally with custom positioning, non-modal uses a positioned `<aside>` | attributes (`variant="modal"`, `side="end"`, `size="md"`, `dismissible`, `swipeable`); `data-state="open|closed|opening|closing|dragging"` for CSS transitions |
| React | portal-based primitives (Radix has no first-party Drawer; vaul or React Aria Drawer-via-Modal compose the slots as children) compositing the slots as named children | props with class-variance-authority for variant/side/size; `data-state` attribute for transition states; `onOpenChange` / `onDismiss` controlled-pattern callbacks |
| Angular (signals) | Angular CDK Overlay + custom positioning strategy (top/bottom/start/end attached); content projection for header / body / footer slots | input<'modal' | 'non-modal' | 'navigation'>(), input<'start' | 'end' | 'top' | 'bottom'>(); host bindings drive `[attr.role]` (dialog vs region) |
| Vue | Headless UI does not yet ship a Drawer (as of 2026-04); third-party (vue-final-modal, custom composable around Teleport) provides the portal + focus trap; named slots for header/body/footer | defineProps with literal-union types; `:data-state` for transition states; emits `update:open` / `dismiss` |
Events
openChangedismisssideChange
Form integration
- name attribute
- Drawer is a container, not a form control — it has no `name` attribute. Forms hosted inside the drawer carry their own `name` attributes on their fields. Native `<dialog>` cooperates with `<form method="dialog">` to submit-and-close in a single user action; custom-implementation drawers re-implement this contract.
- FormData serialization
- Forms inside the drawer submit normally via their own `<form>` element. The drawer itself contributes nothing to FormData. Filter-style drawers commonly serialize their internal form on every change rather than on submit, persisting filter state to the URL or app state.
- form.reset()
- Forms inside the drawer respond to `form.reset()` independently of the drawer's open/closed state. Closing via Escape, backdrop, or swipe does not reset the form. Filter drawers often expose a "Reset filters" action that calls `form.reset()` plus clears application state.
- HTML5 validation
- Forms inside the drawer use HTML5 validation as normal. Validation failures focus the first invalid field — for modal drawers focus stays inside the drawer because of the focus trap; for non-modal drawers the focus also stays naturally because the field is reachable. Avoid auto-closing the drawer on submit failure.
Performance thresholds
stackDepthopen-drawer-count≥1drawersAvoid stacking drawers; the canonical maximum is one open drawer at a time, and a drawer cannot stack on top of a Modal either (see mistake `drawer-stacking-with-modal`). Stacked drawers fight for focus traps and `inert` handling. Patterns that need "drawer-on-drawer" should be redesigned as a sequence or as nested in-drawer disclosure.
swipeFrameBudgetdrag-frame-time≥16msSwipe-to-dismiss feels broken when the drawer position update misses the 16ms (60fps) frame budget. Position updates must use transform (compositor thread), never `inset-inline-start` changes (layout thread). Pin transform-only animations and profile against this budget on low-tier mobile.
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.
Accessibility
| 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). |
Accessibility acceptance
Keyboard walk
| Keys | Expected |
|---|---|
Tab | Modal: focus moves to the next focusable inside the drawer; cycles back to the first after the last (focus trap). Non-modal: focus moves to the next focusable in the document — drawer content first, then siblings; focus may escape the drawer naturally. |
Shift+Tab | Modal: focus moves to the previous focusable inside the drawer; cycles to the last after the first. Non-modal: focus moves to the previous document focusable. |
Escape (when dismissible is true) | Closes the drawer along the canonical `open → closing → closed` path. Modal: focus restores to the trigger. Non-modal: focus stays where it was. |
ArrowLeft / ArrowRight (with handle focused, swipeable resize) | Resizes the drawer along the inline axis when the handle exposes `role="separator"`. Step size matches the keyboard increment convention (~24px). |
Screen-reader announcements
| Trigger | Expected |
|---|---|
| Modal drawer opens | SR announces the drawer's title (via `aria-labelledby`) followed by "dialog" — e.g. "Filters, dialog". Page context is silenced via `inert` on siblings. |
| Non-modal drawer opens | SR announces the title followed by "region" — e.g. "Recent activity, region". Page context remains in the AT tree; focus does not move automatically. |
| Drawer closes | Focus returns to the trigger (modal) or stays in place (non-modal). The trigger's accessible name is re-announced. No automatic announcement is required for the close itself. |
| Swipe-to-dismiss in progress | SR users do not perform swipe dismissals (touch + visual feedback only). The drawer's `dragging` state is not announced; the resulting close event triggers the standard close announcement. |
axe-core rules to assert
aria-dialog-namearia-required-attraria-modal-misusecolor-contrastfocus-order-semanticslandmark-uniqueregion
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.