@frederico-kluser/react-spotlight-tour
v0.1.0
Published
Interactive product tours with a screen-blocking spotlight: dim the page, cut a clickable hole around any element, and walk users through your UI step by step. Framework-light, dependency-free, themeable.
Maintainers
Readme
react-spotlight-tour
Interactive product tours with a screen-blocking spotlight. Dim the whole page, cut a clickable hole around any element, float an instruction panel next to it, and walk your users through your UI — step by step.
- 🔦 Spotlight that blocks the screen. Everything outside the highlighted element is dimmed and un-clickable; the element inside the hole stays fully interactive.
- 🎯 Target anything with a CSS selector — no refs, no wrappers required.
- 🧭 Collision-aware panel that never covers the thing it's pointing at, on any screen size.
- ⏭️ Flexible advancing — manual, on-click-of-target, on-input-filled, or a custom predicate.
- 🎨 Themeable with CSS variables; zero runtime dependencies; SSR-safe.
- ♿ Respects
prefers-reduced-motion; Escape-to-close with confirmation.
This package is the tour engine extracted from the onboarding system of the Ondokai desktop app, genericized for any React app.
Table of contents
- How it works
- Install
- Quick start
- Core concepts
- Advancing steps
- Theming
- Persistence & "show once"
- Adapting the code
- Low-level API —
SpotlightOverlay - API reference
- Accessibility, browsers & caveats
- Credits & license
How it works
The "spotlight" is not an SVG mask or clip-path. It's four dimming
rectangles tiled around the target's bounding box, leaving a transparent,
clickable gap — plus a separate, non-interactive ring drawn on top for the glow.
Everything is rendered through a React portal into document.body.
┌───────────────── TOP mask ──────────────────┐
│ (dimmed, blocks clicks) │
├──────────┬────────────────────┬──────────────┤
│ │ │ │
│ LEFT │ ┌────────────┐ │ RIGHT │
│ mask │ │ TARGET │ │ mask │ ← the hole: transparent
│ │ │ (clickable)│ │ │ and interactive
│ │ └────────────┘ │ ┌───────────────┐
│ │ │ │ instruction │ ← panel auto-placed
├──────────┴────────────────────┴──────│ panel │ to avoid the hole
│ BOTTOM mask │ “Next →” │
│ (dimmed, blocks clicks) └───────────────┘
└──────────────────────────────────────────────────────┘A requestAnimationFrame loop re-reads the target's getBoundingClientRect()
every frame, so the hole glides as the page scrolls, resizes, or the tour
moves to the next target. A capture-phase event guard swallows every
mouse/drag interaction that lands outside the hole.
Install
npm install @frederico-kluser/react-spotlight-tour
# or: pnpm add @frederico-kluser/react-spotlight-tour / yarn add @frederico-kluser/react-spotlight-tourreact and react-dom (>= 18) are peer dependencies.
Import the stylesheet once, anywhere in your app (it ships as a single file):
import '@frederico-kluser/react-spotlight-tour/styles.css';Quick start
Three things: wrap your app, mark your targets, start the tour.
import { TourProvider, useTour, type Step } from '@frederico-kluser/react-spotlight-tour';
import '@frederico-kluser/react-spotlight-tour/styles.css';
const steps: Step[] = [
{ id: 'welcome', title: 'Welcome 👋', description: 'A 30-second tour of the app.' },
{ id: 'save', target: '[data-tour="save"]', title: 'Save', description: 'Your work autosaves here.' },
{ id: 'search', target: '[data-tour="search"]', title: 'Search', description: 'Find anything, fast.' }
];
function Toolbar() {
const tour = useTour();
return (
<header>
<button data-tour="save">Save</button>
<input data-tour="search" placeholder="Search…" />
<button onClick={() => tour.start()}>Take the tour</button>
</header>
);
}
export default function App() {
return (
<TourProvider steps={steps} tourId="main">
<Toolbar />
{/* …the rest of your app… */}
</TourProvider>
);
}That's it — clicking Take the tour dims the screen and walks through the three steps.
Core concepts
Targets & selectors
A step points at a DOM element with any CSS selector (step.target). The
recommended convention is a data attribute you control, so tours don't break
when class names change:
<button data-tour="save">Save</button>
// step: { target: '[data-tour="save"]' }- No
target→ a centered step with no hole; the whole screen dims (great for intro/outro steps). alternateTargetis tried beforetarget— use it when a modal opens on top and you want the spotlight to jump to a control inside it.targetIndexpicks the Nth match when a selector is ambiguous (-1= last).
If a target is off-screen when its step starts, the library scrolls it into view (centered) automatically.
The hole
spotlightPadding (default 10) inflates the hole around the target;
spotlightRadius (default 12) rounds its corners. Set them on TourProvider
(applies to all steps).
The panel
The instruction panel is placed by a collision-aware algorithm: it tries each side of the target in a rotating order, falls back to a compact size, and — only if nothing fits — picks the position with the least overlap. It never sits on top of the element it's describing (except as a last resort on very small screens, where it becomes click-through).
Pointer & keyboard blocking
While a step is active, every click/drag outside the hole is swallowed (the mask
shows a not-allowed cursor). Escape asks to close the tour (with a
confirmation dialog). See interactiveSelectors to poke
holes for fly-out menus, and deferEscapeWhen to yield Escape to your own modal.
Chapters
Group steps with chapterId and pass a chapters array for a "Chapter 2 of 4"
label + a meta block in the panel. Purely cosmetic.
Advancing steps
Each step chooses how it advances via advanceOn (default 'manual'):
| Mode | Behavior |
|---|---|
| 'manual' (default) | The user clicks Continue. |
| 'target-click' | Advances the instant the user clicks the spotlighted element. |
| 'input-filled' | Advances once the spotlighted <input>/<textarea> holds non-empty text. |
| waitUntil: () => boolean | Advances when your predicate returns true (polled while the step is active). Combine with any mode. |
const steps: Step[] = [
// advance when the user actually clicks the button
{ id: 'save', target: '[data-tour="save"]', advanceOn: 'target-click', hideContinueButton: true,
title: 'Click Save', description: 'Go ahead — it still works.' },
// advance when the search field has text
{ id: 'search', target: '[data-tour="search"]', advanceOn: 'input-filled', hideContinueButton: true,
title: 'Search', description: 'Type anything to continue.' },
// advance when your app reaches some state
{ id: 'connected', target: '[data-tour="status"]', waitUntil: () => store.isConnected,
title: 'Connecting…', description: 'This continues once you are connected.' }
];Gated steps show a "waiting → ready" status pill and disable Continue until
satisfied. Set hideContinueButton: true for pure auto-advance. There's a small
(220 ms) debounce before an auto-advance fires.
Drive the tour imperatively from anywhere with the useTour() hook:
const tour = useTour();
tour.start(); // begin from step 0 (optionally pass a fresh Step[])
tour.next(); // advance (completes past the last step)
tour.back(); // step back
tour.goTo(3); // jump
tour.skip(); // end, marked "skipped"
tour.stop(); // pause/hide (restartable, not marked finished)
tour.reset(); // clear persisted progress
tour.hasCompleted(); // did the user finish or skip this tour before?Theming
Every color, radius, shadow, z-index and duration is a --rst-* CSS variable
with a built-in default. To restyle, override the variables on :root (or any
ancestor of the portal — the overlay reads them from document.body's cascade):
:root {
--rst-accent: #e11d48; /* ring + primary buttons */
--rst-accent-rgb: 225, 29, 72; /* used for the glow (must match --rst-accent) */
--rst-scrim: rgba(10, 10, 20, 0.6);
--rst-panel-bg: #0b0b0f; /* dark panel */
--rst-text: #f5f5f7;
--rst-text-muted: #a1a1aa;
--rst-radius-lg: 20px;
}| Variable | Purpose | Default |
|---|---|---|
| --rst-z-index | Stacking of the overlay | 14000 |
| --rst-scrim | Dimming color of the mask/backdrop | rgba(0,0,0,.55) |
| --rst-accent | Spotlight ring + primary button | #6366f1 |
| --rst-accent-rgb | RGB triplet for the glow (match --rst-accent) | 99, 102, 241 |
| --rst-accent-text | Accent used as text (chapter label) | #4f46e5 |
| --rst-accent-tint / --rst-accent-tint-strong | Secondary button fills | tinted indigo |
| --rst-panel-bg | Panel & dialog surface | #ffffff |
| --rst-text / --rst-text-muted / --rst-text-subtle | Text colors | dark greys |
| --rst-border / --rst-border-subtle | Panel borders | translucent black |
| --rst-fill / --rst-fill-strong | Close button, meta block | translucent black |
| --rst-shadow | Panel elevation | soft drop shadow |
| --rst-success / --rst-success-bg / --rst-success-rgb | "Ready" pill | green |
| --rst-warning / --rst-warning-bg / --rst-warning-rgb | "Waiting" pill | amber |
| --rst-radius-lg / --rst-radius-md / --rst-radius-sm / --rst-radius-circle | Corner radii | 16 / 12 / 8 / 999 px |
| --rst-duration-fast / --rst-duration-base / --rst-duration-slow | Transition timings | 0.15 / 0.2 / 0.4 s |
| --rst-ease-out / --rst-ease-in-out | Easing curves | spring-ish cubic-beziers |
The defaults are light-first. For a dark app, override --rst-panel-bg,
--rst-text*, and --rst-scrim as shown above. For full control over markup,
use renderPanel instead of CSS.
Persistence & "show once"
Progress is saved through a pluggable StorageAdapter (default:
localStorage, keyed by tourId). The most common use is starting a tour only
for first-time users:
function Toolbar() {
const tour = useTour();
useEffect(() => {
if (!tour.hasCompleted()) tour.start(); // auto-start once, ever
}, []); // eslint-disable-line react-hooks/exhaustive-deps
return /* … */;
}hasCompleted()→trueif the user previously finished or skipped thistourId.reset()clears it so the tour shows again.- Pass
persist={false}to disable saving, or a custom adapter:
import { createMemoryStorageAdapter } from '@frederico-kluser/react-spotlight-tour';
<TourProvider steps={steps} tourId="main" storage={createMemoryStorageAdapter()}>A StorageAdapter is just { get, set, remove } — back it with cookies, your
server, or sessionStorage as you like.
Note on the × (close) button: it pauses the tour (
stop()), so it is not recorded as finished and can be reopened. Skip ends the tour and is recorded. Change the wording vialabels, or the behavior viarenderPanel.
Adapting the code
The library is deliberately unopinionated where apps differ. Common adaptations:
Localize / rename all text
Every string is data. Step title/description are plain strings — feed them
from your own i18n. Chrome strings come from labels:
<TourProvider
steps={steps}
labels={{
continue: 'Continuar',
finish: 'Concluir',
skip: 'Pular',
stepProgress: (c, t) => `Passo ${c} de ${t}`,
closeConfirmMessage: 'Fechar o tour?'
}}
>Keep a fly-out / menu clickable
Some UIs open a hover menu next to the highlighted element, outside the hole. List those selectors so clicks reach them:
{ id: 'add', target: '[data-tour="add-menu"]', interactiveSelectors: ['#add-flyout'] }Let the panel overlap on tiny screens
allowInteractionUnderPanel: true makes the panel click-through (its buttons
stay clickable) so the user can still reach a spotlighted control beneath it.
Integrate with your router / app readiness
The overlay has no notion of routes. Gate it with active:
<TourProvider steps={steps} active={route === '/editor' && isAppReady}>The engine keeps its state while active is false; the overlay just hides.
Yield Escape to your own modal
If you open a modal on top of a tour step and it should own Escape:
<TourProvider deferEscapeWhen={() => document.body.classList.contains('modal-open')}>Custom panel
See renderPanel to replace the panel entirely while keeping
the spotlight, positioning, and blocking.
Fork the overlay
Prefer to own the rendering? Import SpotlightOverlay
and drive it yourself, or copy src/SpotlightOverlay.tsx + src/positioning.ts
into your project — both are self-contained.
Low-level API — SpotlightOverlay
TourProvider is a thin wrapper around a fully controlled <SpotlightOverlay>.
Use it directly when you want to own progression (custom state machine, Redux,
XState, server-driven steps, etc.):
import { SpotlightOverlay, type Step } from '@frederico-kluser/react-spotlight-tour';
import '@frederico-kluser/react-spotlight-tour/styles.css';
function MyTour({ steps }: { steps: Step[] }) {
const [i, setI] = useState(0);
const [open, setOpen] = useState(true);
const step = steps[i];
if (!step) return null;
return (
<SpotlightOverlay
open={open}
step={step}
stepIndex={i}
totalSteps={steps.length}
onNext={() => (i + 1 < steps.length ? setI(i + 1) : setOpen(false))}
onBack={() => setI(Math.max(0, i - 1))}
onSkip={() => setOpen(false)}
onClose={() => setOpen(false)}
/>
);
}The positioning helpers (calculatePanelPosition, rectsOverlap,
scrollTargetIntoView) are exported too, if you build your own overlay.
API reference
<TourProvider> props
| Prop | Type | Default | Notes |
|---|---|---|---|
| steps | Step[] | [] | Default steps (override per run via start(steps)). |
| chapters | Chapter[] | [] | Optional groupings for the meta label. |
| tourId | string | 'default' | Persistence key suffix. |
| storage | StorageAdapter | localStorage | Persistence backend. |
| persist | boolean | true | Save completed/skipped status. |
| active | boolean | true | Gate overlay rendering (engine keeps running). |
| labels | Partial<TourLabels> | English | Chrome strings. |
| spotlightPadding | number | 10 | Hole padding (px). |
| spotlightRadius | number | 12 | Hole corner radius (px). |
| headerActions | ReactNode | – | Extra panel-header content. |
| showBack | boolean | false | Show a Back button after step 0. |
| confirmOnClose / confirmOnSkip | boolean | true | Ask before closing/skipping. |
| deferEscapeWhen | () => boolean | – | Return true to yield Escape. |
| renderPanel | (ctx) => ReactNode | – | Replace the panel. |
| onStatusChange | (status, meta) => void | – | Lifecycle callback. |
useTour(): TourController
status, steps, chapters, index, currentStep, currentChapter,
isOpen, isActionSatisfied, canAdvance, and the methods start, next,
back, skip, stop, goTo, reset, hasCompleted.
Step
| Field | Type | Notes |
|---|---|---|
| id | string | Required, unique. |
| target | string? | CSS selector; omit for a centered step. |
| alternateTarget | string? | Tried before target. |
| targetIndex | number? | Nth match (-1 = last). |
| title / description | string? | Plain strings (localize yourself). |
| chapterId | string? | Links to a Chapter. |
| advanceOn | 'manual' \| 'target-click' \| 'input-filled' | Default 'manual'. |
| waitUntil | () => boolean | Auto-advance predicate. |
| interactiveSelectors | string[] | Elements outside the hole that stay clickable. |
| allowInteractionUnderPanel | boolean | Make the panel click-through. |
| hideContinueButton | boolean | For auto-advancing steps. |
<SpotlightOverlay> (controlled)
Same visual props as the provider plus open, step, stepIndex,
totalSteps, canAdvance, isActionSatisfied, chapter, and the callbacks
onNext, onBack, onSkip, onClose.
renderPanel(ctx)
Return your own panel JSX; it's rendered inside the positioned container (so
collision handling still applies). ctx = { step, stepIndex, totalSteps,
isLastStep, canAdvance, isActionSatisfied, labels, chapter, next, back, skip,
close }.
Accessibility, browsers & caveats
- The panel is a
role="dialog"/aria-modal; the confirm dialog isrole="alertdialog". All transitions/animations are disabled underprefers-reduced-motion: reduce. - The target must be mounted and visible for the hole to appear. Zero-size
or
display:noneelements yield no spotlight (the mask still dims full-screen). - The hole is rectangular (the target's bounding box + padding).
- Works in any modern browser with
ResizeObserverand portals. SSR-safe: the overlay renders nothing on the server (guards onwindow/document). - Because tracking runs on
requestAnimationFrame, the spotlight follows scrolling containers and layout shifts automatically — no manual "update" calls.
Credits & license
Extracted and generalized from the interactive onboarding system of the Ondokai desktop app.
MIT © Ondokai. See LICENSE.
Local development
npm install
npm run build # bundle to dist/ (ESM + CJS + d.ts + styles.css)
npm run typecheck
npm run test:run # vitestRun the live demo:
cd examples/react-vite && npm install && npm run devPublishing (for maintainers)
npm run build
npm publish --access public