More Modals Snippets
Onboarding Tour HTML CSS JS — Product Walkthrough Tooltip
Onboarding Tour · Modals · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Onboarding Tour — How to Build a Product Walkthrough Tour with getBoundingClientRect Tooltip Positioning in JavaScript

An onboarding tour guides new users through a product's key features on their first visit — highlighting UI elements one by one with positioned tooltips that explain each feature. It's the standard pattern for reducing time-to-value in SaaS products: instead of sending users to a help document, you bring the help directly to the UI elements in context.
Building an onboarding tour from scratch requires solving three genuinely hard problems: accurately positioning a tooltip relative to any arbitrary target element in a scrolled, responsive layout; creating a highlight effect that draws attention to the target without disrupting the layout; and managing step state with keyboard accessibility and resize handling.
This snippet builds a complete five-step product tour in plain JavaScript, without Shepherd.js, Intro.js, or any tour library.
Target Element Positioning with getBoundingClientRect
getBoundingClientRect() returns the position and dimensions of an element relative to the viewport: { top, left, bottom, right, width, height }. This is the correct API for overlay positioning — unlike offsetTop/offsetLeft (which give position relative to the nearest positioned ancestor and require traversing the parent chain), getBoundingClientRect() gives viewport coordinates directly.
The tooltip positioning function: const rect = target.getBoundingClientRect(). For placement: 'right': the tooltip left = rect.right + gap, top = rect.top + rect.height/2 - tooltipHeight/2. For placement below: top = rect.bottom + gap, left = rect.left. The tooltip is position: fixed (not absolute) so it correctly overlays the page regardless of scroll position and parent container transforms.
Viewport Edge Clamping
A positioned tooltip can overflow the viewport if the target element is near a screen edge. After computing the target-relative position, the function clamps: left = Math.max(8, Math.min(left, window.innerWidth - tooltipWidth - 8)) and top = Math.max(8, Math.min(top, window.innerHeight - tooltipHeight - 8)). The 8px margin keeps the tooltip off the absolute viewport edge. This clamping runs after every position computation, including on window.resize.
The Highlight Overlay
Drawing attention to the target element uses two layers. First, a full-screen semi-transparent overlay darkens everything. Second, a "spotlight" cut-out reveals the target at full brightness. This is achieved with a CSS box-shadow technique: the highlight element is sized to match the target element (via getBoundingClientRect) and uses box-shadow: 0 0 0 9999px rgba(0,0,0,0.55) — an enormous outer shadow that spreads to fill the entire viewport around the element, leaving the element itself at normal brightness inside the shadow edge. This creates the spotlight effect without canvas drawing or clip-path masking.
The highlight element is position: fixed and updated on each step transition: highlight.style.top = rect.top - 4 + 'px', highlight.style.left = rect.left - 4 + 'px', highlight.style.width = rect.width + 8 + 'px', highlight.style.height = rect.height + 8 + 'px'. The 4px padding creates breathing room around the target. A border-radius: 6px on the highlight matches the target element's corner rounding.
Step State Machine
The tour has five steps, each defined as: { selector: '#id', title: '...', description: '...', placement: 'right' | 'bottom' | 'left' }. A currentStep integer tracks progress. The functions goNext() and goPrev() increment/decrement currentStep and call showStep(currentStep).
showStep(i) queries the target element (document.querySelector(step.selector)), calls getBoundingClientRect(), positions both the highlight and tooltip, updates the step counter ("3 / 5"), enables/disables Prev/Next buttons at boundaries, and scrolls the target into view with target.scrollIntoView({ behavior: 'smooth', block: 'center' }) to ensure off-screen targets are visible before positioning.
Keyboard Navigation
The tour registers a keydown event listener on document for the duration of the tour: ArrowRight and Enter call goNext(), ArrowLeft calls goPrev(), and Escape calls endTour(). The listener is added when the tour starts and removed when it ends. ARIA attributes — aria-live="polite" on the tooltip content and aria-label on navigation buttons — ensure screen readers announce step changes.
Resize Handling
When the window resizes (e.g., responsive breakpoint change or DevTools opening), target element positions change. A window.resize event listener calls positionTooltip(target, placement) using requestAnimationFrame to debounce: let rafId; window.addEventListener('resize', () => { cancelAnimationFrame(rafId); rafId = requestAnimationFrame(reposition); }). This re-reads getBoundingClientRect() on the current step's target and re-positions both the highlight box and tooltip. Without this, the tooltip and highlight would drift from their targets after a responsive layout change.
Ending and Skipping the Tour
The "Skip" button calls endTour() at any step. endTour() removes the overlay, hides the tooltip and highlight, removes the keyboard listener, and optionally calls localStorage.setItem('tour-complete', '1') to prevent the tour from showing again on page reload. The final step's Next button changes to "Done" and also calls endTour().
Build with AI
Build, Understand, Optimize, and Extend It With AI
You don't need to work out the positioning math and the highlight technique on your own. Paste this snippet's HTML, CSS, and JS into an AI coding assistant like Claude and ask it to walk through exactly how positionTooltip decides between placement right, below, or above by comparing spaceBelow and spaceAbove against the tooltip's measured height, or why the spotlight uses a single box-shadow spread to 9999px instead of a canvas overlay with a cutout. The same assistant can help you optimize it — ask whether the resize listener's requestAnimationFrame debounce is enough on a page with dozens of potential tour targets, or whether repositioning should be skipped entirely while the tooltip is off-screen. It's equally useful for extending the tour: have it add scrollIntoView so off-screen steps auto-scroll into position, support a left or top placement option, or persist tour completion to localStorage so returning users don't see it again. Treat the code less like a finished artifact and more like a starting point for a conversation.
Prompt to recreate it
Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:
Build a "product onboarding tour" in plain HTML, CSS, and JavaScript using only getBoundingClientRect for positioning — no third-party tour library like Shepherd, Intro.js, or Driver.js.
Requirements:
- A STEPS array of plain objects, each with a CSS selector for its target element, a title, a description, and an optional placement (e.g. right, or default below/above).
- A highlight technique that adds a class to the current target element giving it box-shadow: 0 0 0 9999px rgba(0,0,0,0.6) (or similar) plus a colored ring shadow, and a temporarily elevated z-index, so the target appears lit against a fully darkened rest-of-page with no separate overlay element and no clip-path.
- A tooltip element positioned with position: fixed, whose top and left are computed from the target's getBoundingClientRect() each step: if placement is right, center it vertically beside the element; otherwise decide between placing it below or above the target based on which side actually has enough remaining viewport space, defaulting to below when both fit.
- After computing the raw position, clamp both left and top so the tooltip never overflows the viewport edges, leaving a small margin (e.g. 8-12px) on all sides.
- A dot-based progress indicator and a step counter label ("Step N of Total") that update every time the step changes, plus Previous/Next buttons where Previous is disabled on the first step and Next relabels to "Finish" on the last step.
- Keyboard support: ArrowRight or Enter advances, ArrowLeft goes back, and Escape ends the tour immediately, all wired through a single keydown listener active only while the tour is running.
- A window resize listener that recomputes and reapplies the tooltip's position for whatever step is currently active, so the tooltip and highlight never drift away from their target after a layout change.
- On completing the final step, remove the highlight and show a short-lived success toast confirming the tour is done.Step by step
How to Use
- 1Start the tourClick "Start Tour" to begin. The first target element highlights with a spotlight box-shadow and a positioned tooltip appears beside it.
- 2Navigate forwardClick "Next" or press the right arrow key to advance to the next step. Each step highlights a different UI element and repositions the tooltip.
- 3Navigate backwardClick "Back" or press the left arrow key to return to the previous step. The highlight and tooltip move back to the previous target.
- 4Skip or endClick "Skip" at any step to end the tour immediately. On the last step, the Next button becomes "Done" and ends the tour on click.
- 5Define your stepsEdit the STEPS array: each entry needs a selector (#id or .class), a title, a description, and an optional placement (right, bottom, left). The tour positions itself relative to whatever element matches the selector.
- 6Persist tour completionAdd localStorage.setItem("tour-done", "1") in endTour(), then check localStorage.getItem("tour-done") before calling startTour() to skip the tour for returning users.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
The highlight element is a div sized to match the target element using getBoundingClientRect(). It has box-shadow: 0 0 0 9999px rgba(0,0,0,0.55) — an enormous outer box-shadow that spreads in all directions to fill the entire viewport with a dark overlay. The element itself is left at normal brightness, creating the spotlight effect. A border-radius matches the target element's corners. This technique requires no canvas, no SVG, and no clip-path — just one CSS property on a positioned element.
position:absolute positions relative to the nearest positioned ancestor, which may not be the viewport. If the tour runs inside a container with position:relative and overflow:hidden, an absolute tooltip can be clipped. position:fixed always positions relative to the viewport, regardless of scroll position, parent transforms, or container overflow. Since getBoundingClientRect() also returns viewport-relative coordinates, the two are consistent: fixed + getBoundingClientRect() gives the correct position every time.
In positionTooltip(), add cases for "left" and "top": case "left": left = rect.left - tooltipWidth - gap; top = rect.top + rect.height/2 - tooltipHeight/2; break; case "top": top = rect.top - tooltipHeight - gap; left = rect.left + rect.width/2 - tooltipWidth/2; break;. Then apply the same viewport edge clamping to both. Add the placement to the step definition: { selector: "#id", placement: "left", ... }.
In endTour(): localStorage.setItem("onboarding-v1-done", "1"). In your initialization: if (!localStorage.getItem("onboarding-v1-done")) { startTour(); }. Version the key ("v1") so that a new tour version (new features added) shows to users who already completed the old tour. For server-persisted completion (across devices), save a flag to the user record via your API in endTour() and check it server-side before rendering the tour trigger.
window.addEventListener("resize", handler) fires continuously during a drag-resize. Without debouncing, the handler would call getBoundingClientRect() and update the DOM on every resize pixel, causing performance issues. The rAF debounce: let rafId; const handler = () => { cancelAnimationFrame(rafId); rafId = requestAnimationFrame(reposition); }. cancelAnimationFrame cancels any pending rAF from a previous resize event, and the new rAF schedules one call after the current resize frame. reposition re-reads getBoundingClientRect on the current step target and updates highlight and tooltip positions.
Yes. Export via the JSX, Vue, Angular, or Tailwind buttons on this page. In React, keep the current step index in useState and compute the spotlight position from getBoundingClientRect inside a useLayoutEffect so the highlight measures the target after each render.