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.
Everything below is the real component. Change the controls, tab through it, and turn on Inspector Mode to read any value off the screen.
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.
Modal drawer vs non-modal sidebar
The scrim is the tell. With one, the page behind is inert and the panel owns the interaction; without one, the page stays live and the panel is furniture, not an overlay.
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.
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.
Every part, every measurement, and the reason it is that number.
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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- Elevation--shadow-e5
The highest tier below a dialog. It is above everything on the page but below anything the drawer itself opens.
Values are read live from the running stylesheet, so this table can never drift from the code. Click any value to copy it.
Color
| Token | Value | Used 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
| Token | Value | Used for |
|---|---|---|
| width | Panel width | |
| padding | Body inset |
Shadow
| Token | Value | Used for |
|---|---|---|
| --shadow-e5 | — | Panel elevation |
Motion
| Token | Value | Used for |
|---|---|---|
| duration | Slide in | |
| --ease-emphasized | Decelerating entry |
Pick a size from this table. Do not invent a new one — a fourth height is how a design system starts dying.
| Size | Height | Padding | Min width | Max width | When to use |
|---|---|---|---|---|---|
| sm | — | 16px | 20rem | — | Filters, a short list of toggles, a summary. One column, no labels longer than the panel. |
| md | — | 20px | 26rem | — | The default. A single-column form of six to ten fields. |
| lg | — | 20px | 34rem | — | Two columns of key–value detail, or a form beside a preview. |
| xl | — | 24px | 44rem | — | The ceiling. Past this the page behind is gone and you have built a route without a URL. |
| Mobile | — | — | — | 100vw − 2rem | Below 640px it fills the viewport. Keep the 2rem gutter — full-bleed reads as navigation, not as an overlay. |
| Header | 56px | 16px 20px | — | — | Title, description, close. |
| Footer | 60px | 14px 20px | — | — | Right-aligned actions. |
Not a checklist to run at the end. These are the requirements the component was built from.
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
| Escape | Closes the drawer and returns focus to the trigger. Prompt first if the form is dirty. |
| Tab | Cycles inside the panel only. The page behind is inert while the drawer is modal. |
| Shift + Tab | Cycles backwards, wrapping from the first element to the last. |
| Enter | Submits, 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.
| Attribute | Applied to | Notes |
|---|---|---|
| role="dialog" | The panel | A 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 panel | Only when it really is modal. On a non-modal panel this lies to the screen reader about whether the page behind is available. |
| aria-labelledby | The panel | Points at the header title. Without it the panel announces as an unnamed dialog. |
| aria-describedby | The panel | Points at the description, when there is one. Keep it to one line — it is read in full on open. |
| aria-expanded | The trigger | Reflects whether the panel it controls is open, so the state is available without leaving the trigger. |
Example usage
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 : DrawerFramework-free HTML
Framework-free. The inert attribute on the page is what makes it modal.
<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
.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
| Prop | Type | Default | Description |
|---|---|---|---|
| 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. |
| width | string | '26rem' | Any CSS length. Clamped to the viewport minus 2rem. |
| title | ReactNode | — | Rendered in the header and used as the accessible name. |
| description | ReactNode | — | One line under the title. Read out on open, so keep it short. |
| footer | ReactNode | — | Pinned actions. Omit for a read-only panel. |
| children* | ReactNode | — | The body. The only region that scrolls. |
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.