Tour spotlight
A guided tour that cuts a hole in a dimmed page around each element it is talking about.
<script setup lang="ts">
import type { TourStep } from '@shared/composables/useTour'
import { Button } from '@shared/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@shared/components/ui/card'
import { Input } from '@shared/components/ui/input'
import { useTour } from '@shared/composables/useTour'
const STEPS: TourStep[] = [
{
target: '[data-tour="search"]',
title: 'Find anything',
description: 'Search across every record from here, or press Ctrl and K.',
position: 'bottom',
},
{
target: '[data-tour="new"]',
title: 'Add a record',
description: 'This button always creates something in the section you are in.',
position: 'bottom',
},
{
target: '[data-tour="total"]',
title: 'Watch the number',
description: 'Totals update as your filters change, so what you see is what you get.',
position: 'top',
},
]
const { start, reset, isCompleted } = useTour({ id: 'docs-orders-tour', steps: STEPS })
</script>
<template>
<div class="space-y-4">
<div class="flex flex-wrap items-center gap-2">
<Button size="sm" @click="start()">
Start the tour
</Button>
<Button size="sm" variant="outline" @click="reset()">
Mark it unseen
</Button>
<span class="text-sm text-muted-foreground">
{{ isCompleted ? 'Completed' : 'Not completed yet' }}
</span>
</div>
<Card>
<CardHeader>
<CardTitle>Orders</CardTitle>
</CardHeader>
<CardContent class="space-y-3">
<div class="flex flex-wrap items-center gap-2">
<Input data-tour="search" placeholder="Search orders" class="max-w-xs" />
<Button data-tour="new" size="sm">
New order
</Button>
</div>
<p data-tour="total" class="text-2xl font-semibold tabular-nums">
1,204 orders
</p>
</CardContent>
</Card>
</div>
</template>Usage
TourSpotlight takes no props and is mounted once near the root. A page defines its own tour with useTour({ id, steps }) and calls start().
Each step names a CSS selector. The overlay finds that element, scrolls it to the middle of the screen, and draws the cutout around it. If the selector matches nothing, it retries on the next frame, which covers an element that is still rendering; if it still matches nothing, that step shows no overlay rather than pointing at the wrong thing.
The cutout follows the element. A ResizeObserver, plus scroll and resize listeners, keep the hole on the target while the layout moves under it.
TourStep
| Key | Type | Description |
|---|---|---|
target | string | A CSS selector for the element to highlight. |
title | string | The tooltip's heading. |
description | string | The tooltip's body. |
position | 'top' | 'bottom' | 'left' | 'right' | Where the tooltip sits. Defaults to bottom. |
Completion
useTour remembers which tours have been finished, in localStorage under sa-tours, keyed by the tour's id.
| Call | What it does |
|---|---|
start() | Opens the tour at step one, whether or not it was finished before. |
end() | Tears the overlay down without marking it done. |
reset() | Forgets that this tour was finished. |
isCompleted | true once Skip or the last Finish has been pressed. |
The split between end() and completion is deliberate. end() runs on unmount, so navigating away mid-tour cannot permanently suppress it. Only finishing it or skipping it counts.
Use isCompleted to decide whether to start a tour automatically for a first-time reader.
The overlay
The backdrop and the cutout are one positioned element with a very large box-shadow, rather than an SVG mask. It is cheap and it moves as one thing.
The overlay takes no pointer events, so the tour never blocks the page; only the tooltip captures clicks. It sits above dialogs, so a tour can point at something inside one.
API
Source: src/shared/components/common/TourSpotlight.vue, src/shared/composables/useTour.ts
Props
None.
Emits
None.
Slots
None.
Accessibility
- The tooltip is real text in the page, not a title attribute, so it can be read and zoomed.
- Skip, Back and Next are ordinary buttons in the tab order. The last step's button reads Finish, so the end is not a surprise.
- Because the overlay takes no pointer events, a reader can still use the page while the tour is open rather than being trapped in it.
- The tooltip is capped at the viewport width less a margin, so it never runs off the side of a phone.
- The highlight is a ring in the primary color as well as a hole in the dimming, so the target is marked by more than brightness alone.