Skip Tour Dismiss
Leaving the tour early resolves it in order: the tip drops away, the ring opens and dissolves, the dim lifts last.
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 { useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Skip Tour Dismiss
*
* Leaving a tour early, without slamming a door. The tip drops away
* first, the ring opens and dissolves, the dim lifts last, and the
* control that was raised above it settles flat back into the page.
*
* Self-contained: depends only on `react` and `motion`. The tip 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`, `title`, `body`, `accent`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type SkipTourDismissProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
title?: string;
body?: string;
skipLabel?: string;
nextLabel?: string;
/** Stage size. */
width?: number;
height?: number;
/** Ring and primary button color. */
accent?: string;
/** Fires when the tour is left early. */
onSkip?: () => void;
/** Fires when the tour is continued instead. */
onNext?: () => void;
};
type VariantConfig = {
/** Seconds the dim takes to lift — the last thing to go. */
scrimSeconds: number;
/** Seconds the tip takes to drop away. */
tipSeconds: number;
/** px the tip drops as it leaves. */
tipDrop: number;
/** Scale the ring opens to before it dissolves. A shape, so it may. */
ringTo: number;
ringSeconds: number;
/** px the raised control settles back down. */
settle: number;
};
// Quality rule: this is the calm exit, so every curve is an ease-out and
// nothing springs. The order carries the meaning — tip, then ring, then
// dim — and variants change only the pace and the distance.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Barely a gesture. For a tour that can be left at any time.
subtle: {
scrimSeconds: 0.22,
tipSeconds: 0.13,
tipDrop: 3,
ringTo: 1.03,
ringSeconds: 0.2,
settle: 1,
},
// Enough separation to read as three things resolving. All-purpose.
default: {
scrimSeconds: 0.3,
tipSeconds: 0.16,
tipDrop: 5,
ringTo: 1.06,
ringSeconds: 0.28,
settle: 2,
},
// A slower release, for a tour that covered the whole screen.
playful: {
scrimSeconds: 0.38,
tipSeconds: 0.2,
tipDrop: 7,
ringTo: 1.1,
ringSeconds: 0.34,
settle: 3,
},
};
/** The control the tour is pointing at, in stage coordinates. */
const TARGET = { x: 226, y: 16, width: 78, height: 30, radius: 9 };
const TIP_WIDTH = 196;
/** 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 SkipTourDismiss({
variant = "default",
title = "Share what you see",
body = "Send a live link. Permissions stay where you set them.",
skipLabel = "Skip tour",
nextLabel = "Next",
width = 320,
height = 232,
accent = "#5B5BD6",
onSkip,
onNext,
}: SkipTourDismissProps) {
const [running, setRunning] = useState(true);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const tipLeft = Math.min(
Math.max(TARGET.x + TARGET.width / 2 - TIP_WIDTH / 2, 12),
width - TIP_WIDTH - 12
);
const tipTop = TARGET.y + TARGET.height + 12;
const skip = () => {
setRunning(false);
onSkip?.();
};
return (
<div
style={{
position: "relative",
width,
height,
borderRadius: 16,
border: `1px solid ${tone(12)}`,
background: tone(5),
overflow: "hidden",
}}
>
<SampleSurface />
<AnimatePresence>
{running && (
<motion.div
key="dim"
aria-hidden
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
// The dim is the last thing to go: the page coming back is
// the reassurance, so it is given the longest curve.
exit={{
opacity: 0,
transition: { duration: cfg.scrimSeconds, ease: "easeOut" },
}}
transition={{ duration: 0.26, ease: "easeOut" }}
style={{
position: "absolute",
left: TARGET.x - 5,
top: TARGET.y - 5,
width: TARGET.width + 10,
height: TARGET.height + 10,
borderRadius: TARGET.radius + 5,
// One element is the whole dim: a huge shadow spread
// darkens everything outside this box while the box itself
// stays clear. Only opacity animates, so it never touches
// layout.
boxShadow: "0 0 0 9999px rgba(8, 8, 12, 0.5)",
pointerEvents: "none",
}}
/>
)}
</AnimatePresence>
{/* The control lives above the dim, which is what makes the hole
read as the page showing through rather than a bright rectangle
painted on top. */}
<motion.div
initial={false}
animate={{
y: running && !reduceMotion ? -cfg.settle : 0,
boxShadow: running
? "0 8px 20px rgba(8,8,12,0.30)"
: "0 0px 0px rgba(8,8,12,0)",
}}
transition={{ duration: 0.28, ease: "easeOut" }}
style={{
position: "absolute",
left: TARGET.x,
top: TARGET.y,
width: TARGET.width,
height: TARGET.height,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 5,
borderRadius: TARGET.radius,
background: accent,
color: "#ffffff",
fontSize: 12,
fontWeight: 650,
}}
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden>
<path
d="M6 8V1.8M6 1.8 3.7 4.1M6 1.8l2.3 2.3M2 7.6v1.6a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V7.6"
stroke="#ffffff"
strokeWidth="1.3"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
Share
</motion.div>
<AnimatePresence>
{running && (
<motion.div
key="ring"
aria-hidden
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 1.04 }}
animate={{ opacity: 1, scale: 1 }}
// The ring opens outward as it goes, so the dismissal reads
// as release rather than retraction.
exit={{
opacity: 0,
scale: reduceMotion ? 1 : cfg.ringTo,
transition: { duration: cfg.ringSeconds, ease: "easeOut" },
}}
transition={{ duration: 0.24, ease: "easeOut" }}
style={{
position: "absolute",
left: TARGET.x - 5,
top: TARGET.y - 5,
width: TARGET.width + 10,
height: TARGET.height + 10,
boxSizing: "border-box",
borderRadius: TARGET.radius + 5,
border: `2px solid ${accent}`,
pointerEvents: "none",
}}
/>
)}
</AnimatePresence>
<AnimatePresence>
{running && (
<motion.div
key="tip"
role="dialog"
aria-label={title}
initial={reduceMotion ? { opacity: 0 } : { opacity: 0, y: -cfg.tipDrop }}
animate={{ opacity: 1, y: 0 }}
// First out: the words stop applying the moment the tour is
// left, so they should not linger over the returning page.
exit={{
opacity: 0,
y: reduceMotion ? 0 : cfg.tipDrop,
transition: { duration: cfg.tipSeconds, ease: "easeIn" },
}}
transition={{ duration: 0.26, delay: 0.06, ease: "easeOut" }}
style={{
position: "absolute",
left: tipLeft,
top: tipTop,
width: TIP_WIDTH,
boxSizing: "border-box",
padding: 13,
borderRadius: 12,
// Above a scrim, so it cannot be translucent.
// `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 14px 34px rgba(0,0,0,0.22)",
}}
>
<div style={{ fontSize: 13, fontWeight: 650 }}>{title}</div>
<p style={{ margin: "5px 0 0", fontSize: 11.5, lineHeight: 1.5, opacity: 0.66 }}>
{body}
</p>
<div
style={{
display: "flex",
alignItems: "center",
gap: 8,
marginTop: 12,
}}
>
<button
type="button"
onClick={skip}
style={{
padding: "6px 10px",
fontSize: 11.5,
fontWeight: 600,
fontFamily: "inherit",
color: "inherit",
background: "transparent",
border: "none",
borderRadius: 8,
opacity: 0.55,
cursor: "pointer",
}}
>
{skipLabel}
</button>
<button
type="button"
onClick={onNext}
style={{
marginLeft: "auto",
padding: "6px 12px",
fontSize: 11.5,
fontWeight: 650,
fontFamily: "inherit",
color: "#ffffff",
background: accent,
border: "none",
borderRadius: 8,
cursor: "pointer",
}}
>
{nextLabel}
</button>
</div>
</motion.div>
)}
</AnimatePresence>
{/* Sample-only: a real app would remember the choice. This keeps
the dismissal watchable more than once. */}
<AnimatePresence>
{!running && (
<motion.div
key="left"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, transition: { duration: 0.12 } }}
transition={{ duration: 0.26, delay: cfg.scrimSeconds, ease: "easeOut" }}
style={{
position: "absolute",
left: 0,
right: 0,
bottom: 14,
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 9,
fontSize: 11.5,
}}
>
<span style={{ opacity: 0.5 }}>Tour skipped</span>
<button
type="button"
onClick={() => setRunning(true)}
style={{
padding: "5px 10px",
fontSize: 11.5,
fontWeight: 600,
fontFamily: "inherit",
color: "inherit",
background: tone(8),
border: `1px solid ${tone(14)}`,
borderRadius: 8,
cursor: "pointer",
}}
>
Restart tour
</button>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
/** Stand-in product surface — the page the tour was covering. */
function SampleSurface() {
const bars = [58, 34, 72, 46, 64];
return (
<div aria-hidden style={{ position: "absolute", inset: 0 }}>
<div
style={{
position: "absolute",
left: 16,
top: 18,
fontSize: 14,
fontWeight: 680,
}}
>
Weekly report
</div>
<div
style={{
position: "absolute",
left: 16,
top: 62,
right: 16,
height: 150,
borderRadius: 13,
border: `1px solid ${tone(11)}`,
background: tone(4),
}}
/>
<div
style={{
position: "absolute",
left: 32,
top: 80,
width: 92,
height: 8,
borderRadius: 4,
background: tone(18),
}}
/>
<div
style={{
position: "absolute",
left: 32,
top: 96,
width: 58,
height: 6,
borderRadius: 3,
background: tone(12),
}}
/>
{bars.map((barHeight, index) => (
<div
key={index}
style={{
position: "absolute",
left: 32 + index * 52,
top: 192 - barHeight,
width: 30,
height: barHeight,
borderRadius: 7,
background: tone(13),
}}
/>
))}
</div>
);
}About this pattern
Most tours are dismissed, so the exit deserves as much care as the entrance. Three things leave in a deliberate order rather than at once: the tip goes first because its words stop applying the moment the tour is left, the ring opens outward as it dissolves so the dismissal reads as release rather than retraction, and the dim is given the longest curve because the page coming back is the reassurance. The control that had been raised above the overlay settles flat at the same time, which is what returns it to the page instead of leaving it stranded. Everything is an ease-out; nothing springs, because a bouncy exit argues with a decision already made.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Onboarding flow
Guidance that releases the highlighted control gently rather than cutting away.
Related patterns
- Onboarding Step TransitionThe next panel enters from the right as the current one leaves left, with the progress bar advancing behind them.
- First Action NudgeAfter a pause with nothing pressed, a slow halo starts breathing out of the one button worth pressing.
- Notification Opt-inAn example alert drops in and two more fan out behind it, then the permission question reads underneath.