Communication
Tour
A guided tour: a spotlight on one thing at a time, with a card explaining it.
import { Tour } from 'omaris' 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.
<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.
<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.
/orders14 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.
<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.
Sales
1,240,000 IQD today, up 8% on last week.
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().
1,240,000 IQD
<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.
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=1in the URL. Calltour.start()from anywhere,restart()to run it again. - A flow across pages. Put a
pathon the steps that live elsewhere, passnavigate: goto, and mount<Tour {tour} />in+layout.svelteso 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
requiredfields andsupportingText. - 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-tourattribute 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
beforeto put things back. - Set
roundon a step pointing at an avatar or icon button, andpaddingwhere the target's own padding is thin. - Leave
dismissibleon, and watchonskipto learn where people leave.
Don't
- Run it on every visit. Give it an
idandonce, 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: hiddenbox 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.
counter2 / 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 —
textbuttons. 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 bindableDefaults to false
boolean Whether the tour is running. Bindable. Ignored when tour is given.
index bindableDefaults 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 softstrong
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