npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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.

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

  1. How it works
  2. Install
  3. Quick start
  4. Core concepts
  5. Advancing steps
  6. Theming
  7. Persistence & "show once"
  8. Adapting the code
  9. Low-level API — SpotlightOverlay
  10. API reference
  11. Accessibility, browsers & caveats
  12. 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-tour

react 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).
  • alternateTarget is tried before target — use it when a modal opens on top and you want the spotlight to jump to a control inside it.
  • targetIndex picks 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()true if the user previously finished or skipped this tourId.
  • 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 via labels, or the behavior via renderPanel.


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 is role="alertdialog". All transitions/animations are disabled under prefers-reduced-motion: reduce.
  • The target must be mounted and visible for the hole to appear. Zero-size or display:none elements 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 ResizeObserver and portals. SSR-safe: the overlay renders nothing on the server (guards on window/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    # vitest

Run the live demo:

cd examples/react-vite && npm install && npm run dev

Publishing (for maintainers)

npm run build
npm publish --access public