📖 Developer Guide

How to Build a Product Tour in React — and What Stays Hard Afterwards

A React product tour is a satisfying two-day build. You write a step config, resolve a target element, position a tooltip, punch a hole in an overlay, and it works. The interesting part is everything that happens in month three: the virtualised list whose target is not mounted, the modal that renders in a portal, the German translation that breaks the layout, and the marketing manager who wants step four reworded and is told it needs a release. This guide walks through the implementation with real code, then through the seven problems that do not go away.

📅 Updated August 2026 ⏱ 15 min read ✍️ By Kompassify
The architecture of a React product tour showing the step config, target resolver, positioning layer, spotlight overlay and persistence

Building a product tour in React feels like a solved problem for about two days. You define an array of steps, each with a CSS selector and some text. You look up the element, measure it, render a tooltip next to it, dim everything else. It works, it looks good, and you ship it.

Then the tour meets a real application. The dashboard fetches before it renders, so step two points at nothing. Step four's target lives inside a virtualised table and is not mounted until you scroll. The settings modal renders in a portal at the end of the body, so the overlay covers it. A designer renames a class and step three silently stops working — silently, because a missing element is not an exception. And someone in marketing asks to reword step five, which now requires a pull request, a review and a deploy.

This guide does both halves honestly. First, how to build a React product tour properly, with the code for the five pieces that matter. Then the seven problems that remain after the first version works, and a straight answer on when building is the right call.

Key Takeaways

  • Five pieces: step config, target resolver, positioning, spotlight overlay, persistence. The first version is around two hundred lines.
  • Never query a target once. Use a MutationObserver with a timeout, and skip gracefully rather than stalling behind an overlay.
  • Use an SVG mask for the spotlight — one element, rounded corners, and no four-div arithmetic.
  • Persist completion on the user, not in localStorage, or people see the tour again on their second device.
  • Treat each step as a dialog. Focus trap, Escape to dismiss, aria-live on step change — this is a real accessibility requirement.
  • The cost is not the build, it is the edit. If rewording step three takes a sprint, the maintenance model is the problem, not the code.

What a Product Tour Actually Requires

Before any code, here is the full surface area. Most estimates cover the first three rows and none of the rest, which is why product tours are the classic two-day task that takes three weeks.

Piece What it does Where it gets hard
Step config Ordered list of targets, content, placement. Lives in code, so every edit is a deploy.
Target resolver Finds the element, waits if it is not there yet. Async rendering, virtualised lists, route changes.
Positioning Places the step near the target, flips when there is no room. Scroll, resize, zoom, sticky headers, RTL, long translations.
Spotlight overlay Dims the page, cuts a hole around the target. Stacking contexts, portals, transformed ancestors.
Persistence Remembers who has seen what. Cross-device, cross-session, versioning when the tour changes.
Targeting Decides who gets this tour at all. Needs segments, which needs event data.
Analytics Per-step drop-off, completion, dismissal. Always added later, after someone asks whether it works.

1. The Step Configuration

Start with data, not components. A tour is a list; keeping it as plain data means you can later move it to an API, translate it, or version it without touching the renderer.

tour-config.js

export const welcomeTour = { id: 'welcome-v3', // version it — see persistence below steps: [ { target: '[data-tour="new-project"]', title: 'Start with a project', body: 'Everything lives inside a project. Create your first one here.', placement: 'bottom', // advance when the user actually does the thing, not on Next advanceOn: 'click' }, { target: '[data-tour="invite"]', title: 'Bring your team', body: 'Projects get useful with two or more people in them.', placement: 'left', optional: true // skip silently if the target never appears } ] }

Use dedicated data-tour attributes, never class names or generated selectors. A class is a styling decision that someone will change without knowing a tour depends on it. A data-tour attribute is an explicit contract, greppable from the config, and it survives every redesign. This one convention prevents most of the silent breakage that gives home-built tours their reputation.


2. Resolving the Target (Without Giving Up)

The naive implementation calls document.querySelector when the step begins. In a React app that fetches before it renders, that fails constantly. Watch for the element instead, with a timeout so the tour can never hang.

use-target.js

import { useEffect, useState } from 'react' export function useTarget(selector, { timeout = 5000 } = {}) { const [state, setState] = useState({ el: null, status: 'waiting' }) useEffect(() => { if (!selector) return let settled = false const check = () => { const el = document.querySelector(selector) if (el && !settled) { settled = true setState({ el, status: 'found' }) observer.disconnect() clearTimeout(timer) } } const observer = new MutationObserver(check) observer.observe(document.body, { childList: true, subtree: true }) const timer = setTimeout(() => { if (!settled) { settled = true setState({ el: null, status: 'missing' }) // caller skips or ends observer.disconnect() } }, timeout) check() // it may already be there return () => { observer.disconnect(); clearTimeout(timer) } }, [selector, timeout]) return state }

The 'missing' state is the important part. A tour that stalls on an element that never arrives is worse than no tour, because the user is now trapped behind a dimmed overlay with no obvious escape. Optional steps skip; required steps end the tour cleanly and record why.


3. Positioning the Step

Positioning is where hand-rolled tours accumulate the most edge cases. The modern browser primitive — CSS anchor positioning — handles a good share of it declaratively where it is supported, but you still need a measured fallback. The core of it is small:

position.js

const GAP = 12 export function placeStep(targetEl, stepEl, preferred = 'bottom') { const t = targetEl.getBoundingClientRect() const s = stepEl.getBoundingClientRect() const vw = window.innerWidth, vh = window.innerHeight const fits = { bottom: vh - t.bottom >= s.height + GAP, top: t.top >= s.height + GAP, right: vw - t.right >= s.width + GAP, left: t.left >= s.width + GAP } const side = fits[preferred] ? preferred : (Object.keys(fits).find(k => fits[k]) || 'bottom') let top, left if (side === 'bottom') { top = t.bottom + GAP; left = t.left + t.width / 2 - s.width / 2 } if (side === 'top') { top = t.top - s.height - GAP; left = t.left + t.width / 2 - s.width / 2 } if (side === 'right') { left = t.right + GAP; top = t.top + t.height / 2 - s.height / 2 } if (side === 'left') { left = t.left - s.width - GAP; top = t.top + t.height / 2 - s.height / 2 } // clamp inside the viewport so nothing renders off-screen left = Math.max(8, Math.min(left, vw - s.width - 8)) top = Math.max(8, Math.min(top, vh - s.height - 8)) return { top, left, side } }

Then recompute on scroll and resize (passive listeners, throttled to an animation frame), and on any ResizeObserver entry for the target — because a target that changes size after data loads will otherwise leave the step pointing at empty space.


4. The Spotlight Overlay

The obvious implementation is four absolutely positioned divs framing the target. It works until you want rounded corners. An SVG mask is one element, handles radius natively, and is far easier to animate between steps.

Spotlight.jsx

export function Spotlight({ rect, radius = 8, pad = 6 }) { if (!rect) return null return ( <svg style={{ position: 'fixed', inset: 0, width: '100%', height: '100%', zIndex: 9998, pointerEvents: 'none' }} aria-hidden="true" > <defs> <mask id="tour-mask"> <rect width="100%" height="100%" fill="white" /> <rect x={rect.left - pad} y={rect.top - pad} width={rect.width + pad * 2} height={rect.height + pad * 2} rx={radius} fill="black" /> </mask> </defs> <rect width="100%" height="100%" fill="rgba(10, 20, 45, 0.55)" mask="url(#tour-mask)" /> </svg> ) }

Two details worth getting right. Render the overlay and the step into a portal attached to document.body, so no ancestor with overflow: hidden or a transform can clip or re-parent them. And keep pointer-events: none on the overlay if you want the highlighted control to remain genuinely clickable — which you usually do, because the best tours advance when the user performs the action rather than when they click Next.


5. Persistence and Versioning

This is the piece most often done in the cheapest possible way and most often regretted.

✓ Do

  • Store completion against the user record on the server.
  • Record completed and dismissed as different outcomes.
  • Key on the tour's version id, so a rewritten tour can show again.
  • Store the last step reached, for resumability.

✗ Don't

  • Rely on localStorage alone — it is per browser, per device.
  • Use one boolean for all tours forever.
  • Re-show a tour to someone who deliberately escaped it.
  • Forget that clearing site data resets everything.

The localStorage-only approach is the single most common complaint about home-built tours: a user completes onboarding on their laptop, opens the product on a second machine, and is walked through it again. Storing a small map of { tourId: outcome } on the user costs one endpoint and removes the entire class of problem.


Accessibility Is Not Optional Here

A tour takes over the screen, which makes it a dialog whether you called it one or not. The minimum set:


The Seven Things That Stay Hard


Build It, or Configure It?

The code above is genuinely not hard, and if you enjoy this sort of thing you will have a working tour by Thursday. The decision is not about difficulty; it is about who owns the content afterwards.

The test: step three's copy needs changing. Who does it, and how long until a user sees the new wording? If the honest answer is "a developer, next sprint", the tour will stop being maintained within a quarter — not through neglect, but because the people with opinions about onboarding cannot act on them. That is the real cost, and it does not appear in the build estimate.

Build it when the tour is a product feature rather than onboarding content: deeply coupled to your data model, unusual in behaviour, or something you sell to your own customers. Configure it when it is onboarding content that product, marketing or customer success will want to change often — which is almost always. The full economics, including the parts that only show up in year two, are in build vs buy user onboarding.

Building a product tour step in a no-code editor instead of a React component, targeting the same element
(The same step, defined without a deploy — which is the difference that decides whether it is still accurate in six months)

Kompassify is the configured version of everything above: it handles the target resolution, positioning, spotlight, persistence, segmentation and per-step analytics, works on any framework including React, and lets a non-developer change step three's wording in a minute. It installs as a single script and targets the same data-tour attributes you would have written anyway, so nothing about your component code needs to change. GDPR compliant, EU-hosted, free for under 100 monthly active users, with paid plans from $129/month.

Keep the data-tour Attributes. Skip the Other 200 Lines.

Kompassify handles target resolution, positioning, spotlight, persistence, targeting and per-step analytics — on your existing React app, with no component changes and no deploy for every copy edit. GDPR compliant, EU-hosted, and free for under 100 monthly active users.

Start for Free →

Frequently Asked Questions

How do you build a product tour in React?

At minimum you need five pieces: a step configuration listing each target selector and its content; a resolver that finds the target element and waits for it if the app has not rendered it yet; a positioning layer that places the step tooltip relative to the target and flips it when there is no room; an overlay that dims the page and cuts a hole around the target; and persistence so that progress survives a route change and a reload. The first version is roughly two hundred lines of React and takes a competent developer a couple of days. Everything after that first version is where the real cost lives.

How do you highlight an element in a React product tour?

The most robust approach is a full-screen fixed overlay containing an SVG mask: fill the whole viewport with the dim colour, then punch a rounded rectangle out of it at the target's bounding box. It avoids the classic four-div technique, handles rounded corners cleanly, and lets you add a soft border around the cut-out. Set pointer-events to none on the overlay if you want the user to be able to interact with the highlighted control, and remember to recompute the rectangle on scroll and resize.

How do you anchor a tour step to an element that has not rendered yet?

Do not query once and give up. Use a MutationObserver that watches the document for the target selector appearing, with a timeout after which the step is either skipped or the tour is paused. This is essential in any React app that fetches data before rendering, because the element a tour step points at frequently does not exist at the moment the step begins. Skipping gracefully matters more than it sounds: a tour that stalls on a missing element is worse than no tour, because the user is now stuck behind an overlay.

What makes product tours hard to maintain in React?

Selector coupling, mostly. Tour steps target DOM elements, so every refactor, redesign or class-name change can silently break a step — and nothing fails loudly, because a missing element is not an exception. Add virtualised lists where the target may not be mounted, modals and portals that render outside the main tree, server rendering that has no DOM at build time, localisation that changes text length and therefore layout, mobile viewports where the anchored element is off-screen, and focus management for keyboard users. None is individually hard; together they are a permanent maintenance line item.

Should you build a product tour or use a no-code tool?

Build it when the tour is a genuine product feature — deeply integrated with your data, unusual in behaviour, or something you sell. Use a configurable tool when the tour is onboarding content that marketing, product or customer success will want to change frequently, because the real cost of building is not the first version but every subsequent edit going through an engineering backlog and a release. A useful test: if the copy of step three needs changing, who does it and how long until a user sees it? If the honest answer is "a developer, next sprint", building is the more expensive option regardless of how the first estimate looked. The full comparison is in build vs buy user onboarding.

How do you make a React product tour accessible?

Treat each step as a dialog: give the step container role="dialog" with aria-modal, move focus into it when it opens, trap Tab within it while it is open, restore focus to the previous element when it closes, and make Escape dismiss the tour. Announce step changes with an aria-live region so screen-reader users hear the new content, ensure every control is reachable by keyboard, and respect prefers-reduced-motion by disabling scroll and highlight animations. A tour that cannot be dismissed from the keyboard is a genuine accessibility barrier, not a rough edge.

How do you stop a product tour from showing repeatedly?

Persist completion server-side against the user record, not only in browser storage. Local storage is per-browser and per-device, so a user who switches machines or clears their storage sees the tour again — which is the single most common complaint about home-built tours. Store a completion or dismissal flag on the user, check it before starting, and record both "completed" and "dismissed" separately so you can tell the difference between a tour people finish and one people escape from.

How do you measure whether a product tour works?

Emit an event at every transition — tour started, each step viewed, each step completed, dismissed, finished — then look at per-step drop-off rather than an overall completion rate. Completion alone tells you almost nothing, because a tour can be completed by people clicking Next to make it go away. The number that matters is whether users who saw the tour reach activation more often than a comparable group who did not, which requires that the events exist in the first place. Instrument before you launch, not after someone asks.