Skip to content
Communication

Tour

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

Walkthrough

1. 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} />

2. 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} />

3. 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} />

4. 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} />

5. 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} />

6. 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} />

7. 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'	}}/>