Primitives Overlays
Drawer
A modal dialog at an edge of the viewport. The browser owns the top layer, page inertness, Escape, light dismiss and focus return; the package adds the placement, the slide and a contract. Tier 0.
- Tier
- 0 · Native
- Behavior JS
- 0 B brotli · 0 B gzip ·
fixtures/· methodresults.json (native-only) - Platform features
<dialog>,showModal(),closedby="any",command,commandfor,@starting-style,translate- Shared listeners
- none
- Per-instance listeners
- none
- Lazy state
- none
- Native base
<dialog closedby="any"> + command/commandfor
On this page
Example
<button
type="button"
data-slean="button"
data-variant="outline"
commandfor="drawer-filters"
command="show-modal"
>
Filters
</button>
<dialog
id="drawer-filters"
data-slean="drawer"
data-slean-side="right"
closedby="any"
aria-labelledby="drawer-filters-title"
>
<header data-slean-part="header">
<h2 id="drawer-filters-title" data-slean-part="title">Filters</h2>
<p data-slean-part="description">Narrow the list of people.</p>
</header>
<div data-slean-part="body">
<fieldset>
<legend>Team</legend>
<label><input type="checkbox" name="team" value="engineering" data-slean="checkbox" /> Engineering</label>
<label><input type="checkbox" name="team" value="design" data-slean="checkbox" /> Design</label>
<label><input type="checkbox" name="team" value="operations" data-slean="checkbox" /> Operations</label>
<label><input type="checkbox" name="team" value="growth" data-slean="checkbox" /> Growth</label>
</fieldset>
</div>
<footer data-slean-part="footer">
<button
type="button"
data-slean="button"
data-variant="outline"
commandfor="drawer-filters"
command="close"
>
Close
</button>
</footer>
<button
type="button"
data-slean-part="close"
commandfor="drawer-filters"
command="close"
aria-label="Close"
></button>
</dialog><script lang="ts">
// Nothing to import: the browser owns the dialog. The stylesheets are optional.
import '@svelte-lean/styles/button.css';
import '@svelte-lean/styles/checkbox.css';
import '@svelte-lean/styles/drawer.css';
</script>
<button
type="button"
data-slean="button"
data-variant="outline"
commandfor="drawer-filters"
command="show-modal"
>
Filters
</button>
<dialog
id="drawer-filters"
data-slean="drawer"
data-slean-side="right"
closedby="any"
aria-labelledby="drawer-filters-title"
>
<header data-slean-part="header">
<h2 id="drawer-filters-title" data-slean-part="title">Filters</h2>
<p data-slean-part="description">Narrow the list of people.</p>
</header>
<div data-slean-part="body">
<fieldset>
<legend>Team</legend>
<label><input type="checkbox" name="team" value="engineering" data-slean="checkbox" /> Engineering</label>
<label><input type="checkbox" name="team" value="design" data-slean="checkbox" /> Design</label>
<label><input type="checkbox" name="team" value="operations" data-slean="checkbox" /> Operations</label>
<label><input type="checkbox" name="team" value="growth" data-slean="checkbox" /> Growth</label>
</fieldset>
</div>
<footer data-slean-part="footer">
<button
type="button"
data-slean="button"
data-variant="outline"
commandfor="drawer-filters"
command="close"
>
Close
</button>
</footer>
<button
type="button"
data-slean-part="close"
commandfor="drawer-filters"
command="close"
aria-label="Close"
></button>
</dialog>Why this implementation exists
A drawer is a modal dialog with a different geometry. Built in script it takes a portal, a focus trap, a scroll lock, an outside-click listener and an animation. <dialog> opened with showModal() already has the top layer, the inert page, Escape and focus return, and closedby="any" adds the outside click.
Svelte Lean adds the geometry in CSS: the edge from data-slean-side, the full height of a side panel or the full width of a sheet, a slide on translate that starts from @starting-style, and padding for the screen’s safe areas.
The browser owns
- opening through command="show-modal" and closing through command="close" and Escape
- light dismiss on the backdrop through closedby="any", where implemented
- the modal top layer, the backdrop and page inertness
- moving focus into the drawer and returning it to the invoker
Svelte Lean owns
- drawer.css: the edge placement for four sides, the slide, safe-area padding and the layout parts
- the contract and the DrawerSide and DrawerPart types
- documentation
Usage
The markup needs no package. Install @svelte-lean/styles for the stylesheet; @svelte-lean/primitives adds the typed contract and nothing at runtime.
npm install @svelte-lean/stylespnpm add @svelte-lean/stylesyarn add @svelte-lean/stylesbun add @svelte-lean/stylesimport '@svelte-lean/styles/drawer.css';Open with command="show-modal" and close with command="close". closedby="any" closes the drawer on a backdrop click where it is implemented; keep a visible close action for the browsers without it and for touch screens.
data-slean-side is right (the default), left, top or bottom. The side is physical: in a right-to-left page, choose the edge you want.
Put the scrolling content in the body part; the header and the footer stay in place.
Anatomy
| Part | Element | data-slean-part | Required | Notes |
|---|---|---|---|---|
| root | <dialog id="…" closedby="any" data-slean-side="right"> | – | yes | Needs an id and an accessible name. data-slean-side is right (default), left, top or bottom. |
| invoker | <button type="button" commandfor="<id>" command="show-modal"> | – | yes | Opens the drawer as a modal. |
| header | <header> | header | styles only | Groups the title and description; does not scroll. |
| title | <h2> | title | styles only | The element aria-labelledby points at. |
| description | <p> | description | styles only | Optional; reference it with aria-describedby. |
| body | <div> | body | styles only | The scrolling region between header and footer. |
| footer | <footer> | footer | styles only | Holds the close action, which every drawer needs. |
Runtime profile
Tier 0: the drawer has no behavior module, and the Vite plugin maps drawer to no module. Opening, closing, light dismiss and focus are the browser’s; the slide is a CSS transition.
Accessibility contract
- An accessible name from
aria-labelledbyon the title. - Focus moves to the first focusable element, or to the one with
autofocus, and returns to the invoker when the drawer closes. - The rest of the page is inert while the drawer is open; Tab and Shift+Tab stay inside it.
- Escape closes the drawer in every browser; the visible close action covers touch screens and browsers without light dismiss.
- Under reduced motion the duration tokens are zero, so the drawer appears and leaves without sliding.
Keyboard
| Key | When | Result |
|---|---|---|
| Enter/Space | focus on the invoker | Opens the drawer (native) |
| Escape | drawer open | Closes the drawer: cancel, then close (native) |
| Tab/Shift+Tab | drawer open | Stays inside the drawer because the rest of the document is inert (native) |
Platform features
| Feature | Baseline | Outside the target |
|---|---|---|
<dialog>, showModal(), top layer, ::backdrop, page inertness | Widely available (newly available in 2022) | Not applicable within the support policy |
Invoker commands (command, commandfor) | Newly available: Chrome 135, Safari 26 and Firefox 144 (2025); not yet widely available | The drawer stays closed. Fallback: a two-line script calling showModal() and close(). The package ships no polyfill. |
closedby | Chrome 134 and Firefox 141; not in Safari 27 | A backdrop click does nothing; Escape and the close action still close |
@starting-style, transition-behavior | Baseline 2024 | The drawer appears and disappears without the slide |
overlay (exit transition) | Chrome 117; not in Safari 27 | The backdrop disappears at once and the exit slide is drawn in the page’s stacking order |
Without JavaScript
With invoker commands supported, the drawer opens and closes with no script at all. Without invoker commands and without script it stays closed, so content that must be reachable does not belong only in a drawer.
Server rendering
Static HTML. A modal drawer opens on the client only; the server renders it closed.
Before hydration
Nothing is attached. Where invoker commands are supported, the drawer opens before hydration as it does after.
Styling
drawer.css fixes the drawer to its edge, sizes it (a side panel at most 24rem wide, a sheet as tall as its content up to 32rem), slides it on translate with @starting-style and allow-discrete transitions of display and overlay, fades the backdrop and pads the screen edges with env(safe-area-inset-*). The tokens it reads:
| Token | Default (light) | Applies to |
|---|---|---|
--slean-space-8 | 2.5rem | inline-size, max-block-size |
--slean-border | var(--slean-neutral-6) | border-left, border-right, border-bottom, border-top, border-block-start |
--slean-surface | oklch(100% 0 0) | background |
--slean-fg | var(--slean-neutral-12) | color |
--slean-shadow-lg | 0 16px 40px oklch(0% 0 0 / 0.18), 0 2px 6px oklch(0% 0 0 / 0.08) | box-shadow |
--slean-duration-slow | 240ms | transition |
--slean-ease | cubic-bezier(0.2, 0, 0, 1) | transition |
--slean-radius-lg | 0.875rem | border-end-start-radius, border-end-end-radius, border-start-start-radius, border-start-end-radius |
--slean-overlay | oklch(0% 0 0 / 0.4) | background |
--slean-space-1 | 0.25rem | gap |
--slean-space-6 | 1.5rem | padding, padding-block, padding-inline |
--slean-space-4 | 1rem | padding, inset-block-start, inset-inline-end, padding-inline-end |
--slean-text-lg | 1.125rem | font-size |
--slean-font-weight-semibold | 600 | font-weight |
--slean-fg-muted | var(--slean-neutral-11) | color |
--slean-text-sm | 0.875rem | font-size |
--slean-space-2 | 0.5rem | gap, padding-inline-end |
--slean-control-height-sm | 2rem | inline-size, block-size, padding-inline-end |
--slean-radius-sm | 0.375rem | border-radius |
--slean-duration-fast | 100ms | transition |
--slean-icon-close | url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath d='M4.5 4.5l7 7m0-7l-7 7' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round' fill='none'/%3E%3C/svg%3E") | mask |
--slean-muted | var(--slean-neutral-3) | background |
State selectors the stylesheet targets, all from the platform or ARIA: ::backdrop, :hover, [open].
Variant attributes: data-slean-side (left, top, bottom).
Compatibility notes
Invoker commands shipped in Chrome 135, Safari 26 and Firefox 144 during 2025. closedby is implemented in Chrome 134 and Firefox 141 and not in Safari 27, where a backdrop click does nothing and Escape and the close action still close the drawer. Safari 27 has no overlay either: there the backdrop disappears at once and the exit slide is drawn in the page’s stacking order.
Examples
Sides
data-slean-side picks the edge. A side panel takes the full height; a top or bottom sheet
takes the full width and the height of its content. The side is physical and does not flip in a right-to-left
page.
<!-- data-slean-side: right (default), left, top or bottom. The side is physical. -->
<dialog id="drawer-menu" data-slean="drawer" data-slean-side="left" closedby="any"
aria-labelledby="drawer-menu-title">…</dialog>
<dialog id="drawer-notice" data-slean="drawer" data-slean-side="top" closedby="any"
aria-labelledby="drawer-notice-title">…</dialog>
<dialog id="drawer-share" data-slean="drawer" data-slean-side="bottom" closedby="any"
aria-labelledby="drawer-share-title">…</dialog>Testing
apps/playground/tests/primitives/inputs-overlays.spec.tsPlaywright, with page JavaScript enabled and disabled, and axepackages/styles/testsstatic checks of the stylesheet (layers, tokens, specificity, dark parity)
Source
packages/primitives/src/drawer/contract.mdthe contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behaviorpackages/primitives/src/drawer/contract.tstyped constants: name, tier, base, parts, options, eventspackages/styles/css/drawer.cssthe optional stylesheet