Component
drawer
Demo
tokens.css,
base.css and drawer.css. No scripts.
Source
Copy both files, or run npx nojsui add drawer.
Browser support
Shipped in every major engine, but only recently — older versions need the fallback.
| Feature | Baseline | Chrome | Edge | Firefox | Safari | Chrome Android | Firefox Android | Safari iOS |
|---|---|---|---|---|---|---|---|---|
| <dialog> | widely | 37 | 79 | 98 | 15.4 | 37 | 98 | 15.4 |
| Invoker commands | newly | 135 | 135 | 144 | 26.2 | 135 | 144 | 26.2 |
| Feature | Baseline | Chrome | Edge | Firefox | Safari | Chrome Android | Firefox Android | Safari iOS |
|---|---|---|---|---|---|---|---|---|
| @starting-style | newly | 117 | 117 | 129 | 17.5 | 117 | 129 | 17.5 |
| transition-behavior | newly | 117 | 117 | 129 | 17.4 | 117 | 129 | 17.4 |
| :dir() | widely | 120 | 120 | 49 | 16.4 | 120 | 49 | 16.4 |
Usage
A <dialog> with data-side. Everything else is the same as
dialog — Invoker Commands open and close it, and
<dialog> supplies modality.
<button type="button" command="show-modal" commandfor="filters">Filters</button>
<dialog id="filters" class="sk-drawer" data-side="inline-end" aria-labelledby="filters-title">
<header class="sk-drawer__header">
<h2 id="filters-title" class="sk-drawer__title">Filters</h2>
<button type="button" class="sk-drawer__close" command="close" commandfor="filters" aria-label="Close filters">
<span aria-hidden="true">×</span>
</button>
</header>
<div class="sk-drawer__body">…</div>
</dialog>
data-side takes inline-start, inline-end or block-end.
How it works
Placement is logical margins, not position-area. A top-layer dialog’s
containing block is the viewport — measured in all three engines — so pinning it
to an edge is just margin: 0 plus one side set back to auto. position-area
would need an anchor element, and a drawer is anchored to the viewport, not to
anything on the page.
The payoff is that inline-start and inline-end flip in a right-to-left
document with no extra CSS. Verified: in RTL the inline-end drawer renders at
the left edge in Chromium, WebKit and Gecko.
The slide needs one direction-aware rule. translate is physical, so an
inline-side drawer has to know which way “out” is when the document is RTL. A
:dir(rtl) rule flips the travel distance — no @supports guard, because
:dir() is Baseline widely available. The block-end sheet needs none of this.
No swipe-to-dismiss
Deliberate, and the reasoning is in ADR 0006.
Short version: CSS cannot close a <dialog>. A scroll-snap swipe can move the
panel out of view, but the dialog stays open — backdrop still painted, page
behind still inert, focus still confined to a sheet the user believes they
dismissed. That is a trap, not a degraded experience, and it fails hardest for
keyboard and screen-reader users.
The sheet has a close button and Esc. Both actually close it.
Theming
Set any of these anywhere above the component — :root, a section wrapper, or
one instance. drawer.css only ever reads them, so the nearest declaration wins
(ADR 0011).
| Property | Default | Controls |
|---|---|---|
--sk-drawer-bg | --sk-color-surface-raised | Panel background |
--sk-drawer-fg | --sk-color-text | Panel text |
--sk-drawer-shadow | --sk-shadow-lg | Panel elevation |
--sk-drawer-radius | --sk-radius-lg | Radius on the panel’s inner corners |
--sk-drawer-inline-size | 22rem | Width of a side drawer before the viewport clamp |
--sk-drawer-sheet-max-block-size | 80% | Tallest a data-side="block-end" sheet grows |
--sk-drawer-divider-color | --sk-color-border | Rule under the header |
--sk-drawer-close-fg | --sk-color-text-muted | Close button glyph |
--sk-drawer-close-bg-hover | --sk-color-surface | Close button background on hover |
--sk-drawer-button-bg | --sk-color-surface | Trigger background |
--sk-drawer-button-bg-hover | --sk-color-surface-raised | Trigger background on hover |
--sk-drawer-button-fg | --sk-color-text | Trigger text |
--sk-drawer-button-border-color | --sk-color-border-strong | Trigger border |
--sk-drawer-button-radius | --sk-radius-md | Trigger corner radius |
:root { --sk-drawer-inline-size: 28rem; }
Which edge the panel is pinned to, and the slide that follows from it, are set
by data-side — not a knob. The travel distance is derived from the side, so
there is nothing to keep in step by hand.
Keyboard contract
Identical to dialog — it is the same element.
| Key | Behaviour |
|---|---|
Enter / Space on the trigger | Opens the drawer; focus moves inside |
Tab | Moves through the drawer; the page behind is inert |
Esc | Closes and returns focus to the trigger |
Verified in all three engines: opens as :modal, focus lands inside, the
background refuses focus, Esc closes and returns focus.
As with dialog, wrapping at the last control is engine-dependent (Chromium and
WebKit cycle; Gecko held focus on the last control in automation). Focus never
escapes in any engine.
Verified manually in VoiceOver and NVDA: not yet — do this before the component is marked done.
Accessibility notes
- The drawer needs an accessible name —
aria-labelledbyon the dialog pointing at the title. - The close button needs a text label.
×is decorative, so it isaria-hiddenand the button carriesaria-label="Close filters". A bare glyph with no label announces as “button”. - A drawer is still a modal. Everything behind it is inert while it is open. If the content is not worth blocking the page for, use a popover instead.
Degradation
Baseline column from support.json — regenerate with pnpm support.
| Feature used | Baseline status | Behavior without it |
|---|---|---|
<dialog> | widely | — |
| Invoker Commands | newly | The trigger does nothing. Same floor as dialog; see docs/polyfills.md for the opt-in consumer shim. |
@starting-style | newly | No slide in; the drawer appears in place. |
transition-behavior: allow-discrete | newly | No slide out; the drawer disappears at once. |
:dir() | widely | Below the floor (Chrome 120 / Firefox 49 / Safari 16.4) the slide would run from the wrong side in RTL documents. Placement stays correct either way — logical margins do that, not :dir(). |
The usable floor is Invoker Commands, as with dialog: below it the trigger is
an inert button and no CSS rescues that.