Onboarding tour
AdvancedNewA welcome sheet whose laptop zooms into each part of the system it introduces, blurring between steps.
onboarding-tour/onboarding-tour.tsxWelcome to Lumen OS 7
Introducing a new design made
of Prism Glass.
The live component, numbered part by part. Every measurement is read from it as it is drawn. Point at a row to outline its part, or at a part to find its row.
When to use
- A first run after a redesign, where a few new places on one screen each deserve a sentence.
- Three to six steps that all point at parts of the same picture: the camera moves, the screen stays.
- A welcome sheet the reader can leave at any time: Close ends it, Back and the arrow keys go over it again.
- Product launches and what’s-new moments, where the picture carries the message and the copy stays to two lines.
When not to use
- Teaching a task step by step in the live interface. Use coach marks anchored to the real controls.
- Anything the reader must read or agree to before going on: a tour can be closed at once. Use a dialog.
- More than about six steps, or steps with long explanations. Link to a help page instead.
- Features spread over several screens: one picture can’t frame them. Use a short series of pages.
- A single new thing to point out. A badge or a one-line tip next to it is enough.
You can add this component with the shadcn CLI or by hand:
Into components/onboarding-tour of any React project, with no setup:
npx shadcn@latest add https://brunodelorence.com/r/onboarding-tour.json- Copy the component files
Copy these files into one folder of your project, keeping their relative imports together, or download them as a ZIP:
- Import it
As shown under Usage, below.
Either way it needs React 19.2 and a bundler that supports CSS Modules, such as Next.js or Vite. It has a dark colour scheme: it follows your page’s color-scheme, light unless the page sets one, or its colorScheme prop, with nothing to configure. Browsers without light-dark() show it light.
Usage
import { OnboardingTour } from './onboarding-tour';export function Welcome({ onDone }: { onDone: () => void }) { return <OnboardingTour autoplay={false} onFinish={onDone} />;}Each state below is a live instance, locked so you can study it.
The pieces it is built from, each with its measurements read live.
The welcome window of a made-up desktop system: a laptop at the top, a title, two lines, page dots and a wide Continue button. Each step points the laptop’s camera at what it introduces: the menu bar’s control panel, the widgets, a search launcher and the controls’ settings. The frame swells and blurs on the way, and the texts blur out and back in.
- Each step has a framing for the laptop: a scale and a point that stays put. A step change plays one Web Animations transform from wherever the laptop is to the new framing, fast at first and settling slowly, with a slight swell halfway.
- The screen blurs during the change without a blur filter: a veil painted with soft gradients, showing the screen out of focus, covers it within a fifth of the change and clears last, and the screen’s content changes while it is covered.
- The title, the lines and the button’s label blur out and in the same way. Each text has a twin drawn as a soft glow (a text shadow on transparent letters), and the two cross-fade around the swap.
- The laptop’s screen is HTML and CSS: a wallpaper of SVG gradients, a menu bar, a control panel, widgets, a launcher with an app grid, and a settings window. Every part is always there and each step shows its own by opacity, so a change only repaints.
- The page dots are fixed-size boxes clipped to a dot or to the current pill, sliding on a springy curve. A timer swaps the content while the blur hides it, so nothing waits on an animation’s end.
- The window, its texts and controls, and the desktop drawn around it are light-dark() tokens (the desktop’s SVG gradient stops read them), so it follows the page’s color-scheme, or the colorScheme prop: a white window on a pale lilac desktop by day, a dark one on deep blue at night. The laptop and the system on its screen are a picture and look the same on both.
| Prop | Type | Default | Description |
|---|---|---|---|
defaultStep | number | 0 | The step to open on, from 0. |
onStepChange | (step: number) => void | — | Called with the new step, from 0, each time it changes. |
onFinish | () => void | — | Called when the reader gets started on the last step, or closes the tour. |
autoplay | boolean | true | While on screen and untouched, a ghost pointer presses Continue through the steps, goes back once, gets started and starts over. |
colorScheme | 'light' | 'dark' | 'system' | — | Its colours: light, dark, or the reader’s system setting. Without it the component follows the page’s color-scheme, which is light unless the page sets one. |
className | string | — | Extra class for the root. |
The complete source behind this demo. Open source under the MIT license, © 2026 Bruno Delorence: copy it, change it, ship it.
// Copyright (c) 2026 Bruno Delorence. MIT License.'use client';import { useEffect, useEffectEvent, useRef, useState, type CSSProperties, type FocusEvent, type KeyboardEvent,} from 'react';import { DemoPointer, runDemo } from './demo-pointer';import { Screen } from './screen';import { ORIGIN, STEPS, framingVars } from './steps';import { useAutoplay } from './use-autoplay';import { Backdrop } from './wallpaper';import styles from './onboarding-tour.module.css';/* * Onboarding tour: the welcome sheet of a desktop system. A laptop at the top shows the system's * screen; under it, a title, two lines, page dots and a wide Continue button. Each step points * the laptop's camera at the part it introduces: the menu bar's control panel, the widgets, the/* Onboarding tour (onboarding-tour.tsx). The window's size follows the stage's width (the root is a size container) as --w, and everything in it is a share of --w, so it keeps its proportions; below about 470px of stage the texts keep a readable size and the window grows a little taller. Memory and layout. The stage is one layout boundary of a known size: a change inside it (a new title) lays out the stage alone. What moves has a layer of its own, of a fixed size: the laptop (scaled, never resized), the veil over its screen, the texts and their glows, the dots and the pointer. The drawings never change. Colours are the tokens below, light-dark(light, dark) where the two differ: the component takes the color-scheme it inherits from the page (light unless the page says otherwise), or the one its colorScheme prop sets (data-color-scheme). The laptop is an object and its screen a picture of the system (screen.module.css, wallpaper.tsx): they look the same on both. */.root { /* The desktop around the window (under its drawing), and the window. */ --surface: #d9d0fb; --surface-2: #fff; /* The title and the lines; their glow copies, the same colours out of focus. */ --ink: #1b1c24; --ink-muted: #5c5f6e; --ink-glow: rgb(27 28 36 / 0.9); --ink-muted-glow: rgb(92 95 110 / 0.95); /* The window's hairline edge, and its shadows on the desktop, far and near. *//* * The tour's steps: what each one says, and where the laptop's camera points. A framing zooms the * laptop by `scale` about a point that stays put, given as shares of the window's width (the * window is about as tall as it is wide, so one unit serves both axes). *//** What the laptop's screen shows on a step (screen.tsx draws each layer). */export type Scene = 'desktop' | 'menu-bar' | 'widgets' | 'search' | 'controls';export type Framing = { /** How much the laptop is zoomed: 1 shows it whole. */ scale: number; /** The point that stays put while it zooms, in shares of the window's width. */ x: number; y: number;};export type TourStep = { title: string; /** The subtitle, as its two lines. */ lines: [string, string]; scene: Scene; framing: Framing;};import type { CSSProperties } from 'react';import type { Scene } from './steps';import { Wallpaper } from './wallpaper';import styles from './screen.module.css';/* * What the laptop's screen shows: the wallpaper, and over it the desktop's parts that the tour * introduces (the menu bar, its control panel, two widgets, the search launcher, the controls' * settings and the empty slots they can be dragged into). Every part is always there and the * scene picks which ones show, by their opacity: changing a step only repaints the screen, and * never adds or removes a drawing. Sizes are in hundredths of the screen's width (the * stylesheet's --s), so the screen scales as one picture. *//** The parts each scene shows. */const PARTS: Record<Scene, string> = { desktop: '', 'menu-bar': 'menu panel', widgets: 'menu widgets', search: 'menu search', controls: 'menu panel settings',};/** A tile's look: its two colours (top to bottom) and the simple glyph drawn on it. *//* The laptop's screen (screen.tsx). Every size is a multiple of --s, a hundredth of the screen's width, which the laptop sets (onboarding-tour.module.css): the screen is one picture that scales as a whole. Its parts are all there at once; the scene shows some by their opacity. */.screen { --s: calc(var(--u, 4.6px) * 0.7488); position: absolute; inset: 0; overflow: hidden; color: #fff; font-family: -apple-system, BlinkMacSystemFont, Inter, 'Segoe UI', sans-serif; line-height: 1; white-space: nowrap;}.screen b { font-weight: 600;}.part { position: absolute; opacity: 0;}.screen[data-parts~='menu'] .menu,.screen[data-parts~='panel'] .panel,import { useId, type CSSProperties } from 'react';/* * The two drawings behind everything: the laptop's wallpaper, folds of blue and lavender under a * violet sky with one bright edge of light, and the desktop around the window, the same colours * spread wide and soft. Both are made of gradients only (no blur filter, so they cost one paint), * and never change: an SVG whose style changes is laid out again. The wallpaper is a picture and * looks the same on both colour schemes; the desktop takes its colours from the stylesheet's * tokens (pale on light, deep on dark), which change only with the scheme. */const FILL: CSSProperties = { position: 'absolute', inset: 0, display: 'block', width: '100%', height: '100%',};const CLOUD = 'M0 466C44 440 94 430 142 440C198 454 232 500 248 538C292 522 352 534 388 578C412 606 424 630 428 645H0Z';const SLOPE = 'M602 645C654 560 702 502 770 444C850 374 926 306 1000 204V645Z';/** A shape whose edge is softened by wide, faint strokes of its colour, drawn under its fill. */'use client';import type { CSSProperties, Ref } from 'react';/* * The demo's pointer, and the steps that move it. A demo is a script of awaited steps: wait, * glide the pointer onto a control and press it, calling the component's own handler as a click * would. Stopping the demo (the reader takes over, or it scrolls away) ends the step in progress * at once: its promise rejects, which ends the script right there, and the pointer fades out. * * Nothing the pointer does needs a layout: it glides by an animation the compositor plays, and * the press scales a box that keeps a transform at rest (a box that gains or loses one is laid * out again) and rings a thin circle around the tip. The arrow is an SVG drawing whose style * never changes, inside HTML boxes. */export type Point = { x: number; y: number };export type Demo = { /** Resolves after `ms`. */ wait: (ms: number) => Promise<void>; /** Glides the pointer onto `target`: its middle, or a point given as shares of its size. */ moveTo: (target: Element | null | undefined, at?: Point) => Promise<void>; /** Presses and lets go; `action` runs as the pointer goes down. */'use client';import { useCallback, useEffect, useRef, useState } from 'react';/* * When a demo may play on its own: while part of it is on screen and the tab is visible, never * under reduced motion, and not while the reader is using it. `take()` hands it to the reader; it * takes back over `idle` ms after their last input. */export function useAutoplay<T extends HTMLElement>(idle = 6000) { const ref = useRef<T>(null); const [visible, setVisible] = useState(false); const [reduced, setReduced] = useState(true); const [taken, setTaken] = useState(false); const timer = useRef(0); useEffect(() => { const element = ref.current; if (!element) return; let onScreen = false; const update = () => setVisible(onScreen && !document.hidden); const observer = new IntersectionObserver( ([entry]) => { onScreen = entry.isIntersecting;- Back, Close the tour and Continue are real buttons with visible focus rings. With focus in the window, the Left and Right arrow keys change the step.
- Each step change the reader makes is announced in a polite live region (step number, title and both lines); the demo’s changes stay silent.
- The laptop, its screen, the wallpaper, the dots and the glow copies of the texts are hidden from screen readers.
- Under reduced motion the step changes at once and the demo never plays. Keyboard focus inside the window also stops the demo.