Overlay

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.

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

Target
<!-- 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.

Dashboard Admin settings Profile
<!-- 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.

Target

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.

PropertyTypeDefault
idstringindex
targetstring | HTMLElement | (() => HTMLElement | null) | nullnull
title / descriptionstring-
contentSnippet<[TourStepSlotProps]>-
placementPlacementinherits
sideOffset / spotlightPadding / spotlightRadiusnumberinherits
spotlightInteractablebooleaninherits
disableSpotlightbooleanfalse
scrollIntoViewboolean | ScrollIntoViewOptionsinherits
waitForTargetboolean | numberinherits
nextLabel / prevLabel / showPrev / showSkipstring | boolean-
onBeforeNext / onBeforePrev() => boolean | Promise<boolean>-
onEnter() => void | Promise<void>-
disabledbooleanfalse
routestring-
class / uiClassNameValue / Record<Slot, Class>-

UI Slots

Use the `ui` prop to override classes on internal elements.

SlotDescription
overlayDismiss backdrop behind the panel
spotlightThe cut-out highlight around the target
panelThe floating step panel
arrowArrow pointing from panel to target
header / title / description / bodyPanel content regions
footer / navBottom row with progress and buttons
progress / progressDotProgress dots
prevButton / nextButton / skipButton / closeButtonThe default buttons

Props

PropTypeDefault
stepsTourStep[]-
controllerTourController-
apiTourController-
defaultStepnumber0
placementPlacement'bottom'
sideOffsetnumber12
spotlightbooleantrue
spotlightPadding / spotlightRadiusnumber8 / 8
spotlightInteractablebooleanfalse
scrollIntoViewboolean | ScrollIntoViewOptionstrue
waitForTargetboolean | number2000
dismissiblebooleanfalse
arrowbooleantrue
prevLabel / nextLabel / doneLabel / skipLabelstring'Back' / 'Next' / 'Done' / 'Skip'
showProgress / showSkipbooleantrue
size'sm' | 'md' | 'lg''md'
transitionbooleantrue
portalbooleantrue
onOpenChange / onStart / onComplete / onSkip / onStepChangefunction-
class / uiClassNameValue / Record<Slot, Class>-