registry:ui + registry:lib
toast — two seams, no singleton
The store owns state and lifecycle (created by YOUR app, never a module singleton — SSR requests never share state); the viewport owns presentation — a GRID stack that ADOPTS into the website-scaffold's float plane when one is present (the float-button law: it never floats itself; the fixed corner is the standalone fallback), max visible with older queued, per-item live regions — role=status polite / role=alert assertive, and the unified hold (hover/focus freezes BOTH clocks — the store timer and the countdown companion), duration 0 is sticky.
$npx jixoai-ui add toast
https://ui.jixoai.com/r/toast.json
overview
Overview
Two seams: a DOM-free store whose per-toast clock answers to three orthogonal freeze sources, and a viewport that adopts the scaffold's float plane — the stack dialect, per-item live regions, and the self-carried axis carriers.
The store owns state and lifecycle; the viewport owns presentation. A push queues a card into a GRID stack where every card rides the SAME cell — depth is transform vars, never overlaid positions. The clock model: one expiry timer per toast with THREE orthogonal freeze sources — the hover/focus hold, the hidden-tab pauseAll, and the queued-never-seen visibility handshake — that never stomp each other; expiry arms at FIRST VISIBILITY, so a toast beyond maxVisible never expires unseen.
Announcements are per-item: every card is its OWN live region — role=status polite, role=alert when assertive — never one region announcing everything, and the +N queued chip is aria-hidden decoration. Delivery shape: the viewport is a SELF-CARRIED PORTAL — it adopts the scaffold's float plane (the jx-top-layer context), and because a trigger ancestor's CSS carriers never span a portal adoption, the viewport stamps its OWN carriers on the stack root (the density rung, the theme island, the elevation pair). Timed surfaces measured: the auto-dismiss window, the 220ms exit-snapshot frame (reduced motion removes immediately), the 200ms/180ms enter/exit pair, and the countdown drain with its paused play-state.
The keys: a monotonic per-store counter (id: number, nextId++) — duplicate keys
structurally impossible, the LAW #18 worst case (a constant burst) hydration-safe by
construction; the expandable dialog's shared-element name jx-toast-<id> inherits
the uniqueness. The eight axes: elevation is OWN level3 and CONSUMED (the card's ground
and press shadow read the level pair through the popover fallback), density is
ambient-consumed through the kernel lanes, theme splits the card's ink system into a
:root-frozen ground/foreground and a scope-re-derived hue pair, and
size/shape/radius/color/motion carry the supply chain (zero effective readers, grep
receipt). Kinship: float-button (the material
and layers model the card rides), system-dialog (the modal interruption toast refuses — no scrim, the page stays reachable), combobox (the other per-item announcement law).
usage
Usage
Create the store in app state (never a module singleton), mount the viewport once in the root layout, push from anywhere through the api handle.
<script lang="ts">
import ToastViewport from '@ui/toast-viewport.svelte';
import { createToastStore } from '@lib/toast-store';
</script>
// app state — created once, never a module singleton (SSR stays clean)
const toast = createToastStore();
let lastDeploy = $state('');
</script>
<ToastViewport store={toast} /> <!-- once, root layout -->
<PressButton onclick={() => {
lastDeploy = '4f2a';
toast.api.push({ title: 'Deployed', description: 'build 4f2a' });
}}>deploy</PressButton>
<PressButton onclick={() =>
toast.api.push({ title: 'Build failed', variant: 'tonal', class: 'jx-hue-error', assertive: true, duration: 0 })
}>break it (sticky)</PressButton>toast
Push a polite toast, a sticky assertive one, or a burst — hover a toast to freeze its countdown, drag the front card toward an edge to swipe it away, click an expandable card to read it as a dialog; the × dismisses. Older toasts queue past the visible four, and the viewport says so with the +N queued tail chip.
the store is deliberately DOM-free — anything (a worker, a server event bridge) can push through the api handle. The viewport is the only DOM half, mounted once.
<ToastViewport store={toast} />types
Toast variants
The ladder drives border + ink over the floating popover ground; hue is injected through the class seam (a failed status injects --error — never the destructive action hue); assertive switches the live-region role; duration 0 is sticky.
toast · variants
<script lang="ts">
import PressButton from '@ui/press-button.svelte';
import ToastViewport from '@ui/toast-viewport.svelte';
import { createToastStore } from '@lib/toast-store';
</script>
// app state — created once, never a module singleton (SSR stays clean)
const toast = createToastStore();
<div class="grid gap-4 sm:grid-cols-3">
<div class="border border-border p-4"><PressButton onclick={() => toast.api.push({ title: 'Deployed', description: 'outline · polite' })}>outline</PressButton></div>
<div class="border border-border p-4"><PressButton onclick={() => toast.api.push({ title: 'Synced', description: 'tonal · brand tint · polite', variant: 'tonal' })}>tonal</PressButton></div>
<div class="border border-border p-4"><PressButton onclick={() => toast.api.push({ title: 'Build failed', description: 'error status · assertive · sticky', variant: 'tonal', class: 'jx-hue-error', assertive: true, duration: 0 })}>error</PressButton></div>
</div>
<!-- once, root layout -->
<ToastViewport store={toast} />toast-v2
The stack: depth, swipe, the hidden page
One grid cell holds every wrapper (grid-area 1/1) — the stack DIALECT, not overlaid positions. THE NINE SLOTS (R3): the float plane's stage cell takes nine physical positions — left-top … right-bottom via the pos prop; the DEFAULT is right-bottom (sonner's corner). THE GROWTH LAW: the pile grows AWAY from its slot's block edge — top slots descend into the content, every other slot climbs. Collapsed, each card sits one gap deeper and 5% smaller per depth; hover or touch expands to the full list while back boards keep the geometry and surrender only the ink (text-transparent — subtraction, never reflow). Swipe dismisses toward the stack's nearest screen edges; a hidden page freezes every clock — three orthogonal freeze sources, one timer; an expandable toast morphs into its reading posture through a view transition.
toast · stack
Three long clocks stack with depth; pointer enter expands (the gap-sum ladder), leave collapses — the force-expand prop pins the posture.
A carry at or past 48px — or a flick above 0.11 px/ms — dismisses along an allowed axis; the cross axis carries at 0.2 friction. Defaults come from the slot (right-bottom → right + down, toward the nearest edges; the center takes none); a push names its own.
Push, then switch tabs: pauseAll froze the running clocks; coming back resumes exactly the remaining time. Hover holds and the queued-never-seen stay untouched — three freeze sources never stomp each other.
Card and dialog share jx-toast-<id>: the platform morphs one into the other (a WAAPI rect-rise where VT is absent). The dialog is popover=auto — light dismiss collapses back; the clock stays paused while expanded.
<script lang="ts">
import PressButton from '@ui/press-button.svelte';
import ToastViewport from '@ui/toast-viewport.svelte';
import { createToastStore } from '@lib/toast-store';
</script>
// app state — created once, never a module singleton (SSR stays clean)
const toast = createToastStore();
<div class="grid w-full gap-4 sm:grid-cols-2">
<div class="border border-border p-4 grid gap-2"><PressButton onclick={() => { for (let i = 0; i < 3; i++) toast.api.push({ title: `Sync step ${i + 1}`, description: 'hover the stack — it expands to the full list', countdown: true, duration: 15000 }); }}>collapse → hover expand</PressButton><p class="text-sm text-muted-foreground">Three long clocks stack with depth; pointer enter expands (the gap-sum ladder), leave collapses — the force-expand prop pins the posture.</p></div>
<div class="border border-border p-4 grid gap-2"><PressButton onclick={() => toast.api.push({ title: 'Draggable', description: 'drag me toward the right screen edge — or down' })}>swipe to dismiss</PressButton><p class="text-sm text-muted-foreground">A carry at or past 48px — or a flick above 0.11 px/ms — dismisses along an allowed axis; the cross axis carries at 0.2 friction. Defaults come from the slot (right-bottom → right + down, toward the nearest edges; the center takes none); a push names its own.</p></div>
<div class="border border-border p-4 grid gap-2"><PressButton onclick={() => toast.api.push({ title: 'Switch tabs now', description: 'this clock freezes while the page is hidden', countdown: true, duration: 20000 })}>freezes when hidden</PressButton><p class="text-sm text-muted-foreground">Push, then switch tabs: pauseAll froze the running clocks; coming back resumes exactly the remaining time. Hover holds and the queued-never-seen stay untouched — three freeze sources never stomp each other.</p></div>
<div class="border border-border p-4 grid gap-2"><PressButton onclick={() => toast.api.push({ title: 'Incident 4f2a', description: 'click the card for the full reading posture', countdown: true, duration: 15000, expandable: true })}>expandable → dialog</PressButton><p class="text-sm text-muted-foreground">Card and dialog share jx-toast-<id>: the platform morphs one into the other (a WAAPI rect-rise where VT is absent). The dialog is popover=auto — light dismiss collapses back; the clock stays paused while expanded.</p></div>
</div>
<!-- once, root layout -->
<ToastViewport store={toast} />theming
Density and tokens
The stack is floating chrome — it ADOPTS the scaffold's float plane (the fixed corner is only the standalone fallback); the demo trigger follows the density scope while the card paints through theme colors and the tone law.
| Token | Default | Source |
|---|---|---|
| --popover / --popover-foreground | theme colors | color |
| --jx-tonal / --jx-outline | primary / border | color |
| jx-toast-in / jx-toast-out | 200ms / 180ms | component |
| EXIT_MS | 220ms | structural |
| maxVisible | 4 | structural |
| store.setVisible | viewport-only | structural |
| pos | right-bottom | structural |
| expand / gap | false / 8px | structural |
| SWIPE_BY_FLOAT_POS | right-bottom: right, down | structural |
| 48px / 0.11 px·ms / 0.2 | toast-swipe constants | structural |
| store.pauseAll / resumeAll | page-visibility | structural |
api
API
The viewport is presentation-only; the store handle (api.push / api.dismiss / api.snapshot, subscribe, pause, resume) is the other seam.
Properties
| Property | Type | Default | Description |
|---|---|---|---|
| store * | ToastStore | — | The app-created store (createToastStore()) — never a module singleton. |
| maxVisible | number | 4 | Max toasts rendered at once; older ones stay queued behind the +N queued chip. 0 renders none (everything queues — never a wider render). |
| pos | FloatPos | — | The float slot’s nine-grid position (left-top … right-bottom). Default right-bottom; the pile grows AWAY from the slot’s block edge and swipes toward its nearest screen edges. |
| expand | boolean | false | Pins the expanded posture — the full list, every card at its own height. Hover/touch still lifts into it while inside. |
| gap | number | 8 | The stack’s rung spacing in px — the collapsed depth stairs and the expanded ladder both space by it. |
| swipeDirections | readonly SwipeDirection[] | — | Default swipe directions when a push names none — resolved per SLOT toward its nearest screen edges (right-bottom → right + down; the center takes none). |
| class | string | '' | Extra classes on the corner stack. |
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.promise(task, messages) — messages: pending / success / error
| Property | Type | Default | Description |
|---|---|---|---|
| pending * | string | Omit<ToastInit, 'id'> | — | The in-flight notice — pushed at call time, sticky by default; replaced on settle. |
| success | string | init | (value) => … | — | On resolve: replaces the pending toast (polite, default expiry). Omitted → the pending toast is simply dismissed. |
| error | string | init | (reason) => … | error shape | On rejection: tonal + jx-hue-error, assertive, sticky — a consumer object overrides field-by-field. Omitted → the default error shape still lands. |
push(init) — ToastInit
| Property | Type | Default | Description |
|---|---|---|---|
| title * | string | — | Primary line, uppercase nav voice. |
| description | string | — | Secondary muted line. |
| variant | 'outline' | 'tonal' | 'outline' · Own default, not ambient | Ladder prominence: outline = plain notice over the material ground; tonal = 12% tinted ground + tonal ink. Own default, not ambient (toast is outside the paint zone’s frozen availability table). |
| material | 'popover' | 'glass' | 'popover' · Own default, not ambient | The surface MATERIAL (float-button model): popover = solid ground; glass = the shared glass law sheet's frost (data-jx-effect stamp, tuned 12px / saturate 1 / popover 55%) — the entity-law restrained ground. |
| effect | 'none' | 'pulse' | 'sweep' | 'none' | The effect LOOP: pulse = a breathing ring (attentive); sweep = a traveling light (live/in-flight). |
| countdown | boolean | false | Render the countdown companion as the card’s FLOOR — a full-width drain pinned to the bottom edge; the unified hold freezes both clocks. Sticky toasts get none. |
| leading / trailing | Snippet | — | The toast-grid lane slots: an icon leading, actions or a custom companion trailing. |
| class | string | — | Consumer classes — the hue-injection seam, e.g. jx-hue-error. |
| duration | number | 5000 | ms until auto-dismiss; 0 = sticky. |
| assertive | boolean | false | The viewport announces this one as role=alert. |
| expandable | boolean | false | toast-v2: clicking the card expands it into a dialog through a view transition (shared element name jx-toast-<id>; a WAAPI rect-rise where VT is absent). The clock stays paused while expanded; light dismiss collapses back. |
| swipeDirections | readonly SwipeDirection[] | SWIPE_BY_FLOAT_POS[slot] | toast-v2: the drag axes that may dismiss this toast (up/down/left/right). The default vocabulary comes from the stack’s slot — right-bottom takes right + down (toward the nearest edges); the center takes none. |
axes
The eight axes on toast
Elevation is OWN level3 and CONSUMED — the card's ground and press shadow read the level pair through the popover fallback (the snackbar rung); density is ambient-consumed through the kernel lanes with the rung scope stamped on the stack root; theme splits the card's ink system into a :root-frozen ground/foreground and a scope-re-derived hue pair — the carriers portal WITH the stack; size/shape/radius/color/motion carry the supply chain.
| Property | Type | Default | Description |
|---|---|---|---|
| density | '2xs' | 'xs' | 'sm' | 'default' | 'lg' | 'auto' | number (+ the five legacy spellings) | 'auto' | OWN NO-OPINION, AMBIENT-CONSUMED — the card's type rhythm rides the kernel lanes (--jx-text on the description, the leading rung on the dialog panel, --jx-hit/--jx-inset on the panel actions); a named rung stamps data-density ON THE STACK ROOT (the scope portals with the stack — measured lg/sm seats), a number or query lane rides the --jx-density-coefficient carrier through the kernel calc chain. Number unit: coefficient. |
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' | SUPPLY-ONLY for the family's own paint — zero --jx-size-effective readers over ui/toast/ (grep receipt); the carrier's font-size lands on the stack root, so composed leading/trailing snippet content scales while the card's own type stays pinned to the label/text tokens. Number unit: px. |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' | SUPPLY-ONLY — zero --jx-shape-effective readers (grep receipt); the card corner is the static --jx-radius token. Number unit: none. |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' | SUPPLY-ONLY — the concentric broadcast for the reading panel: zero --jx-radius-effective readers over ui/toast/ (grep receipt); the card corner is tokens --jx-radius, not the axis calc. Number unit: px. |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' | SUPPLY-ONLY axis — zero --jx-color-effective readers (grep receipt); the card's hue rides the CLASS SEAM instead (jx-hue-error retints --jx-tonal/--jx-outline through the variant ladder), per the API table. Number unit: hue degrees. |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' | THE SPLIT INK SYSTEM (measured, probe): the card's ground and foreground ride --jx-popover/--jx-popover-foreground — :root-pinned aliases (shape #2, substitution at the declaring element), so a scoped island holds them and ROOT-level dark re-derives; the variant hue pair --jx-tonal/--jx-outline is RE-DECLARED per theme scope (the :root, .jx-light, .dark selector list — substitution re-runs against each scope's --primary/--border), and the elevation SURFACE rung re-derives too, so a dark island flips the card's ground while the popover aliases hold (the measured split). The carrier is the .dark CLASS, never a style var — the viewport stamps class:dark on the stack root: a self-carried dark island THROUGH the portal. No number lane. |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'level3' · Own default, not ambient | OWN level3, CONSUMED — the snackbar rung: elevationSurfaceOf stamps --jx-elevation-shadow / --jx-elevation-surface / --jx-surface-solid-fill on the stack root, and the card reads the pair with the popover fallback (background: var(--jx-elevation-surface, var(--jx-popover)); the press shadow composes --jx-elevation-shadow). The number lane is exact dp snapped DOWN to the table rung. Measured: the level3 pair in the stack's inline style; level4 seat flips the rung. |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' | THE FAMILY OWNS THE MOTION, AXIS UNREAD — enter 200ms (jx-toast-in, --motion-200 nav ease), exit 180ms forwards, the 220ms EXIT_MS snapshot window a dismissed card paints before unmount (reduced motion removes IMMEDIATELY and skips the snapshot), pulse 1.6s / sweep 2.4s loops, and the countdown drain (linear forwards; .paused = play-state paused; RTL reverses direction) — every channel killed under prefers-reduced-motion (toast.css). Zero --jx-motion-effective readers (grep receipt). Number unit: coefficient. |
Receipts: the PORTAL LAW (the viewport adopts the scaffold's float plane through the jx-top-layer context — a trigger ancestor's CSS carriers never span the adoption, so the viewport stamps its OWN carriers on the stack root: the density rung, the .dark class, the elevation pair, the coefficient), the announcement trio (every card its own live region — role=status polite / role=alert assertive, content present AT INSERT, the +N chip aria-hidden; queued cards are NOT in the accessibility tree until they render), the TRANSITION-FRAME battery (auto-dismiss windows measured with a MutationObserver page-clock instrument across multi-run ranges; the 220ms exit-snapshot frame measured, reduced-motion removal immediate; the hover hold and the hidden-tab pauseAll freeze the same clock), the queue law (burst ×5 → four visible + "+1 queued"; dismissing the front promotes the oldest queued card — FIFO order preserved, expiry arms at first visibility), the id landscape (a monotonic per-store counter — duplicate keys structurally impossible; expandable dialogs carry unique jx-toast-<id> shared-element names) and LAW #19 (duplicate ids NONE page-wide) were measured on this page's served DOM (probe, task 44). LAW #18 note: the each keys item.id — the counter makes the LAW #18 worst case (a constant burst) hydration-safe by construction. The query() seat below rides the md viewport key (48rem) on the density lane; the dark-island seat renders the measured theme split live.
<script lang="ts">
import ToastViewport from '@ui/toast-viewport.svelte';
import { createToastStore } from '@lib/toast-store';
import { query } from '@lib/universal-props-query.svelte';
</script>
// app state — created once, never a module singleton (SSR stays clean)
const toast = createToastStore();
// the md key is the registered VIEWPORT scale (48rem): below it the
// small rung stamps, at 48rem+ large wins — data-density flips AND
// the kernel lanes rescale the card's type
const responsiveDensity = query<{ md: DensityLane }, DensityLane>({ md: 'large' }, 'small');
<ToastViewport store={toast} density={responsiveDensity} pos="left-top" />toast · universal props
The left-bottom stack carries theme="dark" density="large" elevation=6dp: its own .dark island rides the portal — the card's ground re-derives in the island (the level3 surface rung), the tonal hue re-derives (the selector-list re-declaration), while the frozen popover aliases hold the root's values (the measured split).
The left-top stack rides the query seat: below 48rem the small rung stamps (data-density="sm"), at 48rem+ large wins ("lg") — and the card's kernel-lane type rescales with it (the description is one var(--jx-text) reader).
<ToastViewport store={store} theme="dark" density="large" elevation={6} pos="left-bottom" />a11y
Accessibility
Every toast is its own live region — polite by default, assertive by opt-in — never one region announcing everything. Toasts beyond maxVisible stay queued and are NOT in the accessibility tree until they render; their expiry arms at first visibility, so a queued toast never expires unseen.
Keyboard
| Key | Action |
|---|---|
| Tab | Reach the dismiss button; hover or focus on a toast pauses its countdown |
ARIA
| Attribute | Value | Description |
|---|---|---|
| role | status | alert | Per-item live region; assertive toasts announce as role=alert. |
| role (stack) | group | The stack container — a named group (aria-label without a role is ignored on a generic div). |
| aria-label (stack) | notifications | Names the stack group (adopted float plane or the fixed-corner fallback). |
| aria-label (dismiss) | dismiss notification | Names each toast’s × button. |
| role (expanded) | dialog | The expandable reading posture (toast-v2) — popover=auto, labeled by the toast title; light dismiss collapses back to the card (the toast survives). |
| aria-hidden | true | The +N queued chip and the countdown gauge are decoration — a queued toast announces when it renders, not before. |