registry:ui · NativeHTML
sheet — a dialog that arrives from the side
A POSITIONING/ANIMATION variant of the native dialog, not a second state machine: showModal() (focus trap, Escape, top layer native), the same generation-token close path, bind:open lifecycle. The panel docks to an edge and slides in along its axis. Backdrop click is deliberately NOT wired — sheet content is often a form; a stray click shouldn't destroy it.
$npx jixoai-ui add sheet
https://ui.jixoai.com/r/sheet.json
overview
Overview
The side drawer on the dialog laws: a positioning/animation variant of the native <dialog>, not a second state machine — and the interior is the card kernel's.
sheet takes the platform's modal contract wholesale — showModal() supplies the top layer, the inert page
behind, the focus trap, and Escape (as the cancel event) — and
changes only where the panel sits and how it arrives: the panel
docks to an edge (side) full-length and slides in
along that axis from off-screen. The lifecycle is bind:open, the same contract as dialog;
the close path is one generation token so a reopen during the
slide-out supersedes the pending close. What sheet does NOT do is
re-decide semantics: backdrop click is deliberately not wired
(sheet content is often a form), and the footer is yours — CardFooter's carved action band is the standard face.
The delivery shape is the fleet's self-carried promotion: the §11
carriers stamp the <dialog> root itself, and
the UA promotes THAT element to the top layer — the resolved
axes ride the panel, never the trigger's tree. The interior is
not sheet's own paint: it is the card kernel's three bands (head
/ body cell / optional foot) under the sheet's 18px rhythm escape
hatch, with the × riding the kernel's end-action seat. The sheet
owns the mechanism — edge docking, the slide state machine, the
× glyph's scale — never the paint.
Two laws before you compose: the timing is a declared exception — the sheet keeps 200ms for entry, exit and the backdrop fade (the dialog family's law reads 120ms), and it is the family's last CSS-keyframe overlay pending its WAAPI kernel migration; and the modal contract is about focus, not scroll — the page behind is inert to focus and pointer interaction goes to the top layer, and wheel is swallowed at every hit target while the sheet is open (the page does not scroll; the scroller itself is never locked — programmatic scroll still works, nothing authors a lock). Lock it in your app shell if the drawer's content demands it.
sheet
Pick a side in the playground, then open: the panel slides from that edge. Escape or the × closes through the shared fade.
<Sheet bind:open title="Filters" {side} width="24rem">
<!-- body -->
</Sheet>law
The dialog laws, side-docked
The platform owns the modal contract; the sheet owns the mechanism — docking, the slide state machine, the generation-token close — and its 200ms is a declared exception, not a drift.
| Property | Type | Default | Description |
|---|---|---|---|
| the promotion | showModal() | the panel itself is the native <dialog> — top layer, inert page, UA focus trap and Escape-cancel come from the platform. The §11 carriers stamp the DIALOG ROOT (self-carried across the promotion — the resolved axes ride the panel, never the trigger's tree) | announces: the page behind is inert; the drawer carries its own scope |
| the close path | × · Escape · your footer · bind:open | one generation token: every path lands in shut(); a reopen during the slide-out SUPERSEDES the pending close (the timer's gen check), so a fast toggle never loses the panel | announces: open always wins the race |
| the 200ms exception | CLOSE_MS = 200 | the sheet keeps its own 200ms for entry, exit and the ::backdrop fade — a DECLARED exception to the dialog family's 120ms law (sheet.css keys --jx-surface-exit-ms: var(--motion-200)). It is also the family's r18 residue: the slide is a CSS keyframe state machine — the WAAPI kernel dialog adopted has not migrated here yet | announces: the last keyframe overlay — pending its kernel migration |
| the backdrop | a click outside the panel | NOTHING — backdrop click is deliberately not wired (sheet content is often a form; a stray click shouldn't destroy it). Close via ×, Escape, or your own footer action | announces: measured: a backdrop click leaves the sheet open |
| reduced motion | prefers-reduced-motion: reduce | the state machine double-kills it: the CSS animation: none AND shut() skips the timer — dialog.close() lands instantly, no 200ms wait | announces: the panel is simply there, then gone |
| the interior | header / children / footer | the card kernel's three bands carry the drawer: the head band (title face + the × on the end-action seat), the CardBody scroll cell under the sheet's 18px rhythm escape hatch, and the optional foot band — the footer snippet renders RAW, CardFooter's carved action band is the standard face | announces: the sheet owns the mechanism (docking, slide, × scale), never the paint |
types
Types
Four docking sides, a width along the docked axis, and the floating-surface paint.
side
left | right (default) | top | bottom — the panel docks full-length and slides in along its axis.
width
CSS length along the docked axis — 24rem default drawer extent; top/bottom panels cap at 85dvh. §13: the prop is width — a css width is never the scale axis.
variant
solid | acrylic | auto (default) — the floating-surface paint family, sliding on top of the surface rise.
usage
Usage
Backdrop click is deliberately not wired — sheet content is often a form; close via ×, Escape, or your own footer action.
<script lang="ts">
import Sheet from '@ui/sheet.svelte';
import CardFooter from '@ui/card/card-footer.svelte';
import PressButton from '@ui/press-button.svelte';
</script>
<PressButton onclick={() => (open = true)}>Filters</PressButton>
<Sheet bind:open title="Filters" side="right">
<p>Filter controls — focus stays trapped, Escape closes.</p>
{#snippet footer()}
<!-- the carved action band: the cluster fills the foot band
vertically, the rim Separator above is its top edge -->
<CardFooter>
<PressButton onclick={() => (open = false)}>Apply</PressButton>
</CardFooter>
{/snippet}
</Sheet>a11y
Accessibility
The native dialog carries the modal contract; title is REQUIRED — it is the dialog's accessible name.
Keyboard
| Key | Action |
|---|---|
| Tab | Cycles the drawer's controls — the showModal() modal containment: no control behind the sheet can take focus (Chromium may visit the body for one hop between cycles, then re-enters) |
| Escape | Cancel event, intercepted to share the 200ms animated close — focus restores to the trigger |
| Enter / Space | Activate the focused control (× button, footer actions) |
ARIA
| Attribute | Value | Description |
|---|---|---|
| aria-label | title (required) | On the dialog element — the title is the REQUIRED accessible name. |
| role | dialog (native) | The platform element; focus containment, inert page and top layer are native. |
| aria-label | "Close" | On the × button. |
api
API
Eight props; the same bind:open lifecycle and close path as dialog.svelte.
Properties
| Property | Type | Default | Description |
|---|---|---|---|
open bind | boolean | false | Bindable open state — same contract as dialog.svelte. |
| side | 'left' | 'right' | 'top' | 'bottom' | 'right' | The edge the panel docks to; slides along that edge’s axis. |
| title * | string | — | REQUIRED a11y: the dialog’s name (aria-label target). |
| children * | Snippet | — | Panel body. |
| header | Snippet | — | Optional header row content beyond the title + ×. |
| footer | Snippet | — | Optional sticky footer action row. |
| width | string | '24rem' · Own default, not ambient | Drawer extent along the docked axis (CSS length). §13 RENAME (W3-C): the prop was `size` — a css width is not the scale axis; the freed name belongs to the universal size lane (root font-size). |
| variant | 'solid' | 'acrylic' | 'auto' | 'auto' · Own default, not ambient | Floating-surface paint. Defaults: literal slot — own ’auto’, ambient when an axis opens. |
Universal props
| Property | Type | Default | Description |
|---|---|---|---|
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | the base scale — root font-size; parts size in em |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' · ambient scope | corner geometry (CSS corner-shape; §14 degrade table) |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | corner size; auto = the concentric broadcast (§3) |
| density | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | spacing/leading scale over the kernel channels (§4) |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' · ambient scope | the hue axis of the fixed oklch primary system (§5) |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' · ambient scope | light/dark profile; system = the JS-mutable global (§6) |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'auto' · ambient scope | official M3 levels over the surface ladder (§7) |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' · ambient scope | intensity across the motion kernels (§8) |
axes
The eight axes on sheet
The self-carried promotion: every resolved lane lands on the <dialog> root (carriers + the §3/§7 consumption pairs + --jx-sheet-size), and the top layer carries the scope — the page behind gets nothing. Seven axes are no-own; elevation is the family's ONE declared own (level4).
| Property | Type | Default | Description |
|---|---|---|---|
| density | 'small' | 'medium' | 'large' | 'auto' | number (+ the five legacy spellings) | 'auto' | SELF-CARRIED, RUNG STAMP — the resolved lane lands on the dialog root, which stamps the §4 legacy rung (measured: density="small" → data-density="sm" — today's CSS keys on the rungs); the top layer carries the scope for everything the drawer renders. Number unit: coefficient. |
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' | SELF-CARRIED — the §11 pair (--jx-size-effective + font-size) lands on the dialog root (measured 18px computed on the size demo); the body cell's own text step is 13px and stays the cell's unless the lane overrides the root. Number unit: px. |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' | SELF-CARRIED, ALIAS LADDER — the lane stamps --jx-shape-effective (+ the §14 radius factor) on the root; sheet.css's silhouette rule reads corner-shape: var(--jx-shape-effective, round). Number unit: none. |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' | SELF-CARRIED, THE CONCENTRIC ANCHOR — an explicit lane stamps --jx-radius-effective on the top-layered root and the consumed calc paints the silhouette (measured: radius={12} → 12px computed border-radius); 'auto' consumes the broadcast against the panel's OWN ANCESTORS — the declaration site in your tree, not the trigger's. Number unit: px. |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' | SELF-CARRIED, SUPPLY INTO THE TOP LAYER — --jx-color-effective lands on the root; the drawer's own chrome is inked popover-foreground (the frame atom) and reads no hue directly, so the lane's work is the scope it supplies to what you render inside. Number unit: hue degrees. |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' | SELF-CARRIED, THE BRIDGE — class:dark lands on the dialog root (measured), and because the whole card-dialect interior is DOM-descended from that root, the entire drawer re-voices inside the top layer — bands, separators, ink. No number lane. |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'level4' · the family's ONE declared own | OWN, level4 — the drawer's historic z-feel (8dp, the dialog rung it docks beside), the only axis with an opinion in SheetDefaults. An explicit lane overrides it (the carried level2 demo measures the swap); the §7 consumption pair stamps the shadow recipe + the paired ladder-rung surface on the root. Number unit: dp. |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' | SUPPLIED, NOT YET READ BY THE SLIDE — the carrier lands on the root, but the r18 keyframe state machine reads --motion-200/--motion-ease-nav directly, not the motion lane (the kernel migration is pending). The lane that DOES bite: prefers-reduced-motion kills the slide entirely (CSS + the instant-close path). Measured in pixels below. Number unit: coefficient. |
| Token | Default | Source |
|---|---|---|
| --jx-sheet-size | 24rem (width prop) | component |
| the slide keyframes | sheet.css, unlayered :where | structural |
| ::backdrop | var(--scrim) | structural |
| .jx-sheet-body-cell | the rhythm escape hatch | component |
| .jx-sheet-x svg | 14px descendant scale | component |
| data-jx-card host | card.css imported | structural |
Sheet · universal props
<!-- the §13 rename: a css WIDTH is 'width'; 'size' is the scale axis -->
<Sheet bind:open title="Filters" side="right" width="24rem">…</Sheet>
<!-- the §7 pair: the drawer's own level4, an explicit lane steps it -->
<Sheet bind:open title="Compact" width="18rem" elevation="level2">…</Sheet>