All patterns

Tour Step Hop

The tour tooltip travels to the next control instead of vanishing and popping up somewhere else.

onboardingfriendlypremiuminteraction · finite · advanced · ~0.5s
Interactive · click to play
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.

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

Product tourFirst-run walkthroughFeature educationGuided setup

Where it shows up

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

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

    A single tour card that repositions itself around the canvas between steps.

Related patterns