sveltelean Primitives
Versionv0.2.0 GitHub

Example

Filters

Filters

Narrow the list of people.

Team
<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>
Tier 0: the two sources differ only by the stylesheet imports. Open it, then press Escape or click the backdrop; focus returns to the button. This works with page JavaScript disabled where the browser implements invoker commands.

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/styles
stylesheets
import '@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

PartElementdata-slean-partRequiredNotes
root<dialog id="…" closedby="any" data-slean-side="right">–yesNeeds 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">–yesOpens the drawer as a modal.
header<header>headerstyles onlyGroups the title and description; does not scroll.
title<h2>titlestyles onlyThe element aria-labelledby points at.
description<p>descriptionstyles onlyOptional; reference it with aria-describedby.
body<div>bodystyles onlyThe scrolling region between header and footer.
footer<footer>footerstyles onlyHolds 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-labelledby on 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

KeyWhenResult
Enter/Spacefocus on the invokerOpens the drawer (native)
Escapedrawer openCloses the drawer: cancel, then close (native)
Tab/Shift+Tabdrawer openStays inside the drawer because the rest of the document is inert (native)

Platform features

FeatureBaselineOutside the target
<dialog>, showModal(), top layer, ::backdrop, page inertnessWidely 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 availableThe drawer stays closed. Fallback: a two-line script calling showModal() and close(). The package ships no polyfill.
closedbyChrome 134 and Firefox 141; not in Safari 27A backdrop click does nothing; Escape and the close action still close
@starting-style, transition-behaviorBaseline 2024The drawer appears and disappears without the slide
overlay (exit transition)Chrome 117; not in Safari 27The 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:

TokenDefault (light)Applies to
--slean-space-82.5reminline-size, max-block-size
--slean-bordervar(--slean-neutral-6)border-left, border-right, border-bottom, border-top, border-block-start
--slean-surfaceoklch(100% 0 0)background
--slean-fgvar(--slean-neutral-12)color
--slean-shadow-lg0 16px 40px oklch(0% 0 0 / 0.18), 0 2px 6px oklch(0% 0 0 / 0.08)box-shadow
--slean-duration-slow240mstransition
--slean-easecubic-bezier(0.2, 0, 0, 1)transition
--slean-radius-lg0.875remborder-end-start-radius, border-end-end-radius, border-start-start-radius, border-start-end-radius
--slean-overlayoklch(0% 0 0 / 0.4)background
--slean-space-10.25remgap
--slean-space-61.5rempadding, padding-block, padding-inline
--slean-space-41rempadding, inset-block-start, inset-inline-end, padding-inline-end
--slean-text-lg1.125remfont-size
--slean-font-weight-semibold600font-weight
--slean-fg-mutedvar(--slean-neutral-11)color
--slean-text-sm0.875remfont-size
--slean-space-20.5remgap, padding-inline-end
--slean-control-height-sm2reminline-size, block-size, padding-inline-end
--slean-radius-sm0.375remborder-radius
--slean-duration-fast100mstransition
--slean-icon-closeurl("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-mutedvar(--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.

Workspace

A side panel against the left edge, as tall as the viewport.

Maintenance window

A sheet from the top, as tall as its content.

Share

A sheet from the bottom, padded for the safe area.

sides
<!-- 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

Source