Flows
Tour
A guided walkthrough that spotlights real elements on the page one at a time with a card beside each.
- Built on
Popover API@starting-stylescrollIntoView- Client JavaScript
- Yes, for state
- Works without React
- React only (needs JavaScript)
- Source, gzipped
- 4.8 kB
- Built from
- Button
Issues tour
Filter the list
Narrow issues by status, owner or label.
Code
"use client";
import { useState } from "react";
import { Button } from "@/components/equip/button";
import { Tour } from "@/components/equip/tour";
const steps = [
{
target: "#demo-tour-filter",
title: "Filter the list",
body: "Narrow issues by status, owner or label.",
},
{
target: "#demo-tour-export",
title: "Export when ready",
body: "Download exactly what the filter shows as CSV.",
},
{
target: "#demo-tour-start",
title: "Come back any time",
body: "This button runs the tour again.",
placement: "top" as const,
},
];
export default function Demo() {
const [open, setOpen] = useState(false);
return (
<div style={{ display: "grid", gap: 16, justifyItems: "center" }}>
<div style={{ display: "flex", gap: 8 }}>
<Button id="demo-tour-filter" size="s" variant="outline">
Filter
</Button>
<Button id="demo-tour-export" size="s" variant="outline">
Export
</Button>
</div>
<Button id="demo-tour-start" onClick={() => setOpen(true)}>
Start tour
</Button>
<Tour label="Issues tour" steps={steps} open={open} onOpenChange={setOpen} />
</div>
);
}Examples
Missing targets are skipped
The second step points at a feature flag that is off, so the tour goes from one to three and counts 2 steps, not 3.
Getting started
Invite your team
Teammates you invite join with the Member role.
Code
"use client";
import { useState } from "react";
import { Button } from "@/components/equip/button";
import { Tour } from "@/components/equip/tour";
const steps = [
{
target: "#example-tour-invite",
title: "Invite your team",
body: "Teammates you invite join with the Member role.",
},
{
target: "#example-tour-ai-summary",
title: "Summaries",
body: "Not shown: this target is not on the page.",
},
{
target: "#example-tour-billing",
title: "Billing lives here",
body: "Change plan or download invoices.",
placement: "end" as const,
},
];
export default function Example() {
const [open, setOpen] = useState(false);
return (
<div style={{ display: "flex", flexWrap: "wrap", gap: 8 }}>
<Button variant="accent" onClick={() => setOpen(true)}>
Show me around
</Button>
<Button id="example-tour-invite" variant="outline">
Invite
</Button>
<Button id="example-tour-billing" variant="outline">
Billing
</Button>
<Tour label="Getting started" steps={steps} open={open} onOpenChange={setOpen} />
</div>
);
}Controlled step and finish
Track the step to resume where someone left off, and use onFinish to remember that they completed it.
select * from ordersStep 1 of 2
Query editor
Write the query
Autocomplete knows your tables and columns.
Code
"use client";
import { useState } from "react";
import { Button } from "@/components/equip/button";
import { Tour } from "@/components/equip/tour";
const steps = [
{
target: "#example-tour-editor",
title: "Write the query",
body: "Autocomplete knows your tables and columns.",
placement: "bottom" as const,
},
{
target: "#example-tour-run",
title: "Run it",
body: "Results stream in as rows arrive.",
placement: "start" as const,
},
];
export default function Example() {
const [open, setOpen] = useState(false);
const [step, setStep] = useState(0);
const [finished, setFinished] = useState(false);
return (
<div style={{ display: "grid", gap: 12, maxWidth: 360 }}>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<code
id="example-tour-editor"
style={{
flex: 1,
padding: "6px 10px",
border: "1px solid var(--eq-line-strong)",
borderRadius: "var(--eq-radius-m)",
fontFamily: "var(--eq-font-mono)",
fontVariationSettings: "var(--eq-font-mono-settings)",
fontSize: "var(--eq-text-s)",
}}
>
select * from orders
</code>
<Button id="example-tour-run" size="s">
Run
</Button>
</div>
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
<Button variant="outline" size="s" onClick={() => setOpen(true)}>
{finished ? "Replay tour" : step > 0 ? "Resume tour" : "Start tour"}
</Button>
<span style={{ color: "var(--eq-ink-2)", fontSize: "var(--eq-text-s)" }}>
{finished ? "Completed" : `Step ${step + 1} of ${steps.length}`}
</span>
</div>
<Tour
label="Query editor"
steps={steps}
open={open}
onOpenChange={setOpen}
step={step}
onStepChange={setStep}
onFinish={() => {
setFinished(true);
setStep(0);
}}
/>
</div>
);
}Props
| Prop | Type | Default | What it does |
|---|---|---|---|
steps | { target: string; title: string; body: ReactNode; placement?: "top" | "bottom" | "start" | "end" }[] | none | Each step's target is a CSS selector. placement defaults to bottom and flips when there is no room. |
label | string | none | Names the tour, e.g. "Product tour". Shown above each title. |
open | boolean | none | Controlled open state. |
defaultOpen | boolean | false | Initial open state when uncontrolled. |
onOpenChange | (open: boolean) => void | none | Called when the tour opens or closes. |
step | number | none | Controlled index into steps. |
defaultStep | number | 0 | The step to start at when uncontrolled. |
onStepChange | (step: number) => void | none | Called with the new index on Back, Next or an arrow key. |
onFinish | () => void | none | Called when Done is pressed on the last step, before it closes. |
...rest | ComponentProps<"div"> | none | Every native attribute, applied to the card. |
--eq-tour-width | CSS length | 18rem | The card's width. |
Anatomy
| spotlight | .eq-tour-spotlight | A manual popover: one rounded box whose huge shadow dims the page around the hole. |
|---|---|---|
| card | .eq-tour | A manual popover with role="dialog", placed beside the target. data-side says where. |
| header | .eq-tour-header | |
| label | .eq-tour-label | The tour's name. |
| close | .eq-tour-close | A ghost Button named End tour. |
| content | .eq-tour-content | A polite live region, so each new step is read out. |
| title | .eq-tour-title | |
| body | .eq-tour-body | |
| footer | .eq-tour-footer | |
| count | .eq-tour-count | "2 of 4", counting only steps whose target exists. |
| back | .eq-tour-back |
Keyboard
| ArrowRight | With focus in the card, goes to the next step (the previous one in right-to-left text). |
|---|---|
| ArrowLeft | With focus in the card, goes to the previous step (the next one in right-to-left text). |
| EnterSpace | On Back, Next, Done or the close button, does what it says. |
| Escape | From anywhere on the page, ends the tour. Focus returns to where it was before the tour if it was in the card. |
| TabShift+Tab | Moves through the card's controls and on into the page, which stays usable. |
Notes
- The page is deliberately not inert while the tour is open, so people can try the thing a step points at; the cost is that focus can leave the card, so Escape works from anywhere and the card is not a focus trap.
- Focus moves into the card when the tour opens, and back to the element that had it when the tour ends from inside the card.
- Steps whose target is not on the page are skipped and left out of the count; if none exist the tour does not open.
- The target is scrolled into view, and the hole and card follow it through scrolling and resizing without lag; only a step change glides.
- The dim never takes a click, so a click outside the card reaches the page instead of closing the tour.
- Smooth scrolling reads --eq-dur-move and becomes instant when it is 0, which it is under reduced motion.
- The exit fade needs transition of overlay, which only Chromium has; elsewhere the tour disappears at once. Everything else uses the Popover API, which is in every current engine.
Source
Two files, and they are the whole part. Open either to read it, copy it straight from its bar, or add the part to your loadout and install it with everything else you picked.
components/equip/tour.tsx323 lines
"use client";
import {
useEffect,
useId,
useRef,
useState,
type ComponentProps,
type KeyboardEvent,
type ReactNode,
} from "react";
import { Button } from "./button";
import "./tour.css";
export type TourStep = {
/** CSS selector for the element to point at. Steps whose target is missing are skipped. */
target: string;
title: string;
body: ReactNode;
/** Preferred side of the target for the card. It flips when there is no room. */
placement?: "top" | "bottom" | "start" | "end";
};
export type TourProps = Omit<ComponentProps<"div">, "children" | "title"> & {
steps: TourStep[];
/** Names the tour, e.g. "Product tour". Shown above each step's title. */
label: string;
open?: boolean;
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
step?: number;
defaultStep?: number;
onStepChange?: (step: number) => void;
/** Called when Done is pressed on the last step, before the tour closes. */
onFinish?: () => void;
};
type Side = "top" | "bottom" | "left" | "right";
const HOLE_PAD = 6;
const GAP = 12;
const EDGE = 8;
function findTarget(steps: TourStep[], index: number): Element | null {
const step = steps[index];
if (!step) return null;
try {
return document.querySelector(step.target);
} catch {
return null;
}
}
function motionIsOff(element: Element) {
return getComputedStyle(element).getPropertyValue("--eq-dur-move").trim().startsWith("0");
}
/**
* A guided walkthrough: a spotlight around one element at a time and a card
* beside it. Both live in the top layer as manual popovers; the page stays
* usable underneath.
*/
export function Tour({
steps,
label,
open: openProp,
defaultOpen = false,
onOpenChange,
step: stepProp,
defaultStep = 0,
onStepChange,
onFinish,
className,
onKeyDown,
...rest
}: TourProps) {
const ids = useId();
const cardRef = useRef<HTMLDivElement>(null);
const holeRef = useRef<HTMLDivElement>(null);
const returnFocus = useRef<HTMLElement | null>(null);
const indexRef = useRef(0);
const [ownOpen, setOwnOpen] = useState(defaultOpen);
const [ownStep, setOwnStep] = useState(defaultStep);
const [present, setPresent] = useState<boolean[] | null>(null);
const open = openProp ?? ownOpen;
const index = stepProp ?? ownStep;
indexRef.current = index;
function setOpen(next: boolean) {
if (openProp === undefined) setOwnOpen(next);
onOpenChange?.(next);
}
function setIndex(next: number) {
if (stepProp === undefined) setOwnStep(next);
onStepChange?.(next);
}
function scan() {
const found = steps.map((_, i) => findTarget(steps, i) !== null);
setPresent(found);
return found;
}
function place(target: Element, glide: boolean) {
const card = cardRef.current;
const hole = holeRef.current;
if (!card || !hole) return;
if (!glide) {
card.setAttribute("data-instant", "");
hole.setAttribute("data-instant", "");
}
const r = target.getBoundingClientRect();
hole.style.left = `${r.left - HOLE_PAD}px`;
hole.style.top = `${r.top - HOLE_PAD}px`;
hole.style.width = `${r.width + HOLE_PAD * 2}px`;
hole.style.height = `${r.height + HOLE_PAD * 2}px`;
const vw = document.documentElement.clientWidth;
const vh = window.innerHeight;
const cw = card.offsetWidth;
const ch = card.offsetHeight;
const rtl = getComputedStyle(card).direction === "rtl";
const wanted = steps[indexRef.current]?.placement ?? "bottom";
const side: Side =
wanted === "start" ? (rtl ? "right" : "left") : wanted === "end" ? (rtl ? "left" : "right") : wanted;
const away = HOLE_PAD + GAP;
const fits: Record<Side, boolean> = {
bottom: r.bottom + away + ch <= vh - EDGE,
top: r.top - away - ch >= EDGE,
right: r.right + away + cw <= vw - EDGE,
left: r.left - away - cw >= EDGE,
};
const opposite: Record<Side, Side> = { top: "bottom", bottom: "top", left: "right", right: "left" };
const order: Side[] = [side, opposite[side], "bottom", "top", "right", "left"];
const chosen = order.find((s) => fits[s]) ?? side;
let x = r.left + r.width / 2 - cw / 2;
let y = r.top + r.height / 2 - ch / 2;
if (chosen === "bottom") y = r.bottom + away;
if (chosen === "top") y = r.top - away - ch;
if (chosen === "right") x = r.right + away;
if (chosen === "left") x = r.left - away - cw;
x = Math.min(Math.max(x, EDGE), Math.max(EDGE, vw - cw - EDGE));
y = Math.min(Math.max(y, EDGE), Math.max(EDGE, vh - ch - EDGE));
card.style.left = `${x}px`;
card.style.top = `${y}px`;
card.setAttribute("data-side", chosen);
if (!glide) {
// Commit the jump before the glide comes back on.
card.getBoundingClientRect();
card.removeAttribute("data-instant");
hole.removeAttribute("data-instant");
}
}
// Opening and closing: the popovers, the first step that exists, and focus.
useEffect(() => {
const card = cardRef.current;
const hole = holeRef.current;
if (!card || !hole) return;
if (!open) {
if (!card.matches(":popover-open")) return;
const focusInside = card.contains(document.activeElement) || document.activeElement === document.body;
card.hidePopover();
hole.hidePopover();
if (focusInside && returnFocus.current?.isConnected) returnFocus.current.focus();
return;
}
const found = scan();
let first = -1;
for (let i = index; i < steps.length && first < 0; i++) if (found[i]) first = i;
for (let i = index - 1; i >= 0 && first < 0; i--) if (found[i]) first = i;
if (first < 0) {
setOpen(false);
return;
}
if (first !== index) setIndex(first);
returnFocus.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
hole.showPopover();
card.showPopover();
indexRef.current = first;
const target = findTarget(steps, first);
if (target) place(target, false);
card.focus();
}, [open]);
// Each step: bring the target into view and glide the hole and the card to it.
useEffect(() => {
const card = cardRef.current;
if (!open || !card?.matches(":popover-open")) return;
const target = findTarget(steps, index);
if (!target) return;
const r = target.getBoundingClientRect();
if (r.top < 0 || r.bottom > window.innerHeight || r.left < 0 || r.right > window.innerWidth) {
target.scrollIntoView({ block: "center", inline: "nearest", behavior: motionIsOff(card) ? "instant" : "smooth" });
}
place(target, true);
}, [open, index]);
// While open: follow the target through scrolling and resizing, and close on Escape from anywhere.
useEffect(() => {
if (!open) return;
let frame = 0;
function follow() {
cancelAnimationFrame(frame);
frame = requestAnimationFrame(() => {
const target = findTarget(steps, indexRef.current);
if (target) place(target, false);
});
}
function escape(event: globalThis.KeyboardEvent) {
if (event.key !== "Escape" || event.defaultPrevented) return;
event.preventDefault();
setOpen(false);
}
window.addEventListener("scroll", follow, { capture: true, passive: true });
window.addEventListener("resize", follow);
document.addEventListener("keydown", escape);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("scroll", follow, { capture: true });
window.removeEventListener("resize", follow);
document.removeEventListener("keydown", escape);
};
}, [open, steps]);
const found = present ?? steps.map(() => true);
const shown = found.map((f, i) => (f ? i : -1)).filter((i) => i >= 0);
const position = shown.indexOf(index);
const prev = position > 0 ? shown[position - 1] : -1;
const next = position >= 0 && position < shown.length - 1 ? shown[position + 1] : -1;
const current = steps[index];
function go(direction: 1 | -1) {
const now = scan();
for (let i = index + direction; i >= 0 && i < steps.length; i += direction) {
if (now[i]) {
setIndex(i);
return;
}
}
if (direction === 1) finish();
}
function finish() {
onFinish?.();
setOpen(false);
}
function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
onKeyDown?.(event);
if (event.defaultPrevented) return;
if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return;
const rtl = getComputedStyle(event.currentTarget).direction === "rtl";
const forward = (event.key === "ArrowRight") !== rtl;
event.preventDefault();
event.stopPropagation();
if (forward ? next >= 0 : prev >= 0) go(forward ? 1 : -1);
}
return (
<>
<div ref={holeRef} popover="manual" className="eq-tour-spotlight" aria-hidden="true" />
<div
role="dialog"
aria-labelledby={`${ids}-label ${ids}-title`}
aria-describedby={`${ids}-body`}
tabIndex={-1}
{...rest}
ref={cardRef}
popover="manual"
className={["eq-tour", className].filter(Boolean).join(" ")}
onKeyDown={handleKeyDown}
>
<div className="eq-tour-header">
<p id={`${ids}-label`} className="eq-tour-label">
{label}
</p>
<Button
variant="ghost"
size="s"
className="eq-tour-close"
aria-label="End tour"
onClick={() => setOpen(false)}
lead={
<svg viewBox="0 0 16 16" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" aria-hidden="true">
<path d="M4 4l8 8M12 4l-8 8" />
</svg>
}
/>
</div>
<div className="eq-tour-content" aria-live="polite">
<h2 id={`${ids}-title`} className="eq-tour-title">
{current?.title}
</h2>
<div id={`${ids}-body`} className="eq-tour-body">
{current?.body}
</div>
</div>
<div className="eq-tour-footer">
<span className="eq-tour-count">
{Math.max(position, 0) + 1} of {shown.length}
</span>
<Button
variant="outline"
size="s"
className="eq-tour-back"
aria-disabled={prev < 0 || undefined}
onClick={() => prev >= 0 && go(-1)}
>
Back
</Button>
<Button size="s" onClick={() => (next >= 0 ? go(1) : finish())}>
{next >= 0 ? "Next" : "Done"}
</Button>
</div>
</div>
</>
);
}components/equip/tour.css198 lines
@layer equip {
/* The dim with a hole: one rounded box whose shadow covers the rest of the viewport.
It never takes a click, so the page underneath stays usable. */
.eq-tour-spotlight {
position: fixed;
inset: auto;
width: 0;
height: 0;
margin: 0;
padding: 0;
overflow: visible;
border: 0;
border-radius: var(--eq-radius-m);
background: transparent;
box-shadow: 0 0 0 200vmax var(--eq-backdrop);
pointer-events: none;
opacity: 0;
/* Position comes last so data-instant can drop it from the list. */
transition:
opacity var(--eq-dur) var(--eq-ease),
display var(--eq-dur) allow-discrete,
overlay var(--eq-dur) allow-discrete,
left var(--eq-dur-move) var(--eq-spring),
top var(--eq-dur-move) var(--eq-spring),
width var(--eq-dur-move) var(--eq-spring),
height var(--eq-dur-move) var(--eq-spring);
}
.eq-tour-spotlight:popover-open {
opacity: 1;
}
@starting-style {
.eq-tour-spotlight:popover-open {
opacity: 0;
}
}
/* Scroll and resize: the hole and the card follow the target exactly instead of gliding. */
.eq-tour-spotlight[data-instant] {
transition-property: opacity, display, overlay;
}
.eq-tour {
--_from: 0 -4px;
position: fixed;
inset: auto;
box-sizing: border-box;
display: none;
flex-direction: column;
gap: var(--eq-space-2);
width: min(var(--eq-tour-width, 18rem), calc(100vw - 2 * var(--eq-space-2)));
margin: 0;
padding: var(--eq-space-4);
overflow: visible;
border: 1px solid var(--eq-line-strong);
border-radius: var(--eq-radius-l);
background: var(--eq-raised);
color: var(--eq-ink);
box-shadow: var(--eq-shadow);
-webkit-backdrop-filter: var(--eq-blur);
backdrop-filter: var(--eq-blur);
font: var(--eq-weight) var(--eq-text-m) / 1.5 var(--eq-font);
opacity: 0;
scale: 0.96;
translate: var(--_from);
/* Leaving: quick. Position comes last so data-instant can drop it from the list. */
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur) var(--eq-ease),
translate var(--eq-dur) var(--eq-ease),
display var(--eq-dur) allow-discrete,
overlay var(--eq-dur) allow-discrete,
left var(--eq-dur-move) var(--eq-spring),
top var(--eq-dur-move) var(--eq-spring);
}
.eq-tour[data-side="top"] {
--_from: 0 4px;
}
.eq-tour[data-side="right"] {
--_from: -4px 0;
}
.eq-tour[data-side="left"] {
--_from: 4px 0;
}
.eq-tour:popover-open {
display: flex;
opacity: 1;
scale: 1;
translate: none;
transition:
opacity var(--eq-dur) var(--eq-ease),
scale var(--eq-dur-move) var(--eq-spring),
translate var(--eq-dur-move) var(--eq-spring),
display var(--eq-dur) allow-discrete,
overlay var(--eq-dur) allow-discrete,
left var(--eq-dur-move) var(--eq-spring),
top var(--eq-dur-move) var(--eq-spring);
}
@starting-style {
.eq-tour:popover-open {
opacity: 0;
scale: 0.96;
translate: var(--_from);
}
}
.eq-tour[data-instant] {
transition-property: opacity, scale, translate, display, overlay;
}
/* The card takes focus as a container; its controls carry the ring. */
.eq-tour:focus-visible {
outline: none;
}
.eq-tour-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--eq-space-2);
margin-block-start: calc(-1 * var(--eq-space-1));
margin-inline-end: calc(-1 * var(--eq-space-2));
}
.eq-tour-label {
margin: 0;
color: var(--eq-ink-2);
font-size: var(--eq-text-xs);
font-weight: var(--eq-weight-strong);
}
.eq-tour-close {
flex: none;
}
.eq-tour-content {
display: flex;
flex-direction: column;
gap: var(--eq-space-1);
}
.eq-tour-title {
margin: 0;
font-size: var(--eq-text-l);
font-weight: var(--eq-weight-strong);
line-height: 1.3;
letter-spacing: -0.01em;
}
.eq-tour-body {
color: var(--eq-ink-2);
}
.eq-tour-body > :first-child {
margin-top: 0;
}
.eq-tour-body > :last-child {
margin-bottom: 0;
}
.eq-tour-footer {
display: flex;
align-items: center;
gap: var(--eq-space-2);
margin-top: var(--eq-space-2);
}
.eq-tour-count {
margin-inline-end: auto;
color: var(--eq-ink-2);
font-size: var(--eq-text-s);
font-variant-numeric: tabular-nums;
}
/* aria-disabled rather than disabled, so focus stays on Back at the first step. */
.eq-tour .eq-tour-back[aria-disabled="true"] {
opacity: 0.45;
cursor: not-allowed;
}
.eq-tour .eq-tour-back[aria-disabled="true"]:active {
scale: none;
}
@media (hover: hover) {
.eq-tour .eq-tour-footer .eq-tour-back[aria-disabled="true"]:hover {
background: transparent;
}
}
}