jixoai-ui
the terminal design language
loading the style sheet…
Skip to content
docs · print medium: screen

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.

install — tour

$npx jixoai-ui add tour

https://ui.jixoai.com/r/tour.json

anchor-name lease box-shadow hole non-modal card(api) snippet placement + indicator recipes

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

tour-usage.svelte svelte
<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.

Tour usage
<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

tour-non-modal-usage.svelte svelte
<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

tour-placement-usage.svelte svelte
<!-- 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.

tour card placements — anchor() expressions
placementblock expressioninline expression
topbottom: anchor(top)left: anchor(center)
top-startbottom: anchor(top)left: anchor(left)
top-endbottom: anchor(top)right: anchor(right)
bottomtop: anchor(bottom)left: anchor(center)
bottom-starttop: anchor(bottom)left: anchor(left)
bottom-endtop: anchor(bottom)right: anchor(right)
leftright: anchor(left)top: anchor(center)
left-startright: anchor(left)top: anchor(top)
left-endright: anchor(left)top: anchor(bottom)
rightleft: anchor(right)top: anchor(center)
right-startleft: anchor(right)top: anchor(top)
right-endleft: 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

tour-indicators-usage.svelte svelte
<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.

targets live in the workbench above — scroll up if they left the viewport
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

tour-types-demo.svelte svelte
<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.

xs
sm
default
lg
TokenDefaultSource
--jx-tour-{id}anchor-name leasecomponent
--jx-tour-gap12pxcomponent
tintvar(--scrim)color
--jx-p0 → 1 timelinecomponent
hole border1px solid var(--primary)structural
--jx-radius-consumed§3/§14 compositionstructural
elevation ownlevel2structural
--jx-hit28 / 32 / 40 / 48pxdensity

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

PropertyTypeDefaultDescription
steps * TourStep[]—Targets + title/description metadata (behavior-domain data).
open bindbooleanfalseBindable open state — the tour runs while true.
startAt number0Zero-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 ambientFloating-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

PropertyTypeDefaultDescription
size'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopethe base scale — root font-size; parts size in em
shape'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto''auto' · ambient scopecorner geometry (CSS corner-shape; §14 degrade table)
radius'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopecorner size; auto = the concentric broadcast (§3)
density'small' | 'medium' | 'large' | 'auto' | number'auto' · ambient scopespacing/leading scale over the kernel channels (§4)
color'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string'auto' · ambient scopethe hue axis of the fixed oklch primary system (§5)
theme'light' | 'dark' | 'system' | 'auto''auto' · ambient scopelight/dark profile; system = the JS-mutable global (§6)
elevation'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number'auto' · ambient scopeofficial M3 levels over the surface ladder (§7)
motion'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number'auto' · ambient scopeintensity across the motion kernels (§8)

TourStep

PropertyTypeDefaultDescription
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.

PropertyTypeDefaultDescription
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 ambientOWN 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-universal.svelte svelte
<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

KeyAction
→Advance to the next enterable step
←Go back one step
EnterNext (the focused button’s default path)
EscapeEnd the tour — focus returns to the opener

ARIA

AttributeValueDescription
roledialogThe card panel; landing focus sits on Next (or the panel with a custom card).
aria-modalfalseNon-modal: no trap, no inert, the page scrolls.
popovermanualTop-layer card + tint; the scrim is pointer-events:none.
aria-labelstep.titleThe dialog is named by the current step.
aria-hiddentrue (recipe)Indicator dots stay decoration; the named dialog carries progress.
jixoai-ui ui.jixoai.com the jixoai design language

Theme

Brand hue

0°

Auto cycle · 24h