Dev 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.

Highlight
Fig 1.1 · Drawer · Dev view
Dev

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
Both

Variants, properties, states

Variants

Structurally different versions of the component.

modalnon-modalnavigation

Properties

The same component, parameterised.

PropertyType
sidestart | end | top | bottom
sizesm | md | lg | full
dismissibleboolean
swipeableboolean

States

Browser/user-driven (interactive) vs. app-driven (data).

KindStates
interactive
focus-visiblehover
data
closedopeningopenclosingdragging
Both

State transitions

FromToTrigger
closedopeningUser 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.
openingopenThe 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.
openclosingUser 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.
closingclosedThe 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.
opendraggingUser 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`.
draggingopenUser releases the swipe with insufficient velocity / distance to dismiss; drawer animates back to its open position.
draggingclosingUser releases the swipe with sufficient velocity / distance to dismiss; drawer animates the rest of the way out via the standard close path.
Dev

Cross-framework expression

FrameworkStructure mechanismVariant 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`
Both

Events

  1. openChange
    Payload
    Boolean. `true` after the drawer has finished entering (`opening → open`), `false` after it has finished exiting (`closing → closed`). Fires after the slide animation settles, not at the start.
    Web Components
    `openChange` CustomEvent on the `<ui-drawer>` host with `event.detail = { open: boolean }`. Native `<dialog>` fires `close` on dismissal which the host re-emits as `openChange`.
    React
    `onOpenChange(open: boolean)` controlled-pattern callback (vaul, React Aria Modal). Some libraries emit before the animation completes; canonical contract is "after settle".
    Angular Signals
    `output<boolean>('openChange')`; emits after each transition settles. Pair with `[(open)]` two-way binding.
    Vue
    `@update:open` for `v-model:open`; some third-party drawers emit `@open` and `@close` separately at the same moment.
  2. dismiss
    Payload
    `{ reason: 'escape' | 'backdrop' | 'closeButton' | 'swipe' }`. Distinguishes user-initiated dismissal from a programmatic close after primary-action commit. The `swipe` reason is the Drawer-specific addition over Modal's three-reason union.
    Web Components
    Native `<dialog>` `cancel` event covers Escape; a custom `dismiss` CustomEvent with the same `event.detail` shape covers backdrop, close-button, and swipe paths.
    React
    vaul exposes `onDismiss` / `onClose` with reason inference from the source event. React Aria's `useModalOverlay` provides the source DOM event so consumers can infer.
    Angular Signals
    `output<DismissReason>('dismiss')` paralleling `openChange`; suppressed on programmatic close.
    Vue
    `@dismiss` event with payload `{ reason }`. Consumers needing the reason wrap any `@close` from an underlying library.
  3. sideChange
    Payload
    `'start' | 'end' | 'top' | 'bottom'`. Fires when the responsive auto-flip kicks in (e.g. authored `side: end` becomes `bottom` below `breakpoint.sm`). Lets consumers re-anchor satellite UI (toast positioning, tooltip arrows) when the drawer reflows.
    Web Components
    `sideChange` CustomEvent with `event.detail = { side }`. Optional event — many hosts do not implement responsive auto-flip and never fire this.
    React
    `onSideChange(side)` callback; not standard across libraries. Canonical reference documents the contract; consumers wire it up where the underlying library does not.
    Angular Signals
    `output<DrawerSide>('sideChange')`.
    Vue
    `@update:side` if the side is `v-model`-bound; otherwise a discrete `@side-change` event.
Dev

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.
Dev

Performance thresholds

  • stackDepthopen-drawer-count1drawers

    Avoid 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-time16ms

    Swipe-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.

Both

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).
Both

Accessibility acceptance

Keyboard walk

KeysExpected
TabModal: 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+TabModal: 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

TriggerExpected
Modal drawer opensSR 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 opensSR announces the title followed by "region" — e.g. "Recent activity, region". Page context remains in the AT tree; focus does not move automatically.
Drawer closesFocus 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 progressSR 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-name
  • aria-required-attr
  • aria-modal-misuse
  • color-contrast
  • focus-order-semantics
  • landmark-unique
  • region
Dev

Common mistakes

#drawer-no-focus-trap-on-modal

Modal drawer with no focus trap

Problem

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.

Fix

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

Problem

After dismissal, focus lands on `<body>`. Keyboard and screen-reader users have to re-orient from the top of the page.

Fix

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

Problem

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".

Fix

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`

Problem

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.

Fix

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

Problem

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.

Fix

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.

Figma↔Code mismatches
  1. 01
    Figma

    A drawer drawn as a static side panel that's just always there

    Code

    A portal-mounted panel with focus trap, escape handling, slide animation, and `inert` toggling

    Consequence

    The 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.

    Correct

    Document 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.

  2. 02
    Figma

    Modal drawer and non-modal drawer drawn as one variant set

    Code

    Two distinct accessibility contracts (focus trap + inert vs none) driven by the variant prop

    Consequence

    Designers 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.

    Correct

    Treat 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.

  3. 03
    Figma

    Swipe-to-dismiss drawn as a static "drag handle" with no animation

    Code

    A pointer-event-driven gesture handler tracking velocity and distance, with kinematic close-or-snap-back

    Consequence

    Designers 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.

    Correct

    Document 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").

  4. 04
    Figma

    Side `start` vs `end` drawn as two separate components

    Code

    A single component with a `side` property toggling logical positioning

    Consequence

    Designers 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.

    Correct

    Model `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.