registry:ui · NativeHTML
dialog — the platform owns the hard parts
One native <dialog> element, opened with showModal() and closed with close(). Focus trapping, the inert page behind, top-layer rendering above every sticky header, and the Escape key are browser features — the component only binds open state to them and adds the shared WAAPI surface timeline. Closed dialogs render nothing in the page, with or without JavaScript. Since the structural kernel (2026-09-09) the interior is the CARD DIALECT: the head/body/foot bands, separators, inline ruler and narrow reversal all ride the data-jx-card sticker (card.css), the faces are the Card family parts (<CardHeader>, <CardBody>, <CardFooter> — the retired DialogHeader/DialogFooter were their clones), and the × rides the end-action seat the card sources reserved for it. The dialog owns the mechanism only: material, scrim, motion, close contract.
$npx jixoai-ui add dialog
https://ui.jixoai.com/r/dialog.json
overview
Overview
One native <dialog>, one motion addition: the platform owns the modal mechanics (top layer, Escape, closed-by-default, the scrim token), the component binds open state and drives the 460ms surface kernel, and the interior is the card dialect — the head/body/foot bands ride the data-jx-card sticker and the Card family faces.
The mechanism split is the component's design rule: everything the browser ships is
consumed as-is — showModal() lifts the panel into
the top layer above every sticky header, the cancel event carries Escape, form method="dialog" closes
natively, and a closed dialog renders nothing inline (no-JS loads included). The
component adds exactly two things: bindable open
state (rising edge → showModal(), falling edge → the teardown) and the shared 460ms --jx-p WAAPI kernel — entry, exit, and the scrim
ride one progress property, instant under reduced motion.
The interior is not dialog flesh: since the structural kernel the panel grows the CARD
DIALECT — the interior host stamps data-jx-card
and card.css's rule set applies (the five-column inline ruler, three integer bands, the
footer's narrow reversal), the faces are CardHeader
/ CardBody / CardFooter (the head band is unconditional — the ×
contract — the foot band exists iff the footer snippet is passed), and the ghost variant
scopes are written over both zones. The dialog owns the mechanism only: material
(variant), scrim, motion, close contract.
The measured contract on the served page: the surface width is min(92vw, 26rem) (416px at desktop, 368px at a
400px viewport — responsive, measured); the scrim is achromatic --scrim (black 32% in light, white 10% in dark —
never a brand tint; the staged demos pin light); the platform focus trap holds for the
in-dialog cycle but LEAKS every second Tab to the page's skip link (measured per-press
by two reviewers — the repair is queued, W-next #17); and the elevation axis is OWN
level4 but STAMPED, NOT PAINTED (W-next #18) — the one axis lane that paints through the
top layer is radius, the §3 concentric anchor (a 20px seat computes 6px on an auto
child, digit-exact). Kinship: system-dialog (the
non-modal, popover-based cousin), sheet (the
edge-anchored twin), popover (the
anchor-positioned non-modal surface).
dialog
One native <dialog> driven by showModal(): the browser owns Escape and the top layer — the component adds bindable open state and the shared WAAPI surface timeline. The platform focus trap holds for the in-dialog cycle but LEAKS every second Tab to the page's skip link (measured per-press; the repair is the family's, W-next). Retitle it from the Playground; preview the scrim in both stage themes.
<script lang="ts">
import Dialog from '@ui/dialog.svelte';
import PressButton from '@ui/press-button.svelte';
</script>
let open = $state(false);
let title = $state('Deploy queued');
</script>
<PressButton onclick={() => (open = true)}>Open dialog</PressButton>
<Dialog {title} bind:open>
<p>build #128 is waiting for a runner. The log streams once it picks up.</p>
</Dialog>demo
Basic
A PressButton flips a bindable open state; the dialog does the rest. Try the × button, the Escape key, and Tab — the in-dialog cycle holds and the page behind is inert, with ONE measured exception: every second Tab reaches the page's skip link (the one page focusable outside the modal's inertness; the next press returns — W-next #17).
dialog · basic
open = false<script lang="ts">
import Dialog from '@ui/dialog.svelte';
import PressButton from '@ui/press-button.svelte';
</script>
let open = $state(false);
</script>
<PressButton onclick={() => (open = true)}>Open dialog</PressButton>
<Dialog title="Deploy queued" bind:open>
<p>build #128 is waiting for a runner. The log streams once it picks up.</p>
</Dialog>Every exit — ×, Escape, or setting open = false from
code — runs the same 460ms surface timeline before the real close(). Reduced-motion users get the instant close.
<script lang="ts">
import Dialog from '@ui/dialog.svelte';
import PressButton from '@ui/press-button.svelte';
</script>
let open = $state(false);
</script>
<PressButton onclick={() => (open = true)}>Open dialog</PressButton>
<Dialog title="Deploy queued" bind:open>
<p>build #128 is waiting for a runner. The log streams once it picks up.</p>
</Dialog>demo
With a footer — form type
The footer snippet is the RAW override of the foot zone — and its standard content is <CardFooter>: the buttons passed as children auto-join one button-group at the row's inline end, ghost by default through the Dialog's zone scope, an explicit fill still winning for the primary. Cancel drops the dialog; Confirm does its work first, then closes through the same animated path. The form shells inside are engraved WELLS (the entity law, r14-12): the dialog is the one solid object — the inputs keep their crisp hairline edge while their ground dissolves into the panel's surface, the well inset carrying the depth; focus still tints, hover still deepens.
—<script lang="ts">
import Dialog from '@ui/dialog.svelte';
import CardFooter from '@ui/card/card-footer.svelte';
import PressButton from '@ui/press-button.svelte';
</script>
let open = $state(false);
const confirm = () => {
open = false;
// ...rotate the key
};
</script>
<Dialog title="Rotate API key" bind:open>
<p>Minting a new key revokes the current one after 24 hours.</p>
{#snippet footer()}
<CardFooter>
<PressButton onclick={() => (open = false)}>Cancel</PressButton>
<PressButton variant="fill" onclick={confirm}>Rotate key</PressButton>
</CardFooter>
{/snippet}
</Dialog>demo
CardHeader — a custom head
The head snippet replaces the visible title row, and CardHeader is its content face: children ride flush, edge-to-edge — the content owns the row's height and padding (an Input shell brings its own), no zone insets intervening. The × close button still rides the head grid's end slot, and title keeps naming the dialog for assistive tech even though its visual row is gone — the search palette composes this same seam.
dialog · custom head
—<script lang="ts">
import Dialog from '@ui/dialog.svelte';
import CardHeader from '@ui/card/card-header.svelte';
import Input from '@ui/input.svelte';
import Icon from '@ui/icon.svelte';
</script>
let open = $state(false);
let query = $state('');
</script>
<Dialog title="Filter events" bind:open>
{#snippet head()}
<!-- col-start-1 = the flush escape: the face's column start
pins to the grid's first line while the x seat keeps its
own column on the same row -->
<CardHeader class="col-start-1">
<Input class="w-full min-w-0" placeholder="Filter events…" bind:value={query} aria-label="Filter events">
{#snippet innerInlineStart()}
<span class="flex-none select-none text-muted-foreground" aria-hidden="true"><Icon name="search" /></span>
{/snippet}
</Input>
</CardHeader>
{/snippet}
<!-- the body lists the events filtered by query -->
</Dialog><script lang="ts">
import Dialog from '@ui/dialog.svelte';
import CardHeader from '@ui/card/card-header.svelte';
import Input from '@ui/input.svelte';
import Icon from '@ui/icon.svelte';
</script>
let open = $state(false);
let query = $state('');
</script>
<Dialog title="Filter events" bind:open>
{#snippet head()}
<!-- col-start-1 = the flush escape: the face's column start
pins to the grid's first line while the x seat keeps its
own column on the same row -->
<CardHeader class="col-start-1">
<Input class="w-full min-w-0" placeholder="Filter events…" bind:value={query} aria-label="Filter events">
{#snippet innerInlineStart()}
<span class="flex-none select-none text-muted-foreground" aria-hidden="true"><Icon name="search" /></span>
{/snippet}
</Input>
</CardHeader>
{/snippet}
<!-- the body lists the events filtered by query -->
</Dialog>demo
Scrolling body — head and foot stay pinned
The panel itself never scrolls: the scroll ring is a row-ruled grid (head · separator · body · separator · foot) under a height cap, and the body zone is the only scroll environment — its scrollbar rides the zone edge with a stable both-edges gutter while the header bar and footer cluster stay pinned. The scroll itself is DECLARATIVE: scroll=false asserts the body fits — the scroll authority and the gutter reservation retire together, the content keeps its full width. The class prop here caps the ring (a geometry-only override) so the scroll shows even on tall viewports.
dialog · scrolling body
<script lang="ts">
import Dialog from '@ui/dialog.svelte';
import CardFooter from '@ui/card/card-footer.svelte';
import PressButton from '@ui/press-button.svelte';
</script>
let open = $state(false);
</script>
<Dialog
title="Event log"
bind:open
class="[&_[data-jx-card]]:max-h-[22rem]"
>
<ol class="flex flex-col gap-1 font-mono text-[12px]">
{#each lines as line, i}
<li class="flex gap-3"><span class="text-muted-foreground">{i + 1}</span>{line}</li>
{/each}
</ol>
{#snippet footer()}
<CardFooter>
<PressButton onclick={() => (open = false)}>Close</PressButton>
<PressButton variant="fill" onclick={exportLog}>Export log</PressButton>
</CardFooter>
{/snippet}
</Dialog><script lang="ts">
import Dialog from '@ui/dialog.svelte';
import CardFooter from '@ui/card/card-footer.svelte';
import PressButton from '@ui/press-button.svelte';
</script>
let open = $state(false);
</script>
<Dialog
title="Event log"
bind:open
class="[&_[data-jx-card]]:max-h-[22rem]"
>
<ol class="flex flex-col gap-1 font-mono text-[12px]">
{#each lines as line, i}
<li class="flex gap-3"><span class="text-muted-foreground">{i + 1}</span>{line}</li>
{/each}
</ol>
{#snippet footer()}
<CardFooter>
<PressButton onclick={() => (open = false)}>Close</PressButton>
<PressButton variant="fill" onclick={exportLog}>Export log</PressButton>
</CardFooter>
{/snippet}
</Dialog>W3C foundation
What the platform gives, what we add
The design rule for this component: every behavior the browser ships is consumed as-is; the component only owns state binding and one motion. Anything beyond that is a named extension direction, not hidden magic.
platform-native, free
-
showModal()— top-layer rendering, focus trap, inert background -
::backdrop— the scrim pseudo-element;--scrim: semi-transparent black in light mode, white in dark mode — a scrim dims/lightens, never colors - Escape — the
cancelevent, intercepted only to share the fade - closed by default — no-JS page loads never paint dialog content inline
-
form method="dialog"— footer submits close natively (instant, skips the fade)
jixoai additions & extensions
-
bind:open— rising edge callsshowModal(), falling edge runs the teardown - surface timeline — the single motion addition: the 460ms --jx-p kernel drives entry, exit, and the scrim; instant under reduced motion
- floating-surface law — the hard offset shadow is a REAL
::afterlayer;@starting-styleentry pulls the layers apart, the close fade presses them back;variant="solid | acrylic | auto"paints the surface (acrylic = dual-layerbackdrop-filter) - × close button — press physics, right of the header bar
- extension: backdrop-click close; intercepting form submits so they fade too
types
Dialog variants
Title and footer are the two compositional axes; variant paints the surface.
titled
The header bar renders when title is given — heading left, × close right.
chrome-less / footer
Omit title for a bare body; the footer snippet adds the separator-bounded foot zone — its standard content is CardFooter, whose buttons auto-join one end-packed group, ghost by default.
variant
solid | acrylic | auto (default) — acrylic is a dual-layer backdrop-filter, auto defers to the environment's transparency preference.
usage
Usage
Flip bind:open from anywhere — every exit (×, Escape, code) runs the same animated close.
<script lang="ts">
import Dialog from '@ui/dialog.svelte';
import PressButton from '@ui/press-button.svelte';
</script>
let open = $state(false);
</script>
<PressButton onclick={() => (open = true)}>Open dialog</PressButton>
<Dialog title="Deploy queued" bind:open>
<p>build #128 is waiting for a runner. The log streams once it picks up.</p>
</Dialog>a11y
Accessibility
The native dialog element carries the modal contract — role and Escape are the platform's, fully; the platform focus trap holds for the in-dialog cycle but LEAKS every second Tab (measured, per-press receipts by two reviewers: in-dialog tabbable → the page skip link → in-dialog tabbable — the only page focusable that escapes the modal inertness).
Keyboard
| Key | Action |
|---|---|
| Tab | Cycles inside the dialog — the showModal() focus trap, WITH A MEASURED LEAK: every second press reaches the page's skip link (the one page focusable the modal inertness does not cover; reproduced per-press, two independent runs — the repair is the family's, W-next). From the skip link the next press returns to the dialog — the rest of the page IS inert |
| Escape | Cancel event, intercepted only to share the animated close |
| Enter / Space | Activate the focused control (× button, footer buttons, form method="dialog" submits) |
ARIA
| Attribute | Value | Description |
|---|---|---|
| aria-label | title | On the dialog element — the header heading when given. |
| role | dialog (native) | The platform element; no ARIA roles to maintain. |
| aria-label | "Close" | On the × button. |
theming
Density and tokens
The surface rides the shared motion kernel — one animated custom property drives entry, exit, and the scrim.
the trigger inherits the density scope, the surface inherits through the DOM tree — flip the canvas dock's density select (xs / sm / default / lg) to re-scope them together; the scrim reads in both stage themes the same way. The four-copy DensityDemo row is retired by that select.
| Token | Default | Source |
|---|---|---|
| --jx-p | 0 → 1 timeline | component |
| --scrim | black 32% / white 10% | color |
| --jx-surface-in-x/y | 0px / 6px | component |
| surface width | min(92vw, 26rem) | structural |
| close fade | 120ms (skipped under reduced motion) | structural |
| --jx-text | 11 / 12 / 13 / 15px | density |
axes
The eight axes on dialog
The eight-axis surface (explicit-props): size · shape · radius · density · color · theme · elevation · motion — each axis takes named steps, auto (inherit the ambient context; stamps nothing), an exact number (px · coefficient · dp · hue per axis), or query() for responsive/container-conditional values. The elevation axis on this modal is STAMPED, NOT PAINTED (measured): the level carriers stamp the top-layered root (radius proves the channel — the 20px seat anchors the concentric law for everything inside), but the shadow/rung recipe never paints on the served dialog — no box-shadow, filter, or backdrop-filter on any channel at settle, level4/level3/6dp/level-1/dark visually identical. The wiring decision (wire the shadow reader or retire the rung receipts) is the family owner's — W-next. The table classifies all eight axes against the served family; the query() seat rides the radius lane across the 48rem key.
| Property | Type | Default | Description |
|---|---|---|---|
| size | 'small' | 'medium' | 'large' | 'auto' | number | query() | 'auto' | SUPPLY-ONLY for the family's own paint — zero --jx-size-effective readers over ui/dialog/ (grep receipt); the carrier stamps the top-layered root (self-carried across the promotion), so composed head/footer snippet content scales while the panel's chrome stays pinned. Number unit: px. |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' | CONSUMED at the platform element — dialog.css paints corner-shape: var(--jx-shape-effective, round) on .jx-dialog (the §14 alias ladder, round by default; the §2 named steps). No number lane. |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | query() | 'auto' | CONSUMED — THE PAINTING LANE. dialog.css composes border-radius from --jx-radius-consumed (explicit lane: effective × the §14 factor; auto: the §3 concentric calc against the panel's OWN ancestors). An explicit lane makes the panel the CONCENTRIC ANCHOR — the carrier stamps --jx-radius-effective on the top-layered root, and children at radius="auto" compute max(0px, R − P) THROUGH the top layer (measured digit-exact: the 20px seat anchors 6px on its child). Number unit: px. |
| density | '2xs' | 'xs' | 'sm' | 'default' | 'lg' | 'auto' | number | query() | 'auto' · no family own | NO OPINION on value (DialogDefaults leaves the slot unparameterized) — a named rung rides the ambient scope channel (the top-layered <dialog> stays a DOM descendant for cascade) and the panel stamps the resolved rung as data-density; auto / number / query lanes omit the stamp (densityRungOf's undefined arm). Number unit: coefficient. |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' | SUPPLY-ONLY — zero --jx-color-effective / hue readers over ui/dialog/ (grep receipt); the panel's ink rides the ambient token sheet, and variant paints the surface (solid | acrylic | auto), not a hue. Number unit: hue degrees. |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' | THE ISLAND STAMP — theme="dark" stamps class:dark on the dialog root: a self-carried dark island THROUGH the top-layer promotion (the <dialog> stays a DOM descendant, so the island re-scopes every token read in the subtree — the card kernel's ground and ink re-derive inside it). The family itself carries zero var-chain theme reads (grep receipt). No number lane. |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | query() | 'level4' · Own default, not ambient | OWN level4 (the modal's historic 8dp z-feel), STAMPED, NOT PAINTED (measured — W-next #18): elevationSurfaceOf composes the level-table pair onto the root style, but no box-shadow / filter / background-image channel paints on the served dialog at settle — level4 / level3 / 6dp / level-1 / dark are visually identical (the seats below teach this measured truth). The number lane is real: exact dp snaps DOWN to the enclosing table rung (6dp IS level3). The wiring decision (wire the shadow reader or retire the rung receipts) is the family owner's. |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' | THE FAMILY OWNS THE MOTION, AXIS UNREAD — one 460ms --jx-p WAAPI kernel drives entry, exit, and the scrim (blurIn/slide/materials/shadow; the @starting-style pull-apart; the jx-waapi allow-discrete exit window holds the panel through the whole run), instant under prefers-reduced-motion. Zero --jx-motion-effective readers (grep receipt). Number unit: coefficient. |
Receipts: the PORTAL LAW — the top-layer promotion moves PAINT, not DOM, and the axis carriers stamp the panel's OWN root either way, so the resolved axes are SELF-CARRIED (a trigger ancestor's stamps never span the boundary); the radius channel is the one elevation-adjacent lane that PAINTS (the 20px seat → 6px auto child, measured digit-exact through the top layer); the elevation rungs are the stamped-not-painted class (W-next #18, the walk found zero shadow channels at settle); the theme axis is the island stamp (class:dark on the dialog root re-scopes the card kernel's tokens inside the subtree — the family itself carries zero var-chain theme reads, grep receipt); and LAW #19 held — duplicate ids NONE page-wide (measured on the served page). The query() seat below rides the md viewport key (48rem) on the radius lane: below it the anchor is 8px (the auto child clamps at max(0px, 8 − 14) = 0px), at md+ the anchor is 20px and the child steps to 6px — the concentric law moving WITH the query.
<script lang="ts">
import Dialog from '@ui/dialog.svelte';
import PressButton from '@ui/press-button.svelte';
import { query } from '@lib/universal-props-query.svelte';
</script>
const responsiveRadius = query({ md: 20 }, 8);
</script>
<Dialog title="Responsive radius" radius={responsiveRadius} bind:open>
<PressButton radius="auto">auto radius rides the anchor</PressButton>
</Dialog>Dialog · universal props
<!-- the §7 elevation × surface-ladder ladder (light, shadows lead) -->
<Dialog title="level4 · the modal default" bind:open={u1}>…</Dialog>
<Dialog title="level3 · one rung down" elevation="level3" bind:open={u2}>…</Dialog>
<!-- the number lane: exact dp snaps DOWN to the enclosing rung -->
<Dialog title="6dp · the number lane" elevation={6} bind:open={u3}>…</Dialog>
<!-- the concave rung: inset shadow + the deepest surface -->
<Dialog title="level-1 · the concave" elevation="level-1" bind:open={u4}>…</Dialog>
<!-- the concentric anchor: radius 20 supplies --jx-radius-effective
through the top layer; children at auto compute max(0px, R − P) -->
<Dialog title="radius 20 · the anchor" radius={20} bind:open={u5}>
<PressButton radius="auto">auto radius — 6px</PressButton>
</Dialog>api
API
Nine props — the platform owns every behavior; the component owns state binding, zone presence, and the zone variant scopes. The footer snippet is the RAW full override of the foot zone; the head/footer content faces are the composition components below.
Properties
| Property | Type | Default | Description |
|---|---|---|---|
| title | string | — | Heading of the default title row (rendered through CardHeader); omit for a chrome-less body. Still names the dialog (aria-label) when a head snippet replaces the visible row. |
open bind | boolean | false | Bindable open state: true → showModal(), false → animated close. |
| variant | 'solid' | 'acrylic' | 'auto' | 'auto' · Own default, not ambient | Floating-surface paint; auto defers to the environment’s transparency preference. Defaults: literal slot — own ’auto’, ambient when an axis opens. |
| class | string | '' | Geometry-only utilities appended after the law’s own (a consumer’s anchor/width, a scroll-ring cap); the platform still paints nothing. |
| scroll | boolean | true | The body zone’s scroll authority (the panel never scrolls). false asserts the body fits — the scroll authority and the stable both-edges gutter reservation retire together. |
| head | Snippet | — | Replaces the visible title row — typically a CardHeader wrapping custom content; the × close still rides the head grid’s end slot. |
| children * | Snippet | — | Dialog body — the only scrollable zone. |
| footer | Snippet | — | The RAW full override of the foot zone — its standard content is a CardFooter (buttons auto-joined in one end-packed group, ghost by the zone’s scope). |
| cancelGuard | () => boolean | — | Consulted on the native cancel request (Escape); returning true holds the dialog open (e.g. through an IME composition). |
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) |
api
CardHeader · CardFooter — the zone content faces
The slot architecture belongs to the zones' content, carried by components (r14-9): Dialog renders the zones and writes the ghost variant scopes; these two are what the zones usually show. CardHeader is also Dialog's internal default — the untitled title row has exactly one source.
Properties
| Property | Type | Default | Description |
|---|---|---|---|
| CardHeader · title | string | — | The default title row (padded chrome bar); yields to children. |
| CardHeader · children | Snippet | — | Custom head content, FLUSH edge-to-edge — owns its own geometry (the palette’s Input). |
| CardFooter · children | Snippet | — | The action buttons — auto-joined in ONE ButtonGroup packed at inline-end; ghost inherited from the Dialog zone scope, an explicit variant wins; ghost seams rule the buttons. |
| CardFooter · end | Snippet | — | Raw inline-end content: present, it replaces the grouped arrangement entirely — the opt-out for non-button content or a custom cluster, bracket and all. |
| CardFooter · opening line | structural | leadingSeam | The actions region’s boundary — the ButtonGroup’s leadingSeam capability: the first button’s own flush seam pseudo (r14-13), not a sibling element; gone with the group under the end face. |
| CardFooter · label | string | 'Dialog footer' | The ButtonGroup’s accessible name. |