registry:ui · contract
tour — targets stay data, the card opens
The spotlight tour: steps carry targets (behavior domain, driver.js precedent); the card opens via the card(api) snippet ({index, total, step, next, prev, skip}) with the zero-config default card when absent. Spotlight, focus and step lifecycle unchanged.
$npx jixoai-ui add tour
https://ui.jixoai.com/r/tour.json
overview
Overview
The guided walkthrough: targets stay behavior-domain data, the spotlight is pure CSS anchor geometry over a reversible anchor-name lease, and the card is a non-modal popover=manual dialog that lands its focus on Next and restores the invoker's on finish.
A tour step is one target plus metadata; the component owns the lifecycle. The LEASE: each step's target takes the panel's anchor-name (its prior inline value saved), and on advance, close, or unmount the exact prior state is restored — a reversible lease, capability wiring, never style authoring. The SPOTLIGHT: the hole is a target-sized transparent window cut by anchor-size() with one huge box-shadow tint — zero geometry JS; engines without anchor positioning degrade to a full-viewport tint with no hole, and the panel falls back to a centered fixed card, never silently misplaced.
The card is NON-MODAL by contract: popover="manual" + role="dialog" + aria-modal="false" — no focus trap, no inert, the page (and any inner scrollbox) stays live, and the tint is pointer-events:none. FOCUS: open lands focus on the Next button and re-lands it on EVERY step change (the named dialog announces the new step's title as focus arrives — the per-step announcement is the focus move, the "1 / n" counter stays aria-hidden decoration); Escape or Skip finishes and the invoker's focus is restored. Targets re-resolve every step: invalid selectors are caught, missing or hidden targets are UNAVAILABLE and skipped deterministically in the travel direction, and a target merely out of view is scrolled to with scrollIntoView(nearest) — never a scroll clamp.
Delivery shape: a SELF-CARRIED PROMOTION — the card rides popover="manual" into the
top layer, so the axis carriers stamp the CARD root itself; the hole is the panel's
SIBLING and keeps the ROOT scope, so a theme island on the card never reaches the
spotlight (measured). The keys: the tour renders ONE card — there is no step-list DOM
(the LAW #18 surface is empty by construction); the recipe dots each keys the index.
The eight axes: elevation is OWN level2 and consumed as the jx-surface fill chain's
head, radius composes the §3/§14 popover-dialect stamp on the card root, density rung
+ theme island stamp self-carried, and size/shape/color/motion carry the supply
chain. Kinship: popover (the anchored-panel
law), system-dialog (the modal interruption
the tour refuses to be), steps (the static
progress analog).
tour
The default card: start it — the first demo card takes the lease (inspect its style), the hole+tint frame it. Next advances (←/→ also work), the last step's button reads Finish, Escape or Skip ends with focus back on the opener. No card snippet — the default card renders the steps' title/description metadata.
demo target A
this card receives the anchor-name lease on step 1
demo target B
…and this one on step 2; the lease moves with the tour
<script lang="ts">
import Tour from '@ui/tour.svelte';
</script>
<Tour
bind:open
steps={[
{ target: '#deploy-card', title: 'Connect', description: 'the lease lands here' },
{ target: '#checks-card', title: 'Verify', description: '…and moves here' },
]}
>
{#snippet card(api)}
<p class="font-nav text-xs uppercase">{api.step.title}</p>
<div class="flex gap-2">
<button type="button" onclick={api.prev} disabled={api.index === 0}>back</button>
<button type="button" onclick={api.next}>
{api.index === api.total - 1 ? 'done' : 'next'}
</button>
<button type="button" onclick={api.skip}>skip</button>
</div>
{/snippet}
</Tour>usage
Usage
Steps stay data (targets are behavior-domain, driver.js precedent); the card snippet receives TourApi — index, total, step, next/prev/skip.
<script lang="ts">
import Tour from '@ui/tour.svelte';
</script>
<Tour
bind:open
steps={[
{ target: '#deploy-card', title: 'Connect', description: 'the lease lands here' },
{ target: '#checks-card', title: 'Verify', description: '…and moves here' },
]}
>
{#snippet card(api)}
<p class="font-nav text-xs uppercase">{api.step.title}</p>
<div class="flex gap-2">
<button type="button" onclick={api.prev} disabled={api.index === 0}>back</button>
<button type="button" onclick={api.next}>
{api.index === api.total - 1 ? 'done' : 'next'}
</button>
<button type="button" onclick={api.skip}>skip</button>
</div>
{/snippet}
</Tour>examples
Examples
Ability-named recipes: the non-modal scroll proof, the placement matrix, and custom step indicators.
Non-modal is the tour's contract, placement and indicators are compositions over the public card/anchor surface — the missing placement prop is recorded in the change's followups.md.
with non-modal scroll
Start the tour inside a tall, scrollable log — then scroll BOTH the box and the page while it runs: nothing locks. The tint frames the leased target with pointer-events:none, aria-modal stays false, and a target that scrolls away is brought back with scrollIntoView(nearest) on step change — never a scroll clamp.
log head
step 1 leases this block — scroll the box while it runs
log tail
step 2 — the page and this scrollbox never locked
<script lang="ts">
let open = $state(false);
</script>
<!-- non-modal is the CONTRACT, not an option: no focus trap, no inert,
no overflow clamp — the page (and any inner scrollbox) stays live,
the tint is pointer-events:none. -->
<PressButton onclick={() => (open = true)}>start the tour</PressButton>
<div class="max-h-48 overflow-y-auto">
<section id="deploy-card">…</section>
<div style="height: 24rem"></div>
<section id="checks-card">…</section>
</div>
<Tour
bind:open
steps={[
{ target: '#deploy-card', title: 'Deploy', description: 'scroll away — it stays free' },
{ target: '#checks-card', title: 'Checks', description: 'the tint never intercepts pointers' },
]}
/>with placement control
Four quadrant targets, four tours — the card places above, beside (start/end) or below its leased target. The geometry is a page-level override of the card's anchor() expressions (the wrapper class scopes each tour); bottom is the shipped default.
target · top quadrant
target · start quadrant
target · end quadrant
target · bottom quadrant
<!-- The card places below its target by default
(tour.css: top: anchor(bottom)). Placement control composes as a
page-level override of the card's anchor geometry — a placement
PROP is the recorded followup (change followups.md). -->
<div class="tour-place-top">
<Tour bind:open steps={[{ target: '#card', title: 'Above' }]} />
</div>
<style>
.tour-place-top :global(.jx-tour) {
top: auto;
bottom: anchor(top);
margin-bottom: var(--jx-tour-gap, 12px);
}
.tour-place-left :global(.jx-tour) {
top: anchor(top);
left: auto;
right: anchor(left);
margin-right: var(--jx-tour-gap, 12px);
}
.tour-place-right :global(.jx-tour) {
top: anchor(top);
left: anchor(right);
margin-left: var(--jx-tour-gap, 12px);
}
/* bottom is the shipped default: top: anchor(bottom) */
</style>reference
The 12 placements, as anchor expressions
Every antd-style placement maps to one block edge plus one inline alignment over the leased target's anchor() box. The four quadrant demos above exercise one per block edge; the rest are the same law with the inline term swapped.
| placement | block expression | inline expression |
|---|---|---|
| top | bottom: anchor(top) | left: anchor(center) |
| top-start | bottom: anchor(top) | left: anchor(left) |
| top-end | bottom: anchor(top) | right: anchor(right) |
| bottom | top: anchor(bottom) | left: anchor(center) |
| bottom-start | top: anchor(bottom) | left: anchor(left) |
| bottom-end | top: anchor(bottom) | right: anchor(right) |
| left | right: anchor(left) | top: anchor(center) |
| left-start | right: anchor(left) | top: anchor(top) |
| left-end | right: anchor(left) | top: anchor(bottom) |
| right | left: anchor(right) | top: anchor(center) |
| right-start | left: anchor(right) | top: anchor(top) |
| right-end | left: anchor(right) | top: anchor(bottom) |
with custom indicators
The card(api) snippet renders step dots from index/total — the live dot follows every next/prev. Dots are aria-hidden decoration; the group label and the step title carry the progress to assistive tech.
indicator target A
indicator target B
<script lang="ts">
let open = $state(false);
</script>
<Tour bind:open steps={[…]>
{#snippet card(api)}
<p>{api.step.title}</p>
<!-- the indicators: one dot per step, the live index painted -->
<div role="group" aria-label="tour progress" class="flex gap-1">
{#each Array.from({ length: api.total }, (_, i) => i) as i (i)}
<span aria-hidden="true" class:list={['dot', i === api.index && 'dot-on']}></span>
{/each}
</div>
<div class="flex gap-2">
<button type="button" onclick={api.prev} disabled={api.index === 0}>back</button>
<button type="button" onclick={api.next}>
{api.index === api.total - 1 ? 'done' : 'next'}
</button>
</div>
{/snippet}
</Tour>composition
The card(api) snippet
steps stay data — targets are behavior domain (driver.js precedent) and title/description are metadata for the DEFAULT card. When you need the interior your way, the card snippet receives TourApi: index, total, the current step object, and next/prev/skip wired to the same lifecycle (spotlight, lease, Escape, deterministic skips). The label props died — callers author the buttons.
TourApi = { index, total, step: TourStep, next(), prev(), skip() }types
Tour variants
Card types over the same lifecycle: the default card renders the steps' title/description metadata; the card(api) snippet authors the whole interior (the indicators recipe lives here).
tour · variants
<script lang="ts">
import PressButton from '@ui/press-button.svelte';
import Tour from '@ui/tour.svelte';
</script>
let open = $state(false);
let cardOpen = $state(false);
<div class="grid w-full gap-4 sm:grid-cols-2">
<div class="border border-border p-4"><PressButton onclick={() => (open = true)}>default card tour</PressButton></div>
<div class="border border-border p-4"><PressButton onclick={() => (cardOpen = true)}>card(api) tour</PressButton></div>
</div>
<!-- the default card: steps metadata renders as title/description -->
<Tour
bind:open
steps={[
{ target: '[data-tour-demo-a]', title: 'Target A', description: 'the lease lands here' },
{ target: '[data-tour-demo-b]', title: 'Target B' },
]}
/>
<!-- the card(api) snippet authors the whole interior -->
<Tour bind:open={cardOpen} steps={[
{ target: '[data-tour-demo-a]', title: 'Target A' },
{ target: '[data-tour-demo-b]', title: 'Target B' },
]}>
{#snippet card(api)}
<p>{api.step.title} · {api.index + 1}/{api.total}</p>
<div class="flex gap-2">
<button type="button" onclick={api.prev} disabled={api.index === 0}>back</button>
<button type="button" onclick={api.next}>{api.index === api.total - 1 ? 'done' : 'next'}</button>
<button type="button" onclick={api.skip}>skip</button>
</div>
{/snippet}
</Tour>theming
Density and tokens
The hole is sized by anchor-size() with zero geometry JS; the panel rides the shared surface-motion kernel.
| Token | Default | Source |
|---|---|---|
| --jx-tour-{id} | anchor-name lease | component |
| --jx-tour-gap | 12px | component |
| tint | var(--scrim) | color |
| --jx-p | 0 → 1 timeline | component |
| hole border | 1px solid var(--primary) | structural |
| --jx-radius-consumed | §3/§14 composition | structural |
| elevation own | level2 | structural |
| --jx-hit | 28 / 32 / 40 / 48px | density |
api
API
TourApi = { index, total, step: TourStep, next(), prev(), skip() } — the card snippet's whole surface. No placement prop (composed as page CSS — the recorded followup); no modal mode (non-modal is the contract).
Properties
| Property | Type | Default | Description |
|---|---|---|---|
| steps * | TourStep[] | — | Targets + title/description metadata (behavior-domain data). |
open bind | boolean | false | Bindable open state — the tour runs while true. |
| startAt | number | 0 | Zero-based first step; skipped-forward past unavailable ones. |
| onfinish | (index: number) => void | — | Fires when the tour finishes (end reached, skipped, or all steps unavailable). |
| onstep | (index: number) => void | — | Step change notification (analytics/progress). |
| card | Snippet<[TourApi]> | — | Replaces the default card interior; receives TourApi — the indicators recipe composes here. |
| variant | 'solid' | 'acrylic' | 'auto' | 'auto' · Own default, not ambient | Floating-surface variant for the card. Defaults: literal slot — own 'auto', ambient when an axis opens (the dialog/sheet grammar). |
| class | string | '' | Extra classes on the card panel. |
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) |
TourStep
| Property | Type | Default | Description |
|---|---|---|---|
| target * | string | () => HTMLElement | null | — | CSS selector for the step’s target, or a resolver; invalid selectors read as unavailable. |
| title | string | — | Metadata for the default card (a custom card renders or ignores it). |
| description | string | — | Metadata for the default card. |
axes
Universal props
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 card rides popover='manual' (a top-layer promotion), so the carriers stamp the CARD root itself — self-carried across the promotion (the batch C portal law); elevation carries the family own level2 (the anchored card's menu rung). Open a demo card to see the lanes live.
| Property | Type | Default | Description |
|---|---|---|---|
| density | '2xs' | 'xs' | 'sm' | 'default' | 'lg' | 'auto' | number (+ the five legacy spellings) | 'auto' | RUNG-STAMPED, CARD-PAINT-UNREAD — data-density lands on the CARD root (self-carried through the promotion, measured seat) and the composed controls' rhythm follows it, but the card's own voices ride STATIC steps (--jx-text-base 13px / --text-label — zero coefficient readers in the card paint, grep receipt). Number unit: coefficient. |
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' | SUPPLY-ONLY for the family's paint — zero --jx-size-effective readers over ui/tour/ (grep receipt); the carrier's font-size lands on the card root so composed card-snippet content scales, while the default card's title/description stay pinned to their token steps. Number unit: px. |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' | SUPPLY-ONLY — zero --jx-shape-effective readers over ui/tour/ (grep receipt). Number unit: none. |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' | THE §3/§14 POPOVER-DIALECT STAMP — the card root composes --jx-radius-consumed per render (explicit: radius-effective × the §14 factor; auto: the concentric max(0px, R − P) calc — tour.svelte:226-230). Measured on the served DOM (probe, task 45); the visible consumption story lives with the card's surface paint. Number unit: px. |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' | SUPPLY-ONLY — zero --jx-color-effective readers over ui/tour/ (grep receipt); the card's ink is the popover token set + the Next chip's brand lean (--jx-primary). Omitted from rest (the §1 interception — Props extends Omit<HTMLAttributes, 'color'>). Number unit: hue degrees. |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' | THE SPLIT ISLAND (measured, probe): the carriers stamp the CARD root — class:dark makes the card its own .dark scope THROUGH the popover promotion, the ground re-derives there (the level table's dark side via the jx-surface fill chain), while the :root-pinned ink aliases (--jx-foreground/--jx-muted-foreground) hold the root's values, and the HOLE — the panel's SIBLING — keeps the ROOT scope entirely, so the spotlight always follows the page theme, never the card's island. No number lane. |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'level2' · Own default, not ambient | OWN level2, CONSUMED — the anchored card's menu rung (3dp, the popover/dropdown-menu law): elevationSurfaceOf stamps --jx-elevation-shadow/--jx-elevation-surface/--jx-surface-solid-fill on the card root and the SHARED surface law reads them as the fill chain's HEAD (jixoai.css .jx-surface-body) + the WAAPI shadow child. Number lane: exact dp snapped DOWN to the table rung. |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' | THE FAMILY OWNS THE MOTION, AXIS UNREAD — open/close ride the shared WAAPI surface-motion kernel (--jx-p 0→1 at showPopover/hidePopover, the real shadow child in lockstep; step moves are pure CSS anchor repositioning, NO JS animation); reduced-motion neutralizes the kernel in one place (the jx-waapi law). Zero --jx-motion-effective readers (grep receipt). Number unit: coefficient. |
Receipts: the SELF-CARRIED PROMOTION (popover="manual" puts the card in the top layer, so the carriers stamp the CARD root — data-density, class:dark, the elevation pair, the radius composition; a trigger ancestor's CSS carriers never span the promotion), the focus law (open lands focus on Next and re-lands it on every step; Escape/Skip/ Finish all restore the invoker's focus — measured on real interactions, probe task 45), the LEASE receipts (the target's style.anchor-name carries the panel's per-instance lease while its step runs and the prior inline value — or none — is restored verbatim on advance and close), the placement receipts (the card's top/left track the leased target's anchor() box — bottom default = target bottom + the 12px gap; the four quadrant recipes ride page-level overrides of the SAME expressions), the scroll-into-view receipt (an out-of-view target is brought to view with scrollIntoView(nearest) on lease), the announcement discipline (the named dialog re-announces the step title through the focus move; the "1 / n" counter and the recipe dots are aria-hidden), the theme split (the card's island vs the hole's root scope — measured) and LAW #19 (duplicate ids NONE page-wide) were measured on this page's served DOM (probe, task 45). KEYED-EACH: the tour renders ONE card — there is no step-list DOM; the indicator dots each keys the index (unique by construction). LAW #19 id landscape: duplicate ids NONE page-wide.
Tour · universal props
step one's target
<Tour bind:open size={18} density="small" theme="dark" elevation="level4" steps={[…]} />a11y
Accessibility
Non-modal by contract — no focus trap, the page stays scrollable, and finishing restores the invoker's focus.
Keyboard
| Key | Action |
|---|---|
| → | Advance to the next enterable step |
| ← | Go back one step |
| Enter | Next (the focused button’s default path) |
| Escape | End the tour — focus returns to the opener |
ARIA
| Attribute | Value | Description |
|---|---|---|
| role | dialog | The card panel; landing focus sits on Next (or the panel with a custom card). |
| aria-modal | false | Non-modal: no trap, no inert, the page scrolls. |
| popover | manual | Top-layer card + tint; the scrim is pointer-events:none. |
| aria-label | step.title | The dialog is named by the current step. |
| aria-hidden | true (recipe) | Indicator dots stay decoration; the named dialog carries progress. |