Tour
A spotlight product tour: a floating panel walks users through your UI while a cut-out
highlights the current target. Supports per-step placement, async guards, targets that are not
rendered yet, disabled steps, and centered dialog steps. Focus is trapped in the panel, changes
are announced via aria-live,
and animations respect prefers-reduced-motion.
For multi-page tours, pair it with the useTour hook.
Basic Usage
Describe the tour as a steps array,
bind api, and call start().
String targets are CSS selectors resolved when the step activates.
<script lang="ts">
import { Tour, Button } from 'sv5ui';
import type { TourController, TourStep } from 'sv5ui';
let api = $state<TourController>();
const steps: TourStep[] = [
{
target: '#create-btn',
title: 'Create a project',
description: 'Everything starts here.'
},
{
target: '#search-input',
title: 'Find anything',
description: 'Search across all your projects.',
placement: 'bottom'
},
{
target: '#profile-menu',
title: 'Your profile',
description: 'Settings and sign out live here.',
placement: 'left'
}
];
</script>
<Button label="Start tour" onclick={() => api?.start()} />
<Tour {steps} bind:api />Placement
Set a component-level default and override per step with any Floating UI Placement ('top', 'right-end', ...). sideOffset controls the gap.
<!-- Component-level default + per-step override -->
<Tour
{steps}
placement="top" <!-- default for every step -->
sideOffset={16}
bind:api
/>
const steps: TourStep[] = [
{ target: '#a', title: 'Right side', placement: 'right' },
{ target: '#b', title: 'Uses the default (top)' },
{ target: '#c', title: 'Below, aligned start', placement: 'bottom-start' }
];Centered Dialog Steps
A step without a target renders as a
centered dialog with no spotlight anchor: ideal for intro and outro steps. The first step of
the demo above works this way.
<!-- Omit target (or pass null) for a centered dialog step
with no spotlight: ideal for intro and outro steps. -->
const steps: TourStep[] = [
{
title: 'Welcome!',
description: 'Let us show you around. It takes 30 seconds.',
nextLabel: 'Show me'
},
{ target: '#create-btn', title: 'Create', description: '...' },
{
title: 'You are all set',
description: 'Enjoy the app!',
nextLabel: 'Finish'
}
];Async Guards
onBeforeNext and onBeforePrev run before the move and can
return a promise. Return false to keep the
user on the current step, for example until a form field is filled in.
<!-- Guards run before moving. Return false (or a promise
resolving to false) to block the transition. -->
const steps: TourStep[] = [
{
target: '#name-input',
title: 'Name your project',
description: 'Fill this in before continuing.',
onBeforeNext: () => {
if (!name.trim()) {
shake('#name-input');
return false;
}
return true;
}
},
{
target: '#save-btn',
title: 'Save it',
// Async guards work too: the panel waits for the promise
onBeforeNext: async () => await saveDraft()
}
];Targets That Do Not Exist Yet
After a route change or inside lazy content, a step's target may not be in the DOM when the
step activates. waitForTarget polls until
it appears (default timeout 2000ms), and onEnter can trigger whatever the step needs (open a panel, switch a tab) before positioning.
the target renders here once the step activates...
If the target never appears within the timeout, the step falls back to a centered dialog.
<!-- If a step's target is not in the DOM yet (route change,
lazy content), the tour polls until it appears. -->
<Tour {steps} waitForTarget={3000} bind:api />
const steps: TourStep[] = [
// Inherits 3000ms from the component
{ target: '#lazy-chart', title: 'Your stats' },
// Per-step override: wait up to 5s for this one
{ target: '#slow-widget', title: 'Heavy widget', waitForTarget: 5000 },
// onEnter can trigger the content the step needs
{
target: '#settings-panel',
title: 'Settings',
onEnter: () => (settingsOpen = true)
}
];Disabled Steps
disabled: true removes a step from the
flow: navigation jumps over it in both directions and the progress dots exclude it. Combine
with $derived state for conditional tours.
<!-- Disabled steps are skipped by next() / prev(). Combine
with your own state to build conditional tours. -->
const steps: TourStep[] = $derived([
{ target: '#inbox', title: 'Inbox' },
{
target: '#admin-panel',
title: 'Admin tools',
disabled: !user.isAdmin
},
{ target: '#help', title: 'Help center' }
]);Spotlight Options
Tune the cut-out per step with spotlightPadding and spotlightRadius, or set spotlightInteractable so the highlighted
element stays clickable. Try it below: the like button works while its tour step is open.
<!-- Spotlight options: padding / radius per step, or let
clicks through to the target for "try it" steps. -->
const steps: TourStep[] = [
{
target: '#fab',
title: 'Round button, round spotlight',
spotlightPadding: 12,
spotlightRadius: 999
},
{
target: '#like-btn',
title: 'Try clicking it!',
spotlightInteractable: true,
showPrev: false
},
{
title: 'No spotlight at all',
disableSpotlight: true
}
];Custom Panel Snippets
header, footer, and content replace the default panel regions.
Each receives the step context: the step data, position, and shorthand next / prev / stop functions.
Per-step custom bodies are also possible with the step-level content snippet.
<!-- header / footer / content replace the default panel
regions. Each receives the current step context. -->
<Tour {steps} bind:api>
{#snippet header({ number, total, step })}
<div class="flex items-center justify-between">
<span class="text-xs font-semibold uppercase">{number} / {total}</span>
<strong>{step.title}</strong>
</div>
{/snippet}
{#snippet footer({ isLast, next, prev, stop, isFirst })}
<div class="flex justify-between gap-2">
<Button label="Quit" variant="ghost" size="xs" onclick={stop} />
<div class="flex gap-2">
{#if !isFirst}<Button label="Back" variant="outline" size="xs" onclick={prev} />{/if}
<Button label={isLast ? 'Done' : 'Next'} size="xs" onclick={next} />
</div>
</div>
{/snippet}
</Tour>Labels & Behavior
Rename any button, hide the progress dots or skip button, pick a panel size, and opt into dismissible to allow closing via
backdrop click or Escape.
Closing early fires onSkip with the
step index; finishing fires onComplete.
<Tour
{steps}
prevLabel="Back"
nextLabel="Continue"
doneLabel="Finish"
skipLabel="Not now"
showProgress={false}
size="lg"
dismissible
bind:api
/>TourStep
Shape of each entry in the `steps` array.
| Property | Type | Default |
|---|---|---|
id | string | index |
target | string | HTMLElement | (() => HTMLElement | null) | null | null |
title / description | string | - |
content | Snippet<[TourStepSlotProps]> | - |
placement | Placement | inherits |
sideOffset / spotlightPadding / spotlightRadius | number | inherits |
spotlightInteractable | boolean | inherits |
disableSpotlight | boolean | false |
scrollIntoView | boolean | ScrollIntoViewOptions | inherits |
waitForTarget | boolean | number | inherits |
nextLabel / prevLabel / showPrev / showSkip | string | boolean | - |
onBeforeNext / onBeforePrev | () => boolean | Promise<boolean> | - |
onEnter | () => void | Promise<void> | - |
disabled | boolean | false |
route | string | - |
class / ui | ClassNameValue / Record<Slot, Class> | - |
UI Slots
Use the `ui` prop to override classes on internal elements.
| Slot | Description |
|---|---|
overlay | Dismiss backdrop behind the panel |
spotlight | The cut-out highlight around the target |
panel | The floating step panel |
arrow | Arrow pointing from panel to target |
header / title / description / body | Panel content regions |
footer / nav | Bottom row with progress and buttons |
progress / progressDot | Progress dots |
prevButton / nextButton / skipButton / closeButton | The default buttons |
Props
| Prop | Type | Default |
|---|---|---|
steps | TourStep[] | - |
controller | TourController | - |
api | TourController | - |
defaultStep | number | 0 |
placement | Placement | 'bottom' |
sideOffset | number | 12 |
spotlight | boolean | true |
spotlightPadding / spotlightRadius | number | 8 / 8 |
spotlightInteractable | boolean | false |
scrollIntoView | boolean | ScrollIntoViewOptions | true |
waitForTarget | boolean | number | 2000 |
dismissible | boolean | false |
arrow | boolean | true |
prevLabel / nextLabel / doneLabel / skipLabel | string | 'Back' / 'Next' / 'Done' / 'Skip' |
showProgress / showSkip | boolean | true |
size | 'sm' | 'md' | 'lg' | 'md' |
transition | boolean | true |
portal | boolean | true |
onOpenChange / onStart / onComplete / onSkip / onStepChange | function | - |
class / ui | ClassNameValue / Record<Slot, Class> | - |