SheetMOL
Extends the dialog pattern to display content that slides in from the edge of the screen. Built on native <dialog> + showModal() with CSS-only slide animations via @starting-style. Supports four sides: right, left, top, bottom.
On this page (9)
§Right
Default side. The sheet slides in from the right edge - commonly used for edit-profile or settings panels.
§Bottom
Slides up from the bottom edge - great for cookie consent, action sheets, or preference panels.
§Density
Set data-density on the {''} root to scale the content padding. A whitespace policy, not a zoom: only padding scales (ratio 0.75 / 1 / 1.25), the slide-in width and typography stay identical. comfortable matches the unsized default.
§States
Named states via the shared State API, driven per instance through the bound api:
default- closed, off-screen (the authored state; the trigger opens it)open- shown modally viashowModal(), slid in from its side
The demo element carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/sheet-{state}.png.
Machine contract - verified against sheet.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
open | boolean | true, false | false | Shown via showModal(); hidden via close() (native open property). |
§API
Generated from sheet.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.
States
type SheetState = 'default' | 'open' - setState(name, config) takes the config of the state it names.
| State | Description |
|---|---|
default | Closed. No config. |
open | Open as a modal panel from its side (showModal()). No config. |
Every element
| Member | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
el.api.setState<S extends SheetState>(name: S, config?: SheetStateConfigs[S]): unknown | Enter a state: the DOM work runs (also when it is the current state), the store records it.
Returns | |||||||||
el.api.getState(): { name: SheetState; config: SheetStateConfigs[SheetState]; model?: ElementModel } | The state the element shows now - read back from the DOM, so it includes what the user changed. Returns | |||||||||
el.api.render(state?: { name: SheetState; config: SheetStateConfigs[SheetState]; model?: ElementModel }): string | The element's markup in a state - the authored markup with that state applied; a pure function of the state.
Returns | |||||||||
el.api.settled(): Promise<void> | Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting). Returns | |||||||||
el.store: Store<{ name: SheetState; config: SheetStateConfigs[SheetState] }> | A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component. |
Registry
| Member | Description | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
df$.shadcn.sheetApi.setState<S extends SheetState>(el: HTMLElement, name: S, config?: SheetStateConfigs[S]): unknown | Enter a state: the DOM work runs (also when it is the current state), the store records it.
Returns | ||||||||||||
df$.shadcn.sheetApi.getState(el: HTMLElement): { name: SheetState; config: SheetStateConfigs[SheetState]; model?: ElementModel } | The state the element shows now - read back from the DOM, so it includes what the user changed.
Returns | ||||||||||||
df$.shadcn.sheetApi.render(state: { name: SheetState; config: SheetStateConfigs[SheetState]; model?: ElementModel }): string | The element's markup in a state - the authored markup with that state applied; a pure function of the state.
Returns | ||||||||||||
df$.shadcn.sheetApi.store(el: HTMLElement): Store<{ name: SheetState; config: SheetStateConfigs[SheetState] }> | The element's store (bindComponent made it).
Returns | ||||||||||||
df$.shadcn.sheetApi.commit<S extends SheetState>(el: HTMLElement, name: S, config?: SheetStateConfigs[S]): void | Record a state the element reached on its own (no DOM work) - for a component's own handlers.
| ||||||||||||
df$.shadcn.sheetStates: SheetState[] | The declared states, 'default' first: default, open. |
§CSS view file
/* -- Sheet component ------------------------------------------- */@layer components { /* -- Base -------------------------------------------------- */ dialog.sheet { border: none; border-radius: 0; background-color: var(--background); color: var(--foreground); padding: 0; margin: 0; max-width: none; max-height: none; opacity: 0; transition: opacity 300ms ease, transform 300ms ease, display 300ms allow-discrete; &[open] { opacity: 1; } /* -- Side: right (default) --------------------------------- */ &, &[data-side="right"] { position: fixed; top: 0; right: 0; bottom: 0; left: auto; width: 24rem; max-width: 100vw; max-height: 100vh; height: 100%; border-left: 1px solid var(--border); transform: translateX(100%); } &[open], &[data-side="right"][open] { transform: translateX(0); } /* -- Side: left -------------------------------------------- */ &[data-side="left"] { position: fixed; top: 0; left: 0; bottom: 0; right: auto; width: 24rem; max-width: 100vw; max-height: 100vh; height: 100%; border-left: none; border-right: 1px solid var(--border); transform: translateX(-100%); &[open] { transform: translateX(0); } } /* -- Side: top --------------------------------------------- */ &[data-side="top"] { position: fixed; top: 0; left: 0; right: 0; bottom: auto; width: 100%; max-width: 100vw; /* a sheet, not a page: the backdrop always shows (content scrolls inside the modal past this) */ max-height: 85dvh; height: auto; border-left: none; border-bottom: 1px solid var(--border); transform: translateY(-100%); &[open] { transform: translateY(0); } } /* -- Side: bottom ------------------------------------------ */ &[data-side="bottom"] { position: fixed; bottom: 0; left: 0; right: 0; top: auto; width: 100%; max-width: 100vw; /* a sheet, not a page: the backdrop always shows (content scrolls inside the modal past this) */ max-height: 85dvh; height: auto; border-left: none; border-top: 1px solid var(--border); transform: translateY(100%); &[open] { transform: translateY(0); } } /* -- Backdrop ---------------------------------------------- */ &::backdrop { background: oklch(0 0 0 / 0); backdrop-filter: blur(0px); transition: all 300ms ease, display 300ms allow-discrete; } &[open]::backdrop { background: oklch(0 0 0 / 0.45); backdrop-filter: blur(3px); } } @starting-style { dialog.sheet[open], dialog.sheet[data-side="right"][open] { opacity: 0; transform: translateX(100%); } dialog.sheet[data-side="left"][open] { opacity: 0; transform: translateX(-100%); } dialog.sheet[data-side="top"][open] { opacity: 0; transform: translateY(-100%); } dialog.sheet[data-side="bottom"][open] { opacity: 0; transform: translateY(100%); } dialog.sheet[open]::backdrop { background: oklch(0 0 0 / 0); backdrop-filter: blur(0px); } } /* -- Scroll lock ------------------------------------------- */ /* Page behind stays put while a sheet is modal (see dialog.css): `:modal` + overflow:hidden freezes the viewport at its current offset. */ html:has(dialog.sheet:modal) { overflow: hidden; scrollbar-gutter: stable; } /* -- Content sections -------------------------------------- */ .sheet-content { padding: 1.5rem; position: relative; } /* -- Density ---------------------------------------------------- data-density on the .sheet root scales the content padding (0.75 / 1 / 1.25 of the 1.5rem default); comfortable matches the unsized default. The sheet's slide-in width is layout, not whitespace — it stays put. */ .sheet:where([data-density="compact"]) .sheet-content { padding: 1rem; } .sheet:where([data-density="comfortable"]) .sheet-content { padding: 1.5rem; } .sheet:where([data-density="spacious"]) .sheet-content { padding: 2rem; } /* Top/bottom sheets span the full viewport width - on wide screens the content would stretch unaligned, so keep a readable, centered column (the side sheets are already width-constrained at 24rem). */ dialog.sheet[data-side="top"] .sheet-content, dialog.sheet[data-side="bottom"] .sheet-content { max-width: 48rem; box-sizing: border-box; /* the 1.5rem padding is part of the 48rem column */ width: 100%; margin-inline: auto; } .sheet-header { margin-bottom: 1rem; padding-right: 2rem; } .sheet-title { font-size: 1.0625rem; font-weight: 600; margin: 0 0 0.375rem; letter-spacing: -0.01em; } .sheet-description { font-size: 0.875rem; color: var(--muted-foreground); margin: 0; line-height: 1.6; } .sheet-body { margin-top: 1rem; } .sheet-footer { display: flex; justify-content: flex-end; gap: 0.5rem; margin-top: 1.5rem; } /* -- Close button ------------------------------------------ */ .sheet-close-x { position: absolute; top: 1rem; right: 1rem; width: 1.75rem; height: 1.75rem; display: flex; align-items: center; justify-content: center; border: none; background: transparent; color: var(--muted-foreground); border-radius: var(--radius-sm); cursor: pointer; transition: color 150ms, background-color 150ms; &:hover { color: var(--foreground); background-color: var(--accent); } }}/* Accessibility: suppress motion for users who request it (REQUIRED for all components - AGENTS.md "Accessibility CSS"). Near-zero duration instead of `none` keeps transitionend/animationend (and discrete display flips) firing so JS state machines that await them keep working. */@media (prefers-reduced-motion: reduce) { @layer components { .sheet, .sheet *, .sheet::before, .sheet::after, .sheet *::before, .sheet *::after, .sheet::backdrop, .sheet-body, .sheet-body *, .sheet-body::before, .sheet-body::after, .sheet-body *::before, .sheet-body *::after, .sheet-body::backdrop, .sheet-close-x, .sheet-close-x *, .sheet-close-x::before, .sheet-close-x::after, .sheet-close-x *::before, .sheet-close-x *::after, .sheet-close-x::backdrop, .sheet-content, .sheet-content *, .sheet-content::before, .sheet-content::after, .sheet-content *::before, .sheet-content *::after, .sheet-content::backdrop, .sheet-description, .sheet-description *, .sheet-description::before, .sheet-description::after, .sheet-description *::before, .sheet-description *::after, .sheet-description::backdrop, .sheet-footer, .sheet-footer *, .sheet-footer::before, .sheet-footer::after, .sheet-footer *::before, .sheet-footer *::after, .sheet-footer::backdrop, .sheet-header, .sheet-header *, .sheet-header::before, .sheet-header::after, .sheet-header *::before, .sheet-header *::after, .sheet-header::backdrop, .sheet-title, .sheet-title *, .sheet-title::before, .sheet-title::after, .sheet-title *::before, .sheet-title *::after, .sheet-title::backdrop { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; } }}§JavaScript view file
Identical pattern to Dialog. Wire triggers via data-sheet-trigger, close buttons via data-sheet-close, and backdrop click. Targets dialog.sheet elements.
// -- Sheet ----------------------------------------------------// Wires [data-sheet-trigger] buttons to <dialog class="sheet"> elements,// plus the named-state API so agents/tests can drive open/closed by name// (AGENTS.md "State API").// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.import { defussGlobals, defussQuery, componentState, bindComponent } from '../../../shared/state-api.js';const df$ = defussGlobals();const dfDollar = defussQuery();const sheetStates = ['default', 'open'];// VERIFIED: (verify's API docs gate) the states below are exactly the declared ones, each// described, and every config field typed, described and named in the code./** setState() configs per state - the sheet's states take none. */export interface SheetStateConfigs { /** Closed. */ default: {}; /** Open as a modal panel from its side (showModal()). */ open: {};}/** * The markup of a state: 'open' carries `open`. render() applies it to a * detached copy; on the live element showModal()/close() (the native * protocol: top layer, focus, inert background) produce exactly this * attribute - the e2e render round trip proves they agree. */function applyMarkup(el, stateName) { dfDollar(el).attr('open', stateName === 'open' ? '' : null);}/** * UI side of setState: 'default' closes, 'open' opens modally. Native * <dialog> mechanics; close-focus-return is handled by the close listener. */function triggerStateChange(sheet, stateName, _config) { switch (stateName) { case 'default': if (sheet.open) sheet.close(); break; case 'open': if (!sheet.open) sheet.showModal(); break; }}/** The state a sheet shows: its `open` - read back after every change, and the state it starts in. */const shown = (sheet: HTMLDialogElement) => (sheet.open ? 'open' : 'default');/** Registry-level API; pass the sheet element explicitly. Unknown names throw. */export const sheetApi = componentState<HTMLDialogElement>({ component: 'sheet', states: sheetStates, apply: (sheet, state) => triggerStateChange(sheet, state.name, state.config), // the state is the dialog's `open`, as the schema observes it: a trigger or // a native commandfor button opens it without setState read: (sheet, state) => ({ name: shown(sheet), config: state.config }), markup: (el, state) => applyMarkup(el, state.name),});df$.sheetApi = sheetApi;df$.sheetStates = sheetStates;function init() {dfDollar('[data-sheet-trigger]:not([data-init])').toArray().forEach((trigger) => { trigger.dataset.init = ''; const sheet = dfDollar<HTMLDialogElement>('#' + CSS.escape(trigger.dataset.sheetTrigger)).get(0); if (!sheet) return; trigger.addEventListener('click', () => { sheet._trigger = trigger; sheet.showModal(); });});dfDollar<HTMLDialogElement>('dialog.sheet:not([data-init])').toArray().forEach((sheet) => { sheet.dataset.init = ''; // el.store + el.api (AGENTS.md "State through stores") bindComponent(sheet, sheetApi, { name: shown(sheet), config: {} }); sheet.addEventListener('click', (e) => { if (e.target === sheet) sheet.close(); }); dfDollar(sheet).find('[data-sheet-close]').toArray().forEach((btn) => { btn.addEventListener('click', () => { sheet.close(); }); }); sheet.addEventListener('close', () => { // `close` fires AFTER the exit transition (display allow-discrete), so a // fast re-open can beat it - a stale event must not yank focus out of an // open sheet. The state itself follows `open` through read(). if (sheet.open) return; if (sheet._trigger) sheet._trigger.focus(); });});}init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub