Tour Step Hop
The tour tooltip travels to the next control instead of vanishing and popping up somewhere else.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useId, useState } from "react";
import type { ReactNode } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Tour Step Hop
*
* The tour tooltip travels to the next control instead of disappearing
* and reappearing beside it. One tooltip, several positions — the
* continuity is what tells the user the tour is still the same object
* moving through their screen, not a series of unrelated popups.
*
* Self-contained: depends only on `react` and `motion`. The tooltip sits
* above a scrim, so it uses the CSS system colors `Canvas`/`CanvasText`
* and lands light in a light app and dark in a dark one.
* Works with zero props; tune via `variant`, `stops`, `accent`, and pass
* `children` to run the tour over your own UI.
* Requires the automatic JSX runtime (default since React 17).
*/
export type TourStop = {
/** Position and size of the control, relative to the stage. */
x: number;
y: number;
width: number;
height: number;
radius?: number;
title: string;
body: string;
};
export type TourStepHopProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Your own stops. The embedded sample is used when omitted. */
stops?: TourStop[];
/** Your own UI behind the scrim. */
children?: ReactNode;
/** Stage size. Tooltips are placed against these bounds. */
width?: number;
height?: number;
/** Ring and primary button color. */
accent?: string;
/** Fires with the index the tour just moved to. */
onStepChange?: (index: number) => void;
/** Fires when the last stop is confirmed. */
onFinish?: () => void;
};
type VariantConfig = {
/** The hop itself — shared by the tooltip, its arrow and the cutout. */
hopSpring: { type: "spring"; stiffness: number; damping: number };
/** Seconds the new copy takes to fade in behind the moving tooltip. */
copySeconds: number;
/** Seconds before the copy starts, so it lands as the tooltip does. */
copyDelay: number;
/** Opacity of the surrounding dim. */
dim: number;
};
// Quality rule: damping ratios (damping / 2√stiffness) stay at or above
// 0.8. A tooltip full of text that overshoots its target and rocks back
// is unreadable for the first third of a second it is there. Variants
// change the pace of the hop, never how much it settles.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// A short, business-like hop. For long tours.
subtle: {
hopSpring: { type: "spring", stiffness: 600, damping: 50 },
copySeconds: 0.12,
copyDelay: 0.05,
dim: 0.46,
},
// Slow enough to follow the tooltip with the eye. All-purpose.
default: {
hopSpring: { type: "spring", stiffness: 340, damping: 36 },
copySeconds: 0.2,
copyDelay: 0.09,
dim: 0.54,
},
// A longer arc across the screen, for a three-stop first-run tour.
playful: {
hopSpring: { type: "spring", stiffness: 180, damping: 26 },
copySeconds: 0.28,
copyDelay: 0.17,
dim: 0.58,
},
};
/** Matches the controls in the embedded sample surface. */
const SAMPLE_STOPS: TourStop[] = [
{
x: 14,
y: 14,
width: 132,
height: 28,
radius: 9,
title: "Find anything",
body: "Search projects, docs and people at once.",
},
{
x: 246,
y: 14,
width: 60,
height: 28,
radius: 9,
title: "Start something",
body: "Projects, docs and tasks all begin here.",
},
{
x: 14,
y: 58,
width: 292,
height: 34,
radius: 11,
title: "Your projects",
body: "Open one to see its tasks and activity.",
},
];
const TIP_WIDTH = 200;
/** Fixed on purpose. A shared-layout element that changes size scales
* its contents while it travels, and scaling text is never acceptable —
* so the tooltip keeps one size and the copy is written to fit it. */
const TIP_HEIGHT = 124;
/** Gap between the highlighted control and the tooltip. */
const TIP_OFFSET = 12;
/** How far the ring stands off the control it surrounds. */
const RING_INSET = 5;
/** Theme-adaptive neutral: mixing the text color in scope with
* `transparent` lands correctly on light and dark surfaces alike. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
const TIP_EDGE = "1px solid color-mix(in srgb, currentColor 14%, transparent)";
export default function TourStepHop({
variant = "default",
stops = SAMPLE_STOPS,
children,
width = 320,
height = 260,
accent = "#5B5BD6",
onStepChange,
onFinish,
}: TourStepHopProps) {
const [step, setStep] = useState(0);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
// Scoped per instance: a hard-coded layoutId would make two tours on
// the same page throw the tooltip back and forth across the document.
const uid = useId();
const tipId = `${uid}-tip`;
const arrowId = `${uid}-arrow`;
const stop = stops[step];
const isLast = step === stops.length - 1;
const tipLeft = Math.min(
Math.max(stop.x + stop.width / 2 - TIP_WIDTH / 2, 12),
width - TIP_WIDTH - 12
);
const tipTop = stop.y + stop.height + TIP_OFFSET;
const arrowLeft = Math.min(
Math.max(stop.x + stop.width / 2 - 5, tipLeft + 14),
tipLeft + TIP_WIDTH - 24
);
// The hop is the whole pattern, so every travelling piece shares one
// transition: tooltip, arrow and the ring around the control.
const hop = reduceMotion ? { duration: 0 } : cfg.hopSpring;
const go = (next: number) => {
setStep(next);
onStepChange?.(next);
};
const handleNext = () => {
if (isLast) {
onFinish?.();
// The sample loops so the hop stays watchable; in a real app this
// is where the tour ends.
go(0);
return;
}
go(step + 1);
};
return (
<div
style={{
position: "relative",
width,
height,
borderRadius: 16,
border: `1px solid ${tone(12)}`,
background: tone(5),
// Clips the oversized shadow that produces the dim.
overflow: "hidden",
}}
>
{children ?? <SampleSurface accent={accent} />}
{/* One element is the dim and the ring at once: a huge shadow
spread darkens everything outside its box, the box itself stays
clear. Its position and size animate, so the hole travels to
the next control and resizes to fit it. */}
<motion.div
aria-hidden
initial={{ opacity: 0 }}
animate={{
opacity: 1,
left: stop.x - RING_INSET,
top: stop.y - RING_INSET,
width: stop.width + RING_INSET * 2,
height: stop.height + RING_INSET * 2,
borderRadius: (stop.radius ?? 10) + RING_INSET,
}}
transition={{ default: hop, opacity: { duration: 0.3, ease: "easeOut" } }}
style={{
position: "absolute",
left: stop.x - RING_INSET,
top: stop.y - RING_INSET,
width: stop.width + RING_INSET * 2,
height: stop.height + RING_INSET * 2,
borderRadius: (stop.radius ?? 10) + RING_INSET,
boxSizing: "border-box",
border: `2px solid ${accent}`,
boxShadow: `0 0 0 9999px rgba(8, 8, 12, ${cfg.dim})`,
pointerEvents: "none",
}}
/>
{/* The wrapper is never keyed, so it fades the tip and its arrow in
once on mount and then stays out of the way — the elements
inside are free to be replaced on every step without the
arrival fade playing again. */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.28, delay: 0.06, ease: "easeOut" }}
style={{ position: "absolute", inset: 0, pointerEvents: "none" }}
>
{/* Keyed by step, so React unmounts one tooltip and mounts the
next — and because both carry the same layoutId, Motion matches
them and animates the new one from where the old one stood.
That is the whole trick: one object moving, not two popups. */}
<motion.div
key={`tip-${step}`}
layoutId={tipId}
role="dialog"
aria-label={stop.title}
transition={hop}
style={{
position: "absolute",
left: tipLeft,
top: tipTop,
width: TIP_WIDTH,
height: TIP_HEIGHT,
boxSizing: "border-box",
padding: 13,
borderRadius: 12,
display: "flex",
flexDirection: "column",
// The tooltip sits on top of the dim, so it cannot be
// translucent — a see-through card would just read as more
// dim. `Canvas`/`CanvasText` are the CSS system colors for page
// background and page text: they follow the host app's color
// scheme and always land as a legible pair.
background: "Canvas",
color: "CanvasText",
border: TIP_EDGE,
boxShadow: "0 16px 36px rgba(0,0,0,0.24)",
overflow: "hidden",
pointerEvents: "auto",
}}
>
{/* The copy is the only thing that changes between stops, so it
is the only thing that fades. It arrives just behind the
tooltip, which is what makes the box read as being refilled
rather than replaced. */}
<motion.div
key={`copy-${step}`}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{
duration: reduceMotion ? 0.14 : cfg.copySeconds,
delay: reduceMotion ? 0 : cfg.copyDelay,
ease: "easeOut",
}}
style={{ flex: 1 }}
>
<div style={{ fontSize: 13.5, fontWeight: 650 }}>{stop.title}</div>
<p style={{ margin: "5px 0 0", fontSize: 11.5, lineHeight: 1.5, opacity: 0.68 }}>
{stop.body}
</p>
</motion.div>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 8,
}}
>
<span style={{ fontSize: 11, fontWeight: 600, opacity: 0.45 }}>
{`${step + 1} of ${stops.length}`}
</span>
<div style={{ display: "flex", gap: 6 }}>
<button
type="button"
onClick={() => go(Math.max(0, step - 1))}
disabled={step === 0}
style={{
padding: "6px 10px",
fontSize: 11.5,
fontWeight: 600,
fontFamily: "inherit",
color: "inherit",
background: "transparent",
border: TIP_EDGE,
borderRadius: 8,
opacity: step === 0 ? 0.3 : 0.75,
cursor: step === 0 ? "default" : "pointer",
}}
>
Back
</button>
<button
type="button"
onClick={handleNext}
style={{
padding: "6px 12px",
fontSize: 11.5,
fontWeight: 650,
fontFamily: "inherit",
color: "#ffffff",
background: accent,
border: "none",
borderRadius: 8,
cursor: "pointer",
}}
>
{isLast ? "Done" : "Next"}
</button>
</div>
</div>
</motion.div>
{/* The arrow travels on its own layoutId: it has to slide along the
tooltip's edge as well as across the screen, because the
tooltip clamps to the stage while the control it points at does
not. */}
<motion.div
key={`arrow-${step}`}
layoutId={arrowId}
aria-hidden
transition={hop}
style={{
position: "absolute",
left: arrowLeft,
top: tipTop - 5,
width: 10,
height: 10,
pointerEvents: "none",
}}
>
<div
style={{
width: 10,
height: 10,
background: "Canvas",
borderTop: TIP_EDGE,
borderLeft: TIP_EDGE,
transform: "rotate(45deg)",
}}
/>
</motion.div>
</motion.div>
</div>
);
}
/** Stand-in product surface, laid out to match SAMPLE_STOPS exactly. */
function SampleSurface({ accent }: { accent: string }) {
const rows = [
{ name: "Draft the launch brief", meta: "Tue" },
{ name: "Collect reference links", meta: "Wed" },
{ name: "Review with the team", meta: "Fri" },
];
return (
<div aria-hidden style={{ position: "absolute", inset: 0 }}>
<div
style={{
position: "absolute",
left: 14,
top: 14,
width: 132,
height: 28,
display: "flex",
alignItems: "center",
gap: 7,
padding: "0 10px",
boxSizing: "border-box",
borderRadius: 9,
border: `1px solid ${tone(14)}`,
background: tone(6),
fontSize: 11.5,
opacity: 0.7,
}}
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<circle cx="5.2" cy="5.2" r="3.6" stroke="currentColor" strokeWidth="1.4" />
<path d="M8 8l2.4 2.4" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
</svg>
Search
</div>
<div
style={{
position: "absolute",
left: 246,
top: 14,
width: 60,
height: 28,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 4,
borderRadius: 9,
background: accent,
color: "#ffffff",
fontSize: 11.5,
fontWeight: 650,
}}
>
<svg width="10" height="10" viewBox="0 0 10 10" fill="none">
<path d="M5 1.4v7.2M1.4 5h7.2" stroke="#ffffff" strokeWidth="1.6" strokeLinecap="round" />
</svg>
New
</div>
<div
style={{
position: "absolute",
left: 14,
top: 58,
width: 292,
height: 34,
display: "flex",
alignItems: "center",
gap: 9,
padding: "0 11px",
boxSizing: "border-box",
borderRadius: 11,
border: `1px solid ${tone(12)}`,
background: tone(7),
}}
>
<span
style={{
width: 16,
height: 16,
borderRadius: 5,
background: `color-mix(in srgb, ${accent} 62%, transparent)`,
}}
/>
<span style={{ fontSize: 12, fontWeight: 600 }}>Launch plan</span>
<span style={{ marginLeft: "auto", fontSize: 11, opacity: 0.45 }}>8 tasks</span>
</div>
{rows.map((row, index) => (
<div
key={row.name}
style={{
position: "absolute",
left: 14,
top: 106 + index * 34,
width: 292,
height: 28,
display: "flex",
alignItems: "center",
gap: 9,
padding: "0 11px",
boxSizing: "border-box",
borderTop: `1px solid ${tone(8)}`,
fontSize: 11.5,
opacity: 0.55,
}}
>
<span
style={{
width: 12,
height: 12,
borderRadius: 999,
border: `1.4px solid ${tone(30)}`,
}}
/>
{row.name}
<span style={{ marginLeft: "auto", opacity: 0.7 }}>{row.meta}</span>
</div>
))}
</div>
);
}About this pattern
A product tour where the tip is one object moving through the screen rather than a series of unrelated popups. Each stop remounts the tooltip at new coordinates, but both instances carry the same layoutId, so Motion matches them and animates the new one from where the old one stood — the travel is the continuity, and it is the whole point. Its arrow rides a second shared element because the tooltip clamps to the stage while the control it points at does not. The cutout that dims the rest of the screen moves and resizes on the same spring, so the hole and the tip arrive together. The tooltip keeps one fixed size at every stop: a shared-layout box that changes size scales its own contents, and scaling text is never acceptable — only the copy inside crossfades.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Onboarding flow
A single tour card that repositions itself around the canvas between steps.
Related patterns
- Name Your WorkspaceTyping a name rolls the monogram to its new initial and slides a fresh address under the header it will appear in.
- Persona SelectPicking a role lifts that card while the others step back, and a line below says what the answer changed.
- Tutorial Card PlayThe tutorial thumbnail grows into a player, and the controls only arrive once the frame has stopped moving.