Skip to content
omaris

Communication

Tour

A guided tour: a spotlight on one thing at a time, with a card explaining it.

import { Tour } from 'omaris'
Learn

Examples

Basic

A step names its target with a CSS selector, so a tour is data — it can come from a config file or the server. The scrim is one element with a hole cut in it, so the spotlight travels between steps rather than blinking.

Omer Chetin
The page stays clickable inside the spotlight.
<script lang="ts">	import { Avatar, Button, Input, Text, Tour, type TourStep } from 'omaris';​	let open = $state(false);​	const STEPS: TourStep[] = [		{			target: '#tour-new',			title: 'Start here',			content: 'Every order begins with this button — the only filled thing on the page.',			side: 'bottom',			align: 'start'		},		{			target: '#tour-search',			title: 'Find anything',			content: 'Search matches an order number, a phone number or a customer name.',			side: 'bottom'		},		{			target: '#tour-avatar',			title: 'Your account',			content: 'Settings, theme and sign-out live behind your avatar.',			side: 'left',			round: true,			padding: 6		},		{			title: 'That is the whole tour',			content: 'A step with no target is centred — which is how a tour opens and closes.'		}	];</script>​<div class="flex w-full flex-col gap-4">	<div class="flex items-center gap-3 rounded-shape-lg border border-border p-3">		<Button id="tour-new" size="sm">New order</Button>		<Input id="tour-search" placeholder="Search orders…" class="max-w-56 flex-1" />		<Avatar id="tour-avatar" name="Omer Chetin" class="ms-auto" />	</div>​	<div class="flex items-center gap-3">		<Button variant="tonal" size="sm" onclick={() => (open = true)}>Take the tour</Button>		<Text variant="label-sm" tone="muted">The page stays clickable inside the spotlight.</Text>	</div></div>​<Tour bind:open steps={STEPS} />

Steps that change the page

A step's before runs before its target is looked for — switch a tab, open a menu, await a fetch — so a tour can walk across parts of the UI that are not on screen yet. A step whose target never appears is skipped: the third step here points at #tour-missing, which does not exist, so Next from the second step lands on the fourth and Back walks past it the other way.

14 orders today.

step 1
<script lang="ts">	import { Button, Tab, TabList, Tabs, Text, Tour, type TourStep } from 'omaris';​	let open = $state(false);	let index = $state(0);	let tab = $state('orders');​	const STEPS: TourStep[] = [		{			target: '#tour-tabs',			title: 'Two views',			content: 'Orders and menu live behind these tabs.',			side: 'bottom'		},		{			target: '#tour-menu-panel',			title: 'The menu',			content: 'The tour switched the tab for you before pointing here.',			side: 'top',			before: () => {				tab = 'menu';			}		},		{			target: '#tour-missing',			title: 'Never rendered',			content: 'You will not see this — the target is not on the page, so the step is skipped.'		},		{			title: 'Still here',			content:				'A step with no target at all is not skipped — it is centred. The one before this was.'		}	];</script>​<div class="flex w-full flex-col gap-4">	<div id="tour-tabs" class="rounded-shape-lg border border-border p-3">		<Tabs bind:value={tab}>			<TabList label="Sections">				<Tab value="orders" label="Orders" />				<Tab value="menu" label="Menu" />			</TabList>		</Tabs>​		{#if tab === 'orders'}			<Text variant="body-sm" tone="muted" class="mt-3 block">14 orders today.</Text>		{:else}			<Text id="tour-menu-panel" variant="body-sm" tone="muted" class="mt-3 block">				38 items across 6 categories.			</Text>		{/if}	</div>​	<div class="flex items-center gap-3">		<Button			variant="tonal"			size="sm"			onclick={() => {				tab = 'orders';				index = 0;				open = true;			}}		>			Start		</Button>		<Text variant="label-sm" tone="muted">step {index + 1}</Text>	</div></div>​<Tour bind:open bind:index steps={STEPS} />

Across pages

A flow that lives on three pages. Each step names the path it belongs to and the tour navigates there itself — navigate: goto in a SvelteKit app, a local variable in this demo. The state lives in createTour rather than in the component, so a navigation that unmounts the page does not end the tour; mount <Tour /> in +layout.svelte and it survives every step.

now on /orders

14 open today.

<script lang="ts">	import { Button, Card, Text, Tour, createTour, type TourStep } from 'omaris';​	/** Stands in for `$page.url.pathname`; `goto` writes it in a real app. */	let path = $state('/orders');​	const PAGES = [		{ path: '/orders', label: 'Orders' },		{ path: '/menu', label: 'Menu' },		{ path: '/settings', label: 'Settings' }	];​	const STEPS: TourStep[] = [		{			path: '/orders',			target: '#np-new',			title: 'Take the order',			content: 'Everything starts here. One order, one screen.',			side: 'bottom',			align: 'start'		},		{			path: '/menu',			target: '#np-item',			title: 'Then the menu',			content: 'The tour changed page for you and waited for this to render before pointing at it.',			side: 'bottom',			align: 'start'		},		{			path: '/settings',			target: '#np-hours',			title: 'And the hours',			content: 'Back navigates too — every step knows the page it belongs to.',			side: 'top',			align: 'start'		},		{			title: 'Three pages, one tour',			content: 'Add an `id` to the controller and it survives a real page load as well.'		}	];​	const tour = createTour(STEPS, {		navigate: (to) => {			path = to;		}	});</script>​<div class="flex w-full flex-col gap-4">	<div class="flex items-center gap-3">		<Button variant="tonal" size="sm" onclick={() => tour.start()}>Show me around</Button>		<Text variant="label-sm" tone="muted">now on <code>{path}</code></Text>	</div>​	<div class="overflow-hidden rounded-shape-lg border border-border">		<nav class="flex gap-1 border-b border-border bg-surface-container-low p-2">			{#each PAGES as page (page.path)}				<Button					variant={path === page.path ? 'tonal' : 'text'}					size="xs"					onclick={() => (path = page.path)}				>					{page.label}				</Button>			{/each}		</nav>​		<div class="p-4">			{#if path === '/orders'}				<div class="flex items-center gap-3">					<Button id="np-new" size="sm">New order</Button>					<Text variant="body-sm" tone="muted">14 open today.</Text>				</div>			{:else if path === '/menu'}				<Card id="np-item" class="w-fit px-4 py-3">					<Text variant="title-sm">Lamb quzi</Text>					<Text variant="body-sm" tone="muted">18,000 IQD · 6 in stock</Text>				</Card>			{:else}				<div id="np-hours" class="w-fit rounded-shape-md bg-surface-container px-4 py-3">					<Text variant="title-sm">Opening hours</Text>					<Text variant="body-sm" tone="muted">11 AM – 11 PM, every day but Friday.</Text>				</div>			{/if}		</div>	</div></div>​<Tour {tour} />

Nowhere to put the card

The case a phone hits constantly: a target with less room beside it than the card wants. The card is never slid over the spotlight to fit — it takes the taller of the two bands, caps itself to it and scrolls what is left over, so the thing being explained stays visible. Narrow this window, or open it on a phone, and the first step does the same thing to a small target.

Kitchen display

Tall enough that a card at its natural height fits neither above nor below it.

Four on shift. Hana is closing tonight.
<script lang="ts">	import { Button, Text, Tour, type TourStep } from 'omaris';​	let open = $state(false);​	const STEPS: TourStep[] = [		{			target: '#nc-panel',			title: 'A target with no room beside it',			content:				'This panel is most of the screen, so neither side can hold the card at its natural height. The card is capped to the band it has and scrolls inside itself instead — the alternative, sliding it back inside the viewport, would put it over the very thing this step is about. Keep reading: there is more here than fits, which is the point.',			side: 'bottom',			padding: 6		},		{			target: '#nc-footer',			title: 'And back to normal',			content: 'A target with room beside it gets a card at its full height, as usual.',			side: 'top'		}	];</script>​<div class="flex w-full flex-col gap-3">	<Button variant="tonal" size="sm" class="w-fit" onclick={() => (open = true)}>		Point at the big one	</Button>​	<div		id="nc-panel"		class="flex h-[58vh] flex-col justify-between rounded-shape-lg border border-border bg-surface-container-low p-4"	>		<Text variant="title-sm">Kitchen display</Text>		<Text variant="body-sm" tone="muted">			Tall enough that a card at its natural height fits neither above nor below it.		</Text>	</div>​	<Text id="nc-footer" variant="label-sm" tone="muted">Four on shift. Hana is closing tonight.</Text	></div>​<Tour bind:open steps={STEPS} />

Long page

Targets far apart in a scrolling panel. Each step scrolls its target into view first, then the hole and the card glide to it — the card's position and height are transitioned, so a longer explanation grows the card rather than snapping it. Scroll the panel yourself mid-tour and the spotlight keeps up without lagging.

Four sections, a screen apart.

Sales

1,240,000 IQD today, up 8% on last week.

Menu

2 out of stock

38 items across 6 categories. Two are out of stock.

Staff

Four on shift. Hana is closing tonight.

Reviews

4.7 across 212 reviews this month.

<script lang="ts">	import { Badge, Button, Card, Text, Tour, type TourStep } from 'omaris';​	let open = $state(false);​	const SECTIONS = [		{ id: 'sales', title: 'Sales', line: '1,240,000 IQD today, up 8% on last week.' },		{ id: 'menu', title: 'Menu', line: '38 items across 6 categories. Two are out of stock.' },		{ id: 'staff', title: 'Staff', line: 'Four on shift. Hana is closing tonight.' },		{ id: 'reviews', title: 'Reviews', line: '4.7 across 212 reviews this month.' }	];​	const STEPS: TourStep[] = [		{			target: '#tour-lp-sales',			title: 'Sales, first',			content: 'The top card is the one you check every morning.',			side: 'bottom',			align: 'start'		},		{			target: '#tour-lp-stock',			title: 'Out of stock',			content:				'The badge counts items that are hidden from the menu. Tap a section to restock; the tour waits, and the page underneath is still live inside the spotlight while it does.',			side: 'left'		},		{			target: '#tour-lp-reviews',			title: 'Reviews, last',			content: 'Far down the page — the tour scrolled here for you, and the card followed.',			side: 'top'		},		{			title: 'That is the whole page',			content: 'A centred step closes the hole in place rather than swapping it out.'		}	];</script>​<div class="flex w-full flex-col gap-3">	<div class="flex items-center gap-3">		<Button variant="tonal" size="sm" onclick={() => (open = true)}>Walk the page</Button>		<Text variant="label-sm" tone="muted">Four sections, a screen apart.</Text>	</div>​	<!-- svelte-ignore a11y_no_noninteractive_tabindex -->	<div		class="flex h-80 flex-col gap-48 overflow-y-auto rounded-shape-lg border border-border p-4"		tabindex="0"		role="group"		aria-label="Page"	>		{#each SECTIONS as section (section.id)}			<Card id="tour-lp-{section.id}" class="shrink-0 px-4 py-3">				<div class="flex items-center gap-2">					<Text variant="title-sm">{section.title}</Text>					{#if section.id === 'menu'}						<Badge id="tour-lp-stock" tone="warning" class="ms-auto">2 out of stock</Badge>					{/if}				</div>				<Text variant="body-sm" tone="muted">{section.line}</Text>			</Card>		{/each}	</div></div>​<Tour bind:open steps={STEPS} />

Remembering where you were

Give the controller an id and the step you are on is written to sessionStorage, so a tour that is interrupted by a real page load — a link, a form post, a hard refresh — comes back on the same step instead of starting over. Start this one, stop halfway, and reload the page. once does the opposite bookkeeping: a first-run tour that start() only ever opens once, until reset().

Revenue today

1,240,000 IQD

not started
<script lang="ts">	import { Button, Text, Tour, createTour, type TourStep } from 'omaris';​	const STEPS: TourStep[] = [		{			target: '#nr-total',			title: 'Today',			content: 'Reload the page here and the tour comes back on this step.',			side: 'bottom',			align: 'start'		},		{			target: '#nr-export',			title: 'Export',			content: 'Takes the rows you are looking at, not the whole table.',			side: 'bottom'		},		{			title: 'Finished',			content: 'Finishing or skipping clears what was remembered.'		}	];​	const tour = createTour(STEPS, { id: 'docs-tour-resume' });</script>​<div class="flex w-full flex-col gap-4">	<div class="flex flex-wrap items-center gap-3 rounded-shape-lg border border-border p-3">		<div id="nr-total">			<Text variant="label-sm" tone="muted">Revenue today</Text>			<Text variant="title-md" tabular>1,240,000 IQD</Text>		</div>		<Button id="nr-export" variant="outlined" size="sm" class="ms-auto">Export</Button>	</div>​	<div class="flex flex-wrap items-center gap-3">		<Button variant="tonal" size="sm" onclick={() => tour.start()}>Start</Button>		<Button variant="text" size="sm" onclick={() => tour.reset()}>Forget</Button>		<Text variant="label-sm" tone="muted">			{tour.open ? `on step ${tour.index + 1}` : tour.done ? 'seen' : 'not started'}		</Text>	</div></div>​<Tour {tour} />

Overridden

scrim="strong" darkens the page further, dots={false} drops the progress row, labels rewrites the buttons, and classes reaches the card and the spotlight itself — a wider card with a tinted ring around the hole.

Revenue today

1,240,000 IQD

<script lang="ts">	import { Button, Card, Text, Tour, type TourStep } from 'omaris';​	let open = $state(false);​	const STEPS: TourStep[] = [		{			target: '#tour-o-total',			title: 'Today so far',			content: 'Updated as orders come in — no refresh needed.',			side: 'right',			padding: 12		},		{			target: '#tour-o-export',			title: 'Take it with you',			content: 'Every table on the dashboard exports the rows you are looking at.',			side: 'top'		}	];</script>​<div class="flex w-full flex-col gap-4">	<div class="flex flex-wrap items-center gap-3">		<Card id="tour-o-total" class="px-4 py-3">			<Text variant="label-sm" tone="muted">Revenue today</Text>			<Text variant="headline-sm">1,240,000 IQD</Text>		</Card>		<Button id="tour-o-export" variant="outlined" size="sm">Export CSV</Button>	</div>​	<Button variant="tonal" size="sm" class="self-start" onclick={() => (open = true)}>		Show me around	</Button></div>​<Tour	bind:open	steps={STEPS}	scrim="strong"	dots={false}	width={380}	padding={10}	labels={{ next: 'Keep going', back: 'Back a step', done: 'Got it', skip: 'Not now' }}	classes={{		spotlight: 'rounded-shape-lg',		card: 'rounded-shape-xl shadow-4',		title: 'text-title-lg'	}}/>

When to use it

Use it for

  • A first run through a dashboard: three to six steps pointing at real things like the new-order button, the filter bar, the settings menu. createTour(steps, { id, once: true }) runs it once per browser and resumes after a reload.
  • A "what's new" walk started from a button or ?tour=1 in the URL. Call tour.start() from anywhere, restart() to run it again.
  • A flow across pages. Put a path on the steps that live elsewhere, pass navigate: goto, and mount <Tour {tour} /> in +layout.svelte so navigation does not unmount it.
  • Steps whose target is not on screen yet. Open a menu, switch a tab or await a fetch in the step's before.

Not for

  • Explaining one control on hover → Tooltip. A tour is a sequence.
  • Documentation, more than two sentences a step → a docs page, linked from a rich Tooltip or an Alert with an action.
  • An announcement with nothing to point at, like "Welcome to v2" → Dialog. One centred, targetless step can open a tour but should not be the whole tour.
  • Walking someone through a form → the form itself, with required fields and supportingText.
  • A screen with nothing on it → Empty. With no targets every step is skipped and the tour finishes at once.

Do

  • Select targets by an id or a data-tour attribute you own. A step that names a design-system class breaks on the next refactor.
  • Keep a step to a title and a sentence or two. The card fits the room beside the target and scrolls; on a phone that room is small.
  • Let a step ask for a real click when it helps. The page stays live inside the spotlight; use the next step's before to put things back.
  • Set round on a step pointing at an avatar or icon button, and padding where the target's own padding is thin.
  • Leave dismissible on, and watch onskip to learn where people leave.

Don't

  • Run it on every visit. Give it an id and once, and a "Take the tour" button for the rest.
  • Put more than about eight steps in one tour. Split it by page and start the second from the first's onfinish.
  • Mount <Tour /> inside the page a step navigates away from. It goes with the page.
  • Point at a target inside an overflow: hidden box that clips it. The hole is clipped to what is visible.

Quick reference

scrim
  • soft (default)
  • strong

How dark the page goes behind the spotlight.

API

Tour

A guided tour: a spotlight on one thing at a time, with a card explaining it.

The scrim is a single element with a hole cut in it — one box-shadow with an enormous spread — rather than four rectangles arranged around the target. That matters because the hole then animates: moving between steps slides and resizes one box with MD3 easing, so the spotlight travels across the page instead of blinking from place to place. The card travels with it — its position and height are transitioned, and the copy inside fades out, swaps, and fades back in once the card has settled — so a step change reads as one movement, not a cut.

A step names its target with a CSS selector, so a tour is data — it can come from a config file, a feature flag or the server, and it does not need a ref threaded through every component it points at. A step whose target isn't on the page is skipped rather than left pointing at nothing: Next walks forward past it, Back walks backward past it, and a tour with nothing left to point at finishes. A step with no target is different — it is centred, which is how a tour opens and closes.

The card never covers the hole. When a side is too short for it — which on a phone is most sides — the card shrinks into that side and scrolls inside itself rather than sliding over the thing being pointed at, and the step scrolls its target near the top of the screen instead of the middle so there is a side to put it on at all.

The page underneath stays interactive inside the spotlight, so a tour can ask you to actually click the thing. Everywhere else, a click on the scrim ends the tour when it is dismissible.

A tour that crosses pages is the same thing with a path on the steps that live elsewhere, and a createTour controller holding the state so it survives the navigation. Mount that one in +layout.svelte, not in a page — a tour that navigates away from the component rendering it takes the component with it.

import { Tour } from 'omaris'
<Tour bind:open steps={[  { target: '#new-order', title: 'Start here', content: 'Every order begins with this button.' }]} />
const tour = createTour(STEPS, { id: 'onboarding', navigate: goto });
<Tour {tour} />

Parts

Every element this renders is reachable from outside. Pass Tailwind for one part as classes={{ part: '…' }}; class covers the root and is merged last, so it always wins.

scrim
The click-catcher behind the card when the tour is dismissible: a transparent sheet with the spotlight cut out of it, so the page stays clickable inside the hole and a click anywhere else ends the tour.
spotlight
The dark layer, with the spotlight cut out of it.
card
The explaining card. Positioned with translate, so it glides.
scroller
Holds the card's copy when there is less room beside the target than the card wants — the card is capped to the space it has and this scrolls, so it is never resized over the spotlight.
body
What gets measured: everything inside the card.
header
No description in the source yet.
title
No description in the source yet.
counter
2 / 5.
progress
The slim track under the header.
progressBar
How much of the tour is done.
content
No description in the source yet.
footer
Dots, then the buttons.
dots
No description in the source yet.
dot
No description in the source yet.
actions
No description in the source yet.
ghost
Back, and the skip link — text buttons.
next
Next / Done — the filled button.

Props

steps
TourStep[]

The steps. Omit it when tour is given — the controller holds them.

tour
TourController

A controller from createTour, holding steps, open and index outside the component. Use one when the tour crosses pages: the state lives in the controller (and, with an id, in storage), so a real navigation resumes the tour where it was instead of ending it.

open bindable

Defaults to false

boolean

Whether the tour is running. Bindable. Ignored when tour is given.

index bindable

Defaults to 0

number

Which step, from 0. Bindable. Setting it to a step whose target is missing lands on the next one that resolves. Ignored when tour is given.

scrim

Defaults to 'soft'

TourScrim
soft
strong
padding

Defaults to 8

number

Default breathing room around a target, in px.

width

Defaults to 320

number | string

Card width. A number is px, a string any CSS length.

dismissible

Defaults to true

boolean

Let Escape and a click on the scrim end the tour.

dots

Defaults to true

boolean

Show the progress dots.

progress

Defaults to true

boolean

Show the slim progress bar under the header.

labels
{ next?: string; back?: string; done?: string; skip?: string }

Button text.

scrollIntoView

Defaults to true

boolean

Bring each target into view before pointing at it.

wait

Defaults to 0

number

How long to wait, in ms, for a step's target to appear before the step is treated as missing and skipped. 0 looks once. Raise it for targets that arrive with a fetch; a step with a path waits anyway.

navigate
(path: string) => void | Promise<void>

How a step's path is followed. goto from $app/navigation in a SvelteKit app; a full page load otherwise, which a tour with an id survives.

onfinish
() => void
onskip
(index: number) => void
onstep
(index: number, step: TourStep) => void

Fires once per step shown, after its target has been found.

class
string
classes
TourClasses