All patterns

Feature Spotlight

The surrounding UI dims, a soft ring settles around one control, and a tooltip fades in beside it.

onboardingfriendlyelegantautomatic · finite · intermediate · ~0.9s
Variant

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.

432 lines · react + motion only
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.

Product tourNew feature calloutFirst-run tipDiscovery hint

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Set up your workspaceStep 2 of 4
    What should we call it?
    Ridgeline
    Who else is joining?
    3 invited
    Next
    Onboarding flow

    Tips that quiet their surroundings and point at a single control.

Related patterns