Skip to content

Drawer

An edge-anchored panel that slides over the page instead of replacing it. Pick it over a dialog when the thing behind still matters.

Also called Bottom Sheet, Side Sheet, Side Panel, Off-canvas, Slide-over, Action Sheet, Navigation Drawer — in this system all of them are Drawer.

Live preview

Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.

Playground

Pick a row. The list stays on screen behind the panel — that is the entire reason to choose a drawer over a dialog.

A filter panel

Filters applied live, with the result changing behind the panel. This is the case a dialog cannot serve — covering the results while someone filters them is self-defeating.

2 active

Widths

Width follows the content, not the screen. A single-column form needs 26rem; two columns of key–value detail need 34rem; anything past 44rem should have been a page.

20rem · filters
26rem · form
34rem · detail
44rem · ceiling

Interaction states

Every state a user can put this component into, rendered side by side. If a state is missing here, it is missing in production too.

Right
Left
Narrow20rem
Wide44rem
No scrimthat is a sidebar
Closed
Mobilefull width
260ms from the edge
Entering

Anatomy

Every part, every measurement, and the reason it is that number.

api-gateway
Deployment settings
✕

A right-anchored modal drawer over a list. The visible strip of list on the left is not wasted space — it is the reason this is a drawer.

  1. Scrim--ds-layer-scrim + 2px blur

    Dims the page and absorbs the click that closes the panel. Its opacity is the difference between "the page is paused" and "the page is gone" — 72% in dark, 42% in light, because a white page needs far less dimming to read as inactive.

  2. Panelmin(26rem, 100vw − 2rem)

    Full viewport height, anchored to one edge. The 2rem clamp keeps a sliver of scrim visible on small screens so the panel never reads as a whole new page.

  3. Edge border1px --ds-border

    A hairline on the anchored side. Shadow alone separates the panel in light mode but disappears in dark, where surfaces are lightened rather than shadowed.

  4. Header20px / 16px, sticky

    Title, optional description, and the close control. It does not scroll — the exit must be reachable no matter how far down the body the user has gone.

  5. Close control32px icon button

    Top corner on the panel’s inner side. Escape and the scrim also close, but a visible control is the only one a touch user can find without guessing.

  6. Body20px padding, scrolls

    The only scrolling region. The page behind is scroll-locked, so a stray wheel event cannot move the thing the user is using the drawer to inspect.

  7. Footer14px / 20px, inset surface

    Actions pinned to the bottom, right-aligned, primary last. Inset background so it separates from the body without a second border.

  8. Elevation--shadow-e5

    The highest tier below a dialog. It is above everything on the page but below anything the drawer itself opens.

Design tokens used

Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.

Color

TokenValueUsed for
--ds-layer-scrim—The dimming layer over the page
--ds-surface-overlay—Panel background — a modal surface sits at the top of the ramp
--ds-border-subtle—Footer and header rules. The footer has no fill: an absolute surface token inside an overlay reads as a hole in it
--ds-border—The anchored edge
--ds-border-subtle—Header and footer rules

Spacing

TokenValueUsed for
widthPanel width
paddingBody inset

Shadow

TokenValueUsed for
--shadow-e5—Panel elevation

Motion

TokenValueUsed for
durationSlide in
--ease-emphasizedDecelerating entry

Recommended sizes

Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.

SizeHeightPaddingMin widthMax widthWhen to use
sm—16px20rem—Filters, a short list of toggles, a summary. One column, no labels longer than the panel.
md—20px26rem—The default. A single-column form of six to ten fields.
lg—20px34rem—Two columns of key–value detail, or a form beside a preview.
xl—24px44rem—The ceiling. Past this the page behind is gone and you have built a route without a URL.
Mobile———100vw − 2remBelow 640px it fills the viewport. Keep the 2rem gutter — full-bleed reads as navigation, not as an overlay.
Header56px16px 20px——Title, description, close.
Footer60px14px 20px——Right-aligned actions.

Do

list still readable
Leave the context visibleThe strip of page beside the panel is what distinguishes a drawer from a dialog. If the panel is so wide that nothing shows, the choice of component no longer buys anything.
detail
Anchor detail panels to the rightRight is detail; left is navigation. Users have a spatial model built by every tool they use, and putting an inspector on the left makes it collide with the sidebar in that model.
body scrolls · header and footer do not
Pin the actions to the bottomA save button that scrolls out of the panel is a save button users assume is missing. The footer stays put while the body scrolls, exactly as in a dialog.
Escape on a dirty form → "Discard changes?"
Warn before discarding a dirty formA drawer has three exits — Escape, the scrim, and the close button — and all three are easy to hit by accident. A dialog has the same problem; a drawer has it three times over.
row → drawer → Escape → same row
Return focus to what opened itThe user was on a row. Closing the panel should put them back on that row, not at the top of the document, so the keyboard path in and out is symmetrical.

Don't

Do not open a drawer from a drawerTwo stacked panels leave no context visible and no clear meaning for Escape. If the second panel is genuinely needed, replace the contents of the first and give it a back control.
"Delete this?" in a 26rem edge panel
Do not use one for a confirmationConfirmations need to be in the centre of vision and impossible to ignore. A panel that slides in at the edge is the wrong amount of ceremony for "are you sure?".
no URL, no refresh, no back
Do not put a whole workspace in oneIf it has tabs, its own toolbar and ten minutes of work in it, it needs a URL. Users will refresh, deep-link and open in a new tab, and a drawer supports none of those.
scroll inside panel → list behind jumps
Do not leave the background scrollableWheeling inside a modal drawer that has no overflow scrolls the page behind it. The user returns to a list that has moved and loses the row they were inspecting.
500ms “premium” easing → 500ms of waiting, every time
Do not animate slower than ~300msThe slide is meant to explain where the panel came from, and it has done that in a quarter of a second. Beyond that it is a delay the user pays on every open.

Accessibility

Not a checklist to run at the end. These are the requirements the component was built from.

1.4.13Content on Hover or FocusAA2.1.2No Keyboard TrapA2.4.3Focus OrderA2.4.11Focus Not ObscuredAA4.1.2Name, Role, ValueA

Contrast

  • The panel must reach 3:1 against the scrimmed page behind it, or the edge dissolves. In dark themes the scrim alone is not enough — the panel needs a lighter surface as well.
  • The scrim is decorative but functional: too light and the page behind competes for attention, too dark and the retained context becomes unreadable. It is not the same value in both themes — 72% over a dark page, 42% over a white one.
  • The close control is an icon-only button and still needs 3:1 for its glyph, at 32px, in the corner where it will be tapped in a hurry.

Keyboard

EscapeCloses the drawer and returns focus to the trigger. Prompt first if the form is dirty.
TabCycles inside the panel only. The page behind is inert while the drawer is modal.
Shift + TabCycles backwards, wrapping from the first element to the last.
EnterSubmits, when the panel contains a form with a primary action.

Screen readers

  • Announced as "api-gateway, dialog" with the description following. The panel needs an accessible name even when the title is visually obvious.
  • Everything outside the panel is hidden while it is open — either with inert on the page container or aria-hidden on the siblings. Without it, a screen reader wanders into a page the sighted user cannot see.
  • The close button needs a real label. "Close panel" beats "Close", which is ambiguous when a dialog can also be open.
  • Do not move focus into the body automatically on open. The user hears the title first, then chooses to Tab in.

Focus & touch

  • On open, focus moves to the panel itself — not to the first field, which would skip the title on the way in. On close it returns to the element that opened it. Focus is trapped for as long as the panel is modal, and released the moment it is not.
  • The close control is 32px visually with a 44px hit area. On mobile the panel fills the viewport minus a 2rem gutter, and that gutter is a real dismiss target, not decoration.
AttributeApplied toNotes
role="dialog"The panelA drawer is a dialog that happens to be anchored to an edge. The role is the same; only the geometry differs.
aria-modal="true"The panelOnly when it really is modal. On a non-modal panel this lies to the screen reader about whether the page behind is available.
aria-labelledbyThe panelPoints at the header title. Without it the panel announces as an unnamed dialog.
aria-describedbyThe panelPoints at the description, when there is one. Keep it to one line — it is read in full on open.
aria-expandedThe triggerReflects whether the panel it controls is open, so the state is available without leaving the trigger.

Code

Example usage

tsx
1import { Drawer } from '@/ui/Overlay'23const [open, setOpen] = useState(false)4const [selected, setSelected] = useState<Service>()56<Drawer7  open={open}8  onClose={handleClose}9  side="right"          // detail goes right; left is navigation10  width="26rem"11  title={selected?.name}12  description="Deployment settings for this service"13  footer={14    <>15      <Button variant="text" onClick={handleClose}>Cancel</Button>16      <Button onClick={save}>Save changes</Button>17    </>18  }19>20  <ServiceForm value={selected} onChange={setDraft} />21</Drawer>2223// Three exits, one guard — Escape, the scrim and the close button24// all land here, so the dirty check belongs in one place.25function handleClose() {26  if (dirty && !confirm('Discard changes?')) return27  setOpen(false)28}2930// Below the tablet breakpoint a drawer becomes a bottom sheet.31// Same content, same state, the container follows the thumb.32const isPhone = useMediaQuery('(max-width: 639px)')33const Panel = isPhone ? BottomSheet : Drawer

Framework-free HTML

Framework-free. The inert attribute on the page is what makes it modal.

html
<div class="ds-page" inert>
  <!-- the list stays rendered and stays visible -->
</div>

<div class="ds-scrim" data-close></div>

<aside
  class="ds-drawer ds-drawer--right"
  role="dialog"
  aria-modal="true"
  aria-labelledby="drawer-title"
  aria-describedby="drawer-desc"
  tabindex="-1"
>
  <header class="ds-drawer__header">
    <div>
      <h2 id="drawer-title">api-gateway</h2>
      <p id="drawer-desc">Deployment settings for this service</p>
    </div>
    <button type="button" class="ds-icon-button" aria-label="Close panel" data-close>
      <svg aria-hidden="true"><!-- × --></svg>
    </button>
  </header>

  <div class="ds-drawer__body"><!-- the only scrolling region --></div>

  <footer class="ds-drawer__footer">
    <button class="ds-button ds-button--text" data-close>Cancel</button>
    <button class="ds-button ds-button--filled">Save changes</button>
  </footer>
</aside>

CSS

css
.ds-drawer {
  position: fixed;
  inset-block: 0;
  z-index: 75;
  display: flex;
  flex-direction: column;

  /* Clamped, so a sliver of scrim always shows on a phone.
     A full-bleed panel reads as a new page, not as an overlay. */
  inline-size: min(26rem, 100vw - 2rem);

  background: var(--ds-surface-overlay);   /* modal surface: top of the ramp */
  box-shadow: var(--shadow-e5);
}

/* Enter from the edge you are anchored to — the motion is the
   explanation of where the panel came from. */
.ds-drawer--right {
  inset-inline-end: 0;
  border-inline-start: 1px solid var(--ds-border);
  animation: drawer-in-right 260ms var(--ease-emphasized) both;
}
.ds-drawer--left {
  inset-inline-start: 0;
  border-inline-end: 1px solid var(--ds-border);
  animation: drawer-in-left 260ms var(--ease-emphasized) both;
}

@keyframes drawer-in-right {
  from { transform: translateX(100%); }
  to   { transform: translateX(0); }
}
@keyframes drawer-in-left {
  from { transform: translateX(-100%); }
  to   { transform: translateX(0); }
}

/* Header and footer are fixed; only the body moves. */
.ds-drawer__header,
.ds-drawer__footer { flex: none; }

.ds-drawer__body {
  flex: 1;
  overflow-y: auto;
  overscroll-behavior: contain;   /* stops the page behind scrolling */
  padding: 20px;
}

.ds-drawer__footer {
  display: flex;
  justify-content: flex-end;
  gap: 10px;
  padding: 14px 20px;

  /* The rule separates it; a fill would not. An opaque surface token here is
     an absolute value inside a container one rung above it, so it reads as a
     hole punched in the panel rather than a bar attached to it. */
  border-block-start: 1px solid var(--ds-border-subtle);
}

.ds-scrim {
  position: fixed;
  inset: 0;
  z-index: 70;
  background: var(--ds-layer-scrim);
  animation: fade-in 180ms linear both;
}

/* The slide is information, not decoration — but not everyone
   can take it. Keep the panel, drop the travel. */
@media (prefers-reduced-motion: reduce) {
  .ds-drawer { animation: fade-in 120ms linear both; }
}

Component API

Drawer

PropTypeDefaultDescription
open*boolean—Mounts and animates the panel in. Unmounted when false — a hidden drawer should not keep a form alive.
onClose*() => void—Fires for Escape, the scrim and the close button. Put the dirty-form guard here, once.
side'left' | 'right''right'Right for detail, left for navigation.
widthstring'26rem'Any CSS length. Clamped to the viewport minus 2rem.
titleReactNode—Rendered in the header and used as the accessible name.
descriptionReactNode—One line under the title. Read out on open, so keep it short.
footerReactNode—Pinned actions. Omit for a read-only panel.
children*ReactNode—The body. The only region that scrolls.

Notes

Professional tips

  • Give the drawer a URL parameter — ?panel=api-gateway. It costs one line, and it makes the panel linkable, refreshable and back-button-friendly without turning it into a route.
  • When the drawer edits a row, highlight that row behind the panel. It answers "which one am I editing?" without the user having to remember.
  • Prev/next arrows in the header turn a detail drawer into a review queue. Reviewing forty items without closing the panel forty times is a large win for a small control.
  • Below the tablet breakpoint, swap the drawer for a bottom sheet. Same content and same state — an edge panel on a phone is a full-screen takeover with extra steps.
  • One drawer per screen. If two features both want one, they want the same one with different contents.

Performance

  • Unmount the contents when closed. A drawer that stays mounted keeps its subscriptions, its timers and its stale form state, and users notice when it reopens showing the previous row.
  • Animate transform only. Animating width or inset-inline-end lays out every frame and drops the panel to a visible stutter on a long list.
  • Fetch the detail when the row is selected, not when the drawer finishes animating. The 260ms of slide is free loading time.
  • Scroll-lock the page with overflow: hidden plus a scrollbar-width pad, or the content behind jumps sideways as the drawer opens.

Common mistakes

  • Drawers stacked on drawers, leaving no context and no clear target for Escape.
  • A panel wide enough to cover the page, which throws away the only advantage a drawer has.
  • Escape discarding a half-filled form with no confirmation.
  • Focus left on the trigger when the panel opens, so a screen reader never hears the title.
  • aria-modal="true" on a panel whose background is still clickable, which tells assistive tech the opposite of the truth.
  • The page behind scrolling when the wheel reaches the end of the panel body.

Real-world recommendations

  • Drawer or dialog is settled by one question: does the user need to see the page behind while they work? If the honest answer is no, use a dialog — it is simpler and it is centred.
  • Drawer or page is settled by a second question: will anyone want to link to this? If yes, it is a page, no matter how tempting the panel is.
  • Filter panels are the strongest case for a drawer and the most common place teams reach for a dialog instead. Watching results change as you filter is the whole interaction.
  • Track how long panels stay open. A median over a couple of minutes means the task outgrew the container and wants a route of its own.
  • If your drawer has grown its own tabs, that is the signal it has become a page. Tabs inside an edge panel put two navigation systems in one 26rem column.