Feature Spotlight
The surrounding UI dims, a soft ring settles around one control, and a tooltip fades in beside it.
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 type { ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
/**
* Vibary · Feature Spotlight
*
* The surrounding UI dims, a soft ring settles around one control, and
* a tooltip fades in beside it.
*
* Self-contained: depends only on `react` and `motion`. Works with zero
* props; tune via `variant`, `title`, `body`, `spotlight`, `accent`.
* A sample surface is embedded so the file runs as-is — pass `children`
* to spotlight your own UI, together with a matching `spotlight` rect.
* Requires the automatic JSX runtime (default since React 17).
*/
export type SpotlightRect = {
/** Position and size of the highlighted element, relative to the stage. */
x: number;
y: number;
width: number;
height: number;
radius?: number;
};
export type FeatureSpotlightProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Which element to highlight. Defaults to the sample Share control. */
spotlight?: SpotlightRect;
title?: string;
body?: string;
/** Dismiss button label. */
dismissLabel?: string;
/** Your own UI behind the overlay. */
children?: ReactNode;
/** Stage size. The tooltip is placed against these bounds. */
width?: number;
height?: number;
/** Ring and tooltip accent color. */
accent?: string;
/** Fires when the tip is dismissed. */
onDismiss?: () => void;
};
type VariantConfig = {
/** Opacity of the surrounding dim. */
dim: number;
dimDuration: number;
/** Ring scale before it settles onto the control. */
ringFrom: number;
ringDelay: number;
ringSpring: { type: "spring"; stiffness: number; damping: number };
/** Second, wider ring — off for the quietest variant. */
halo: boolean;
tooltipDelay: number;
tooltipRise: number;
};
// Quality rule: the ring settles onto the control, it does not pounce.
// Every spring sits at or above a 0.8 damping ratio, and the scale it
// travels stays inside 15% — a ring that snaps in from double size
// reads as an ad, not as guidance.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// Light dim, single ring, quick tip. For tips that fire often.
subtle: {
dim: 0.5,
dimDuration: 0.24,
ringFrom: 1.03,
ringDelay: 0.06,
ringSpring: { type: "spring", stiffness: 550, damping: 47 },
halo: false,
tooltipDelay: 0.16,
tooltipRise: 5,
},
// Ring plus a soft halo, staged so the eye lands before it reads.
// The all-purpose setting.
default: {
dim: 0.58,
dimDuration: 0.3,
ringFrom: 1.09,
ringDelay: 0.12,
ringSpring: { type: "spring", stiffness: 460, damping: 40 },
halo: true,
tooltipDelay: 0.24,
tooltipRise: 6,
},
// Deeper dim, wider approach, longer stagger — for the one feature a
// release is actually about.
playful: {
dim: 0.62,
dimDuration: 0.36,
ringFrom: 1.16,
ringDelay: 0.15,
ringSpring: { type: "spring", stiffness: 380, damping: 32 },
halo: true,
tooltipDelay: 0.32,
tooltipRise: 9,
},
};
/** Matches the Share control in the embedded sample surface. */
const SAMPLE_TARGET: SpotlightRect = {
x: 232,
y: 14,
width: 72,
height: 30,
radius: 9,
};
/** Hairline for the tip and its arrow, mixed from the text color in scope
* so it reads on a light card and on a dark one. */
const TIP_EDGE = "1px solid color-mix(in srgb, currentColor 14%, transparent)";
const TOOLTIP_WIDTH = 200;
/** Rough, on purpose: picking a side needs an estimate, and measuring
* the real height would cost a layout pass and a frame of jitter. */
const TOOLTIP_HEIGHT = 104;
export default function FeatureSpotlight({
variant = "default",
spotlight = SAMPLE_TARGET,
title = "Share with your team",
body = "Send a live link to anyone. Permissions stay exactly where you set them.",
dismissLabel = "Got it",
children,
width = 320,
height = 216,
accent = "#5B5BD6",
onDismiss,
}: FeatureSpotlightProps) {
const [visible, setVisible] = useState(true);
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const radius = spotlight.radius ?? 10;
const tooltipWidth = Math.min(TOOLTIP_WIDTH, width - 24);
const centerX = spotlight.x + spotlight.width / 2;
const tooltipLeft = Math.min(
Math.max(centerX - tooltipWidth / 2, 12),
width - tooltipWidth - 12
);
// Below the control unless it would run off the stage.
const below = spotlight.y + spotlight.height + TOOLTIP_HEIGHT + 12 <= height;
const arrowLeft = Math.min(
Math.max(centerX - tooltipLeft - 5, 14),
tooltipWidth - 24
);
const dismiss = () => {
setVisible(false);
onDismiss?.();
};
return (
<div
style={{
position: "relative",
width,
height,
borderRadius: 16,
border: "1px solid rgba(127,127,140,0.22)",
background: "rgba(127,127,140,0.07)",
// Clips the oversized shadow that produces the dim.
overflow: "hidden",
}}
>
{children ?? <SampleSurface accent={accent} />}
<AnimatePresence>
{visible && (
<motion.div
key="spotlight"
exit={{ opacity: 0 }}
transition={{ duration: 0.18, ease: "easeOut" }}
style={{ position: "absolute", inset: 0, pointerEvents: "none" }}
>
{/* The dim is one element sitting on the control with a huge
shadow spread: everything outside the rect darkens, the
rect itself stays clear. Only its opacity animates, so
the dim never touches layout. */}
<motion.div
aria-hidden
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: cfg.dimDuration, ease: "easeOut" }}
style={{
position: "absolute",
left: spotlight.x,
top: spotlight.y,
width: spotlight.width,
height: spotlight.height,
borderRadius: radius,
boxShadow: `0 0 0 9999px rgba(8, 8, 12, ${cfg.dim})`,
}}
/>
{cfg.halo && (
<motion.div
aria-hidden
initial={
reduceMotion
? { opacity: 0 }
: { opacity: 0, scale: cfg.ringFrom + 0.06 }
}
animate={{ opacity: 0.35, scale: 1 }}
transition={
reduceMotion
? { duration: 0.2, ease: "easeOut" }
: { ...cfg.ringSpring, delay: cfg.ringDelay + 0.05 }
}
style={{
position: "absolute",
left: spotlight.x - 12,
top: spotlight.y - 12,
width: spotlight.width + 24,
height: spotlight.height + 24,
borderRadius: radius + 12,
border: `1px solid ${accent}`,
}}
/>
)}
<motion.div
aria-hidden
initial={
reduceMotion
? { opacity: 0 }
: { opacity: 0, scale: cfg.ringFrom }
}
animate={{ opacity: 1, scale: 1 }}
transition={
reduceMotion
? { duration: 0.2, ease: "easeOut" }
: { ...cfg.ringSpring, delay: cfg.ringDelay }
}
style={{
position: "absolute",
left: spotlight.x - 6,
top: spotlight.y - 6,
width: spotlight.width + 12,
height: spotlight.height + 12,
borderRadius: radius + 6,
border: `2px solid ${accent}`,
}}
/>
<motion.div
role="status"
initial={
reduceMotion
? { opacity: 0 }
: // Starts on the control side and settles away from
// it, so the tip reads as coming out of the thing it
// describes.
{ opacity: 0, y: below ? -cfg.tooltipRise : cfg.tooltipRise }
}
animate={{ opacity: 1, y: 0 }}
// A tween, not a spring: the card is all text and text
// must land flat.
transition={{
duration: reduceMotion ? 0.2 : 0.28,
delay: reduceMotion ? 0.1 : cfg.tooltipDelay,
ease: "easeOut",
}}
style={{
position: "absolute",
left: tooltipLeft,
top: below ? spotlight.y + spotlight.height + 12 : undefined,
bottom: below ? undefined : height - spotlight.y + 12,
width: tooltipWidth,
boxSizing: "border-box",
padding: 14,
borderRadius: 12,
// The tip 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, so the tip lands light in
// a light app and dark in a dark one, always as a
// legible pair.
background: "Canvas",
color: "CanvasText",
border: TIP_EDGE,
boxShadow: "0 14px 34px rgba(0,0,0,0.22)",
pointerEvents: "auto",
}}
>
<span
aria-hidden
style={{
position: "absolute",
left: arrowLeft,
top: below ? -6 : undefined,
bottom: below ? undefined : -6,
width: 10,
height: 10,
background: "Canvas",
borderTop: below ? TIP_EDGE : "none",
borderLeft: below ? TIP_EDGE : "none",
borderBottom: below ? "none" : TIP_EDGE,
borderRight: below ? "none" : TIP_EDGE,
transform: "rotate(45deg)",
}}
/>
<div style={{ fontSize: 13.5, fontWeight: 650 }}>{title}</div>
<p
style={{
margin: "5px 0 0",
fontSize: 12,
lineHeight: 1.5,
opacity: 0.68,
}}
>
{body}
</p>
<button
type="button"
onClick={dismiss}
style={{
marginTop: 10,
padding: "6px 12px",
fontSize: 12,
fontWeight: 600,
fontFamily: "inherit",
color: "#ffffff",
background: accent,
border: "none",
borderRadius: 8,
cursor: "pointer",
}}
>
{dismissLabel}
</button>
</motion.div>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
/** Stand-in product surface, laid out to match SAMPLE_TARGET exactly. */
function SampleSurface({ accent }: { accent: string }) {
const chips = [
{ label: "Overview", x: 16, width: 82, active: false },
{ label: "Activity", x: 104, width: 74, active: false },
{ label: "Share", x: 232, width: 72, active: true },
];
const bars = [34, 52, 40, 64, 28];
return (
<div aria-hidden style={{ position: "absolute", inset: 0 }}>
{chips.map((chip) => (
<div
key={chip.label}
style={{
position: "absolute",
left: chip.x,
top: 14,
width: chip.width,
height: 30,
display: "flex",
alignItems: "center",
justifyContent: "center",
fontSize: 12,
fontWeight: 600,
borderRadius: 9,
border: `1px solid ${
chip.active ? accent : "rgba(127,127,140,0.24)"
}`,
background: chip.active
? "rgba(127,127,140,0.14)"
: "rgba(127,127,140,0.08)",
}}
>
{chip.label}
</div>
))}
<div
style={{
position: "absolute",
left: 16,
top: 58,
width: 288,
height: 142,
borderRadius: 12,
border: "1px solid rgba(127,127,140,0.18)",
background: "rgba(127,127,140,0.08)",
}}
/>
<div
style={{
position: "absolute",
left: 36,
top: 76,
width: 84,
height: 8,
borderRadius: 4,
background: "rgba(127,127,140,0.34)",
}}
/>
<div
style={{
position: "absolute",
left: 36,
top: 94,
width: 52,
height: 6,
borderRadius: 3,
background: "rgba(127,127,140,0.22)",
}}
/>
{bars.map((barHeight, index) => (
<div
key={index}
style={{
position: "absolute",
left: 36 + index * 40,
top: 180 - barHeight,
width: 26,
height: barHeight,
borderRadius: 6,
background: "rgba(127,127,140,0.26)",
}}
/>
))}
</div>
);
}About this pattern
The coach mark that introduces one thing at a time: a new control after a release, the next action in a product tour, the button a first-run user keeps missing. The dim arrives first so the eye has somewhere to go, the ring settles onto the control from just outside its bounds, and the tip follows a beat later — staged, because all three arriving at once gives the eye nothing to follow. The dim is painted as an oversized shadow spread around a clear rect, so the highlighted control stays visible, clickable and untouched by layout.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- Onboarding flow
Tips that quiet their surroundings and point at a single control.
Related patterns
- Tour Step HopThe tour tooltip travels to the next control instead of vanishing and popping up somewhere else.
- 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.