# Spotlight Tour

> A guided tour that dims the page and cuts a hole around one element at a time. The hole glides to each next element and takes its shape, with the explanation card riding along beside it.

An animated React component from KeyframeUI, installed as source with the shadcn CLI. Built with React, Tailwind CSS and Motion.

- Docs and live preview: https://keyframeui.com/components/spotlight-tour
- Tags: Onboarding, Overlay, Motion
- npm dependencies: motion, clsx, tailwind-merge

## Installation

Register the @keyframeui registry in `components.json` once per project:

```json
{
  "registries": {
    "@keyframeui": "https://keyframeui.com/r/{name}.json"
  }
}
```

Then add the component. It lands in `components/ui/spotlight-tour.tsx`, and the CLI also adds `lib/utils.ts` (the `cn` helper) and installs the npm dependencies:

```bash
npx shadcn@latest add @keyframeui/spotlight-tour
```

Or install by URL, without registering the namespace:

```bash
npx shadcn@latest add https://keyframeui.com/r/spotlight-tour.json
```

## Usage

The main demo shows typical usage.

### Product Onboarding

A first tour of a dashboard: the avatar, the toolbar, the New button and the sidebar. The hole goes from a circle to a long pill to a button to a tall panel. Clicking New moves the tour on.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/spotlight-tour-demo
```

```tsx title="components/spotlight-tour-demo.tsx"
"use client";

import * as React from "react";
import { AnimatePresence, motion } from "motion/react";

import { SpotlightTour, type SpotlightTourStep } from "@/components/ui/spotlight-tour";

const steps: SpotlightTourStep[] = [
  {
    target: "[data-tour=avatar]",
    title: "Your profile",
    body: "Switch accounts, change your theme and manage billing from here.",
  },
  {
    target: "[data-tour=toolbar]",
    title: "Quick actions",
    body: "Search, your inbox and help. Everything you reach for daily, one click away.",
  },
  {
    target: "[data-tour=new]",
    title: "Create a project",
    body: "Start from scratch or from a template. Click New to try it.",
    advanceOnClick: true,
  },
  {
    target: "[data-tour=sidebar]",
    title: "Your workspace",
    body: "Projects, deployments and settings all live here. That's the whole tour.",
  },
];

const glyph = (d: string) => (
  <svg aria-hidden viewBox="0 0 24 24" className="size-4" fill="none" stroke="currentColor" strokeWidth={1.75} strokeLinecap="round" strokeLinejoin="round">
    <path d={d} />
  </svg>
);

const nav = [
  { label: "Projects", icon: glyph("M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z") },
  { label: "Deployments", icon: glyph("M13 2 3 14h9l-1 8 10-12h-9l1-8z") },
  { label: "Analytics", icon: glyph("M4 20V10M10 20V4M16 20v-7M22 20H2") },
  { label: "Domains", icon: glyph("M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18ZM3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18") },
  { label: "Settings", icon: glyph("M4 6h9M17 6h3M4 12h3M11 12h9M4 18h11M19 18h1M15 4v4M9 10v4M17 16v4") },
];

const tools = [
  { label: "Search", icon: glyph("M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14ZM20 20l-3.5-3.5") },
  { label: "Inbox", icon: glyph("M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10.3 21a1.94 1.94 0 0 0 3.4 0") },
  { label: "Help", icon: glyph("M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18ZM9.1 9a3 3 0 0 1 5.8 1c0 2-3 3-3 3M12 17h.01") },
];

type Project = { id: string; name: string; meta: string; color: string; status: "Live" | "Building" | "Draft" };

const projects: Project[] = [
  { id: "storefront", name: "Storefront", meta: "Updated 2h ago", color: "bg-orange-500", status: "Live" },
  { id: "docs", name: "Docs", meta: "Updated yesterday", color: "bg-sky-500", status: "Live" },
  { id: "dashboard", name: "Admin dashboard", meta: "Updated 3 days ago", color: "bg-violet-500", status: "Building" },
  { id: "blog", name: "Blog", meta: "Updated last week", color: "bg-emerald-500", status: "Live" },
];

const status = {
  Live: "bg-emerald-500/10 text-emerald-700 dark:text-emerald-400",
  Building: "bg-amber-500/10 text-amber-700 dark:text-amber-400",
  Draft: "bg-zinc-500/10 text-zinc-600 dark:text-zinc-400",
};

const panel = "rounded-lg bg-white ring-1 ring-zinc-900/[0.06] dark:bg-zinc-950 dark:ring-white/[0.06]";

export function SpotlightTourDemo() {
  const [open, setOpen] = React.useState(false);
  const [list, setList] = React.useState(projects);

  const addProject = () =>
    setList((current) =>
      current.length >= 6
        ? current
        : [{ id: `draft-${current.length}`, name: "Untitled project", meta: "Just now", color: "bg-zinc-400", status: "Draft" }, ...current],
    );

  return (
    <div className="flex h-[28rem] w-full max-w-3xl gap-2 overflow-hidden rounded-xl border border-zinc-200 bg-zinc-100 p-2 dark:border-zinc-800 dark:bg-zinc-900">
      <aside data-tour="sidebar" className={`${panel} flex w-12 shrink-0 flex-col gap-0.5 p-1.5 sm:w-44 sm:p-2`}>
        <span className="mb-3 flex h-8 items-center gap-2 px-1.5 text-sm font-semibold text-zinc-900 dark:text-zinc-50">
          <span aria-hidden className="size-5 shrink-0 rounded-md bg-gradient-to-br from-orange-400 to-rose-500" />
          <span className="hidden sm:inline">Acme</span>
        </span>
        {nav.map((item, i) => (
          <span
            key={item.label}
            className={
              i === 0
                ? "flex h-8 items-center gap-2.5 rounded-md bg-zinc-100 px-2 text-sm text-zinc-900 dark:bg-zinc-900 dark:text-zinc-50"
                : "flex h-8 items-center gap-2.5 rounded-md px-2 text-sm text-zinc-500"
            }
          >
            {item.icon}
            <span className="hidden sm:inline">{item.label}</span>
          </span>
        ))}
        <div className="mt-auto hidden rounded-md bg-zinc-50 p-2.5 sm:block dark:bg-zinc-900">
          <p className="text-xs font-medium text-zinc-900 dark:text-zinc-100">Hobby plan</p>
          <div className="mt-2 h-1 rounded-full bg-zinc-200 dark:bg-zinc-800">
            <div className="h-full w-2/3 rounded-full bg-zinc-900 dark:bg-zinc-100" />
          </div>
          <p className="mt-1.5 text-[11px] text-zinc-500">4 of 6 projects</p>
        </div>
      </aside>

      <div className="flex min-w-0 flex-1 flex-col gap-2">
        <header className="flex h-10 shrink-0 items-center justify-between gap-3 pl-2">
          <p className="truncate text-sm text-zinc-500">
            Acme <span className="px-1 text-zinc-300 dark:text-zinc-700">/</span>
            <span className="text-zinc-900 dark:text-zinc-50">Projects</span>
          </p>
          <div className="flex items-center gap-2">
            <div data-tour="toolbar" className="flex items-center rounded-full bg-white p-1 ring-1 ring-zinc-900/[0.06] dark:bg-zinc-950 dark:ring-white/[0.06]">
              {tools.map((tool) => (
                <button
                  key={tool.label}
                  type="button"
                  aria-label={tool.label}
                  className="grid size-7 place-items-center rounded-full text-zinc-500 transition-colors hover:bg-zinc-100 hover:text-zinc-900 dark:hover:bg-zinc-900 dark:hover:text-zinc-50"
                >
                  {tool.icon}
                </button>
              ))}
            </div>
            <button
              data-tour="avatar"
              type="button"
              aria-label="Account"
              className="grid size-9 place-items-center rounded-full bg-gradient-to-br from-sky-400 to-indigo-500 text-xs font-semibold text-white ring-2 ring-white dark:ring-zinc-950"
            >
              JL
            </button>
          </div>
        </header>

        <main className={`${panel} flex min-h-0 flex-1 flex-col overflow-hidden`}>
          <div className="flex items-center justify-between gap-3 border-b border-zinc-100 px-4 py-3 dark:border-zinc-900">
            <h3 className="text-sm font-medium text-zinc-900 dark:text-zinc-50">Projects</h3>
            <button
              data-tour="new"
              type="button"
              onClick={addProject}
              className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-zinc-900 px-3 text-sm font-medium text-white transition-[background-color,scale] duration-150 hover:bg-zinc-800 active:scale-[0.97] dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-200"
            >
              <svg aria-hidden viewBox="0 0 24 24" className="size-3.5" fill="none" stroke="currentColor" strokeWidth={2.25} strokeLinecap="round">
                <path d="M12 5v14M5 12h14" />
              </svg>
              New
            </button>
          </div>

          <ul className="min-h-0 flex-1 overflow-hidden px-2 py-1.5">
            <AnimatePresence initial={false}>
              {list.map((project) => (
                <motion.li
                  key={project.id}
                  layout
                  initial={{ opacity: 0, y: -6 }}
                  animate={{ opacity: 1, y: 0 }}
                  transition={{ type: "spring", visualDuration: 0.35, bounce: 0.15 }}
                  className="flex h-12 items-center gap-3 rounded-md px-2"
                >
                  <span aria-hidden className={`size-2 shrink-0 rounded-full ${project.color}`} />
                  <span className="min-w-0 flex-1">
                    <span className="block truncate text-sm text-zinc-900 dark:text-zinc-100">{project.name}</span>
                    <span className="block truncate text-xs text-zinc-500">{project.meta}</span>
                  </span>
                  <span className={`rounded-full px-2 py-0.5 text-[11px] font-medium ${status[project.status]}`}>
                    {project.status}
                  </span>
                </motion.li>
              ))}
            </AnimatePresence>
          </ul>

          <div className="flex items-center justify-between gap-3 border-t border-zinc-100 bg-zinc-50/60 px-4 py-3 dark:border-zinc-900 dark:bg-zinc-900/40">
            <p className="text-sm text-pretty text-zinc-600 dark:text-zinc-400">New here? Take a 20-second tour.</p>
            <button
              type="button"
              onClick={() => setOpen(true)}
              className="h-8 shrink-0 rounded-lg px-3 text-sm font-medium text-zinc-900 ring-1 ring-zinc-900/10 transition-[background-color,scale] duration-150 hover:bg-white active:scale-[0.97] dark:text-zinc-50 dark:ring-white/10 dark:hover:bg-zinc-900"
            >
              Start tour
            </button>
          </div>
        </main>
      </div>

      <SpotlightTour open={open} onOpenChange={setOpen} steps={steps} />
    </div>
  );
}
```

## Examples

Finished sections built with Spotlight Tour. Each one installs whole, with the component included.

### What's New

Three new features spread down a page. Each one scrolls into view while the spotlight travels to it. The Live switch stays usable while it's highlighted.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/spotlight-tour-whats-new-demo
```

```tsx title="components/spotlight-tour-whats-new-demo.tsx"
"use client";

import * as React from "react";

import { SpotlightTour, type SpotlightTourStep } from "@/components/ui/spotlight-tour";

// The features are spread down the page. The tour scrolls each one into view
// while the spotlight travels to it.
const steps: SpotlightTourStep[] = [
  {
    target: "[data-tour=whats-new]",
    title: "3 new features",
    body: "Here's what shipped this week. It takes about 20 seconds.",
  },
  {
    target: "[data-tour=live]",
    title: "Live mode",
    body: "Charts now update in real time. Flip the switch to watch the numbers move.",
    interactive: true,
  },
  {
    target: "[data-tour=notes]",
    title: "Notes on charts",
    body: "Pin a note to any point on a chart, so next week's spike explains itself.",
  },
  {
    target: "[data-tour=export]",
    title: "Export to CSV",
    body: "Download any table as a CSV, with your filters applied.",
  },
];

const seed = [42, 48, 45, 53, 50, 58, 61, 57, 66, 63, 70, 74, 69, 78, 82, 79, 88, 85, 92, 96];
const weeks = [32, 38, 35, 44, 41, 47, 96, 58, 52, 55, 60, 63];
const pages = [
  { path: "/", views: "48,210", change: "+12%" },
  { path: "/pricing", views: "18,904", change: "+31%" },
  { path: "/docs/getting-started", views: "12,377", change: "+8%" },
  { path: "/blog/launch-week", views: "9,120", change: "+164%" },
  { path: "/changelog", views: "6,483", change: "-3%" },
  { path: "/careers", views: "2,941", change: "+5%" },
];

function useLive(on: boolean) {
  const [points, setPoints] = React.useState(seed);
  React.useEffect(() => {
    if (!on) return;
    const timer = window.setInterval(() => {
      setPoints((current) => {
        const last = current[current.length - 1];
        const next = Math.max(40, Math.min(120, last + Math.round(Math.random() * 14 - 6)));
        return [...current.slice(1), next];
      });
    }, 900);
    return () => window.clearInterval(timer);
  }, [on]);
  return points;
}

function line(points: number[], width: number, height: number) {
  const max = 130;
  return points
    .map((value, i) => `${i ? "L" : "M"}${((i / (points.length - 1)) * width).toFixed(1)} ${(height - (value / max) * height).toFixed(1)}`)
    .join("");
}

const card = "rounded-xl bg-white p-4 ring-1 ring-zinc-900/[0.06] dark:bg-zinc-950 dark:ring-white/[0.07]";

export function SpotlightTourWhatsNewDemo() {
  const [open, setOpen] = React.useState(false);
  const [live, setLive] = React.useState(false);
  const points = useLive(live);
  const visitors = points[points.length - 1] * 37;
  const path = line(points, 300, 96);

  return (
    <div className="w-full overflow-hidden rounded-xl border border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-900">
      <div className="h-[var(--demo-height,36rem)] overflow-y-auto overscroll-contain">
        <header className="sticky top-0 z-10 flex h-14 items-center justify-between gap-3 border-b border-zinc-200/80 bg-zinc-50/85 px-4 backdrop-blur sm:px-6 dark:border-zinc-800/80 dark:bg-zinc-900/85">
          <span className="flex items-center gap-2 text-sm font-semibold text-zinc-900 dark:text-zinc-50">
            <span aria-hidden className="size-5 rounded-full bg-gradient-to-br from-emerald-400 to-teal-600" />
            Pulse
          </span>
          <button
            data-tour="whats-new"
            type="button"
            onClick={() => setOpen(true)}
            className="inline-flex h-7 items-center gap-1.5 rounded-full bg-white pr-1 pl-2.5 text-xs font-medium text-zinc-900 ring-1 ring-zinc-900/10 transition-[background-color,scale] duration-150 hover:bg-zinc-100 active:scale-[0.97] dark:bg-zinc-950 dark:text-zinc-50 dark:ring-white/10 dark:hover:bg-zinc-800"
          >
            What&apos;s new
            <span className="grid h-5 min-w-5 place-items-center rounded-full bg-emerald-500 px-1.5 text-[11px] text-white">3</span>
          </button>
        </header>

        <div className="mx-auto max-w-2xl space-y-4 px-4 py-6 sm:px-6">
          <div>
            <h2 className="text-xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">Overview</h2>
            <p className="mt-1 text-sm text-zinc-500">acme.com · Last 30 days</p>
          </div>

          <div className="grid grid-cols-3 gap-2 sm:gap-3">
            {[
              ["Visitors", "184k"],
              ["Bounce", "38%"],
              ["Avg. visit", "2m 41s"],
            ].map(([label, value]) => (
              <div key={label} className={card}>
                <p className="text-xs text-zinc-500">{label}</p>
                <p className="mt-1 text-lg font-semibold tracking-tight text-zinc-900 tabular-nums dark:text-zinc-50">{value}</p>
              </div>
            ))}
          </div>

          <section className={card}>
            <div className="flex items-center justify-between gap-3">
              <div>
                <p className="text-sm font-medium text-zinc-900 dark:text-zinc-50">Visitors right now</p>
                <p className="mt-0.5 text-2xl font-semibold tracking-tight text-zinc-900 tabular-nums dark:text-zinc-50">
                  {visitors.toLocaleString("en-US")}
                </p>
              </div>
              <label data-tour="live" className="flex cursor-pointer items-center gap-2 rounded-full py-1 pr-1 pl-2.5 text-xs font-medium text-zinc-600 dark:text-zinc-300">
                Live
                <button
                  type="button"
                  role="switch"
                  aria-checked={live}
                  onClick={() => setLive((on) => !on)}
                  className={`relative h-5 w-9 cursor-pointer rounded-full transition-colors duration-200 ${live ? "bg-emerald-500" : "bg-zinc-300 dark:bg-zinc-700"}`}
                >
                  <span
                    className={`absolute top-0.5 left-0.5 size-4 rounded-full bg-white shadow-sm transition-transform duration-200 ease-out ${live ? "translate-x-4" : ""}`}
                  />
                </button>
              </label>
            </div>
            <svg aria-hidden viewBox="0 0 300 96" preserveAspectRatio="none" className="mt-4 h-24 w-full overflow-visible">
              <path d={`${path}L300 96L0 96Z`} className="fill-emerald-500/10" />
              <path d={path} fill="none" strokeWidth={1.75} vectorEffect="non-scaling-stroke" className="stroke-emerald-500" />
            </svg>
          </section>

          <div className="pt-4">
            <h2 className="text-base font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">Weekly signups</h2>
            <p className="mt-1 text-sm text-zinc-500">Week 7 was launch week.</p>
          </div>

          <section className={card}>
            <div className="relative flex h-40 items-end gap-1.5 sm:gap-2">
              {weeks.map((value, i) => (
                <div
                  key={i}
                  className={`flex-1 rounded-t-[4px] ${i === 6 ? "bg-emerald-500" : "bg-zinc-200 dark:bg-zinc-800"}`}
                  style={{ height: `${value}%` }}
                />
              ))}
              <div
                data-tour="notes"
                className="absolute top-1 left-[38%] w-44 rounded-lg bg-zinc-900 px-3 py-2 text-xs text-white shadow-lg sm:left-[46%] dark:bg-zinc-100 dark:text-zinc-900"
              >
                <p className="font-medium">Launch day</p>
                <p className="mt-0.5 text-zinc-400 dark:text-zinc-600">Front page of Hacker News for 9 hours.</p>
              </div>
            </div>
          </section>

          <div className="grid gap-3 pt-4 sm:grid-cols-2">
            {[
              ["Top referrer", "news.ycombinator.com", "31% of visits"],
              ["Top country", "United States", "42% of visits"],
              ["Top device", "Desktop", "68% of visits"],
              ["Top browser", "Chrome", "57% of visits"],
            ].map(([label, value, note]) => (
              <div key={label} className={card}>
                <p className="text-xs text-zinc-500">{label}</p>
                <p className="mt-1 truncate text-sm font-medium text-zinc-900 dark:text-zinc-50">{value}</p>
                <p className="mt-0.5 text-xs text-zinc-500">{note}</p>
              </div>
            ))}
          </div>

          <section className={`${card} p-0`}>
            <div className="flex items-center justify-between gap-3 px-4 py-3">
              <p className="text-sm font-medium text-zinc-900 dark:text-zinc-50">Top pages</p>
              <button
                data-tour="export"
                type="button"
                className="inline-flex h-7 items-center gap-1.5 rounded-md px-2.5 text-xs font-medium text-zinc-700 ring-1 ring-zinc-900/10 transition-colors hover:bg-zinc-50 dark:text-zinc-200 dark:ring-white/10 dark:hover:bg-zinc-900"
              >
                <svg aria-hidden viewBox="0 0 24 24" className="size-3.5" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
                  <path d="M12 3v12M7 10l5 5 5-5M5 21h14" />
                </svg>
                Export CSV
              </button>
            </div>
            <table className="w-full text-sm">
              <tbody>
                {pages.map((page) => (
                  <tr key={page.path} className="border-t border-zinc-100 dark:border-zinc-900">
                    <td className="max-w-0 truncate px-4 py-2.5 text-zinc-700 dark:text-zinc-300">{page.path}</td>
                    <td className="px-2 py-2.5 text-right text-zinc-900 tabular-nums dark:text-zinc-100">{page.views}</td>
                    <td
                      className={`px-4 py-2.5 text-right text-xs tabular-nums ${page.change.startsWith("-") ? "text-rose-600 dark:text-rose-400" : "text-emerald-600 dark:text-emerald-400"}`}
                    >
                      {page.change}
                    </td>
                  </tr>
                ))}
              </tbody>
            </table>
          </section>
        </div>
      </div>

      <SpotlightTour open={open} onOpenChange={setOpen} steps={steps} />
    </div>
  );
}
```

### Form Help

A sign-up form that explains each field in turn. You can type into each field while it's highlighted.

Install this demo, with the component included:

```bash
npx shadcn@latest add @keyframeui/spotlight-tour-form-demo
```

```tsx title="components/spotlight-tour-form-demo.tsx"
"use client";

import * as React from "react";

import { SpotlightTour, type SpotlightTourStep } from "@/components/ui/spotlight-tour";

// Each field stays typable while it's highlighted.
const steps: SpotlightTourStep[] = [
  {
    target: "[data-tour=signup-name]",
    title: "Your name",
    body: "This is how teammates see you in comments and mentions.",
    side: "right",
    padding: 6,
    interactive: true,
  },
  {
    target: "[data-tour=signup-email]",
    title: "Use your work email",
    body: "So teammates can find you, and you join your company's workspace automatically.",
    side: "right",
    padding: 6,
    interactive: true,
  },
  {
    target: "[data-tour=signup-password]",
    title: "Pick a password",
    body: "At least 12 characters. A short sentence is easier to remember than symbols.",
    side: "right",
    padding: 6,
    interactive: true,
  },
  {
    target: "[data-tour=signup-workspace]",
    title: "Name your workspace",
    body: "It becomes your address. You can change it later in settings.",
    side: "right",
    padding: 6,
    interactive: true,
  },
  {
    target: "[data-tour=signup-submit]",
    title: "You're ready",
    body: "Create the workspace, then invite your team on the next screen.",
    side: "right",
    padding: 6,
  },
];

function strength(password: string) {
  let score = 0;
  if (password.length >= 8) score++;
  if (password.length >= 12) score++;
  if (/[A-Z]/.test(password) && /[a-z]/.test(password)) score++;
  if (/[\d\s\W]/.test(password)) score++;
  return password ? Math.max(score, 1) : 0;
}

const meter = ["", "bg-rose-500", "bg-amber-500", "bg-lime-500", "bg-emerald-500"];

const field =
  "h-10 w-full rounded-lg bg-white px-3 text-sm text-zinc-900 ring-1 ring-zinc-900/10 transition-shadow outline-none placeholder:text-zinc-400 focus:ring-2 focus:ring-zinc-900 dark:bg-zinc-950 dark:text-zinc-50 dark:ring-white/10 dark:focus:ring-zinc-100";
const label = "mb-1.5 block text-sm font-medium text-zinc-900 dark:text-zinc-100";

export function SpotlightTourFormDemo() {
  const [open, setOpen] = React.useState(false);
  const [password, setPassword] = React.useState("");
  const [workspace, setWorkspace] = React.useState("");
  const [visible, setVisible] = React.useState(false);
  const [created, setCreated] = React.useState(false);
  const score = strength(password);

  React.useEffect(() => {
    if (!created) return;
    const timer = window.setTimeout(() => setCreated(false), 2200);
    return () => window.clearTimeout(timer);
  }, [created]);

  return (
    <div className="w-full max-w-sm rounded-2xl border border-zinc-200 bg-zinc-50 p-6 dark:border-zinc-800 dark:bg-zinc-900">
      <div className="flex items-start justify-between gap-4">
        <div>
          <h2 className="text-lg font-semibold tracking-tight text-zinc-900 dark:text-zinc-50">Create your workspace</h2>
          <p className="mt-1 text-sm text-zinc-500">Free for teams of up to 5.</p>
        </div>
        <button
          type="button"
          onClick={() => setOpen(true)}
          className="mt-0.5 shrink-0 rounded-md text-sm font-medium text-zinc-500 underline decoration-zinc-300 underline-offset-4 transition-colors hover:text-zinc-900 hover:decoration-zinc-900 dark:decoration-zinc-700 dark:hover:text-zinc-50 dark:hover:decoration-zinc-50"
        >
          Guide me
        </button>
      </div>

      <form
        className="mt-6 space-y-4"
        onSubmit={(event) => {
          event.preventDefault();
          setCreated(true);
        }}
      >
        <div>
          <label htmlFor="signup-name" className={label}>
            Full name
          </label>
          <input id="signup-name" data-tour="signup-name" autoComplete="name" placeholder="Jordan Lee" className={field} />
        </div>

        <div>
          <label htmlFor="signup-email" className={label}>
            Work email
          </label>
          <input
            id="signup-email"
            data-tour="signup-email"
            type="email"
            autoComplete="email"
            placeholder="jordan@company.com"
            className={field}
          />
        </div>

        <div>
          <label htmlFor="signup-password" className={label}>
            Password
          </label>
          <div data-tour="signup-password" className="rounded-lg">
            <div className="relative">
              <input
                id="signup-password"
                type={visible ? "text" : "password"}
                autoComplete="new-password"
                value={password}
                onChange={(event) => setPassword(event.target.value)}
                className={`${field} pr-16`}
              />
              <button
                type="button"
                onClick={() => setVisible((on) => !on)}
                className="absolute top-1/2 right-2 -translate-y-1/2 rounded px-1.5 py-0.5 text-xs font-medium text-zinc-500 hover:text-zinc-900 dark:hover:text-zinc-50"
              >
                {visible ? "Hide" : "Show"}
              </button>
            </div>
            <div aria-hidden className="mt-2 grid grid-cols-4 gap-1">
              {[1, 2, 3, 4].map((bar) => (
                <span
                  key={bar}
                  className={`h-1 rounded-full transition-colors duration-300 ${bar <= score ? meter[score] : "bg-zinc-200 dark:bg-zinc-800"}`}
                />
              ))}
            </div>
          </div>
        </div>

        <div>
          <label htmlFor="signup-workspace" className={label}>
            Workspace
          </label>
          <div
            data-tour="signup-workspace"
            className="flex h-10 items-center rounded-lg bg-white text-sm ring-1 ring-zinc-900/10 transition-shadow focus-within:ring-2 focus-within:ring-zinc-900 dark:bg-zinc-950 dark:ring-white/10 dark:focus-within:ring-zinc-100"
          >
            <span className="pl-3 text-zinc-400">pulse.app/</span>
            <input
              id="signup-workspace"
              value={workspace}
              onChange={(event) => setWorkspace(event.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ""))}
              placeholder="acme"
              className="h-full min-w-0 flex-1 bg-transparent pr-3 text-zinc-900 outline-none placeholder:text-zinc-400 dark:text-zinc-50"
            />
          </div>
        </div>

        <button
          data-tour="signup-submit"
          type="submit"
          className="h-10 w-full rounded-lg bg-zinc-900 text-sm font-medium text-white transition-[background-color,scale] duration-150 hover:bg-zinc-800 active:scale-[0.98] dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-200"
        >
          {created ? "Workspace created" : "Create workspace"}
        </button>
      </form>

      <SpotlightTour open={open} onOpenChange={setOpen} steps={steps} />
    </div>
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `steps` (required) | `SpotlightTourStep[]` |  | The stops on the tour. Each has a `title`, and optionally a `body`, a `target` (a CSS selector, an element or a ref; leave it out for a centered card with no highlight), a `side` for the card, its own `padding` and `radius`, `interactive` to let people click and type inside the hole, and `advanceOnClick` to move on when the element is clicked. |
| `open` (required) | `boolean` |  | Whether the tour is showing. |
| `onOpenChange` | `(open: boolean) => void` |  | Called with `false` when the tour is skipped, closed with Esc or finished. |
| `step` | `number` |  | The current step, to control it yourself. Otherwise the tour starts at the first step each time it opens. |
| `onStepChange` | `(step: number) => void` |  | Called when the step changes. Use it to get the page ready, such as opening a menu before highlighting something inside it. The tour waits for the element to appear. |
| `onComplete` | `() => void` |  | Called when Done is pressed on the last step, not when the tour is skipped. |
| `padding` | `number` | `8` | Room between each element and the edge of its hole, in pixels. A step's own `padding` wins. |
| `className` | `string` |  | Extra classes for the card. |
| `overlayClassName` | `string` |  | Extra classes for the dimmed layer, such as its color. Defaults to `bg-black/50 dark:bg-black/65`. |

## Source

```tsx title="components/ui/spotlight-tour.tsx"
"use client";

import * as React from "react";
import {
  AnimatePresence,
  animate,
  cancelFrame,
  frame,
  motion,
  useMotionValue,
  useReducedMotion,
  type Variants,
} from "motion/react";

import { cn } from "@/lib/utils";

/* ─────────────────────────────────────────────────────────
 * SPOTLIGHT TOUR STORYBOARD
 *
 *  open      the page dims, then a hole irises open out of the
 *            dark around the first element and the card rises
 *            in beside it
 *  next      the hole glides to the next element on a spring
 *            and takes its shape: round for an avatar, a long
 *            pill for a toolbar, a rounded panel for a sidebar.
 *            The card rides along, moving to another side when
 *            it has to, and its text slides over to the new step
 *  scroll    an element out of view scrolls in on the same
 *            spring the hole travels on, so it's one motion
 *  arrive    one soft ring pulses out from the hole
 *  redirect  press again mid-flight and it bends toward the new
 *            element without stopping
 *  stay      scrolling, resizing and layout shifts keep the hole
 *            on its element
 *  finish    the hole widens past the edges of the screen as the
 *            dark lifts, and the card fades away
 * ───────────────────────────────────────────────────────── */

const TOUR = {
  open: { type: "spring", visualDuration: 0.5, bounce: 0.2, delay: 0.12 }, // the iris on the first step
  travel: { type: "spring", visualDuration: 0.5, bounce: 0.14 },
  close: { type: "spring", visualDuration: 0.6, bounce: 0 },
  resize: { type: "spring", visualDuration: 0.32, bounce: 0.04 }, // the card's height following its text
  dots: { type: "spring", visualDuration: 0.4, bounce: 0.24 },
  pulse: { duration: 0.9, ease: [0.22, 1, 0.36, 1] },
  dimIn: { duration: 0.3, ease: "easeOut" },
  dimOut: { duration: 0.45, ease: [0.55, 0, 0.75, 0.4] }, // lingers, then lifts, so the iris reads
  cardIn: { duration: 0.32, ease: [0.22, 1, 0.36, 1], delay: 0.2 },
  fadeOut: { duration: 0.16, ease: "easeOut" },
  padding: 8, // px between an element and the edge of its hole
  gap: 14, // px between the hole and the card; the arrow sits in it
  margin: 12, // px kept clear of the viewport edges
  wait: 1500, // ms to wait for a step's element to appear
} as const;

const DIM = "bg-black/50 dark:bg-black/65";

export interface SpotlightTourStep {
  /** The element to highlight: a CSS selector, an element or a ref. Leave it out for a step with no highlight. */
  target?: string | Element | React.RefObject<Element | null> | null;
  title: React.ReactNode;
  body?: React.ReactNode;
  /** Side of the element the card sits on. Defaults to the side with the most room. */
  side?: "top" | "right" | "bottom" | "left";
  /** Room between the element and the edge of the hole, in px. */
  padding?: number;
  /** Corner radius of the hole, in px. Defaults to the element's own, plus the padding. */
  radius?: number;
  /** Let people click and type inside the hole. */
  interactive?: boolean;
  /** Go to the next step when the element is clicked. The click still reaches it. */
  advanceOnClick?: boolean;
}

export interface SpotlightTourProps {
  steps: SpotlightTourStep[];
  open: boolean;
  onOpenChange?: (open: boolean) => void;
  /** The current step, to control it yourself. Otherwise the tour starts at the first step each time it opens. */
  step?: number;
  onStepChange?: (step: number) => void;
  /** Called when Done is pressed on the last step, not when the tour is skipped. */
  onComplete?: () => void;
  /** Default room around each element, in px. */
  padding?: number;
  /** Classes for the card. */
  className?: string;
  /** Classes for the dimmed layer, e.g. its color. */
  overlayClassName?: string;
}

type Side = NonNullable<SpotlightTourStep["side"]>;
type Placement = Side | "center";
type Point = { x: number; y: number };
type Size = { width: number; height: number };
type Radii = [number, number, number, number]; // top-left, top-right, bottom-right, bottom-left
type Hole = Point & Size & { radii: Radii };
type Corner = { px: number; pct: number };
type ScrollMove = { element: Element; axis: "x" | "y"; from: number; to: number };
type Thenable = { then: (onResolve: () => void) => unknown };
type View = Size & { bottom: number }; // `bottom` is the visible bottom edge, above an on-screen keyboard
type Flight = {
  // Where the trip started. The start rides along with the element it
  // left, so a page scrolling under it carries it too.
  anchor: Element | null;
  anchorAt: Point;
  shift: Point;
  hole: Hole;
  card: Point | null; // null: the card starts where it ends
  side: Placement;
};

const ORIGIN: Point = { x: 0, y: 0 };
// Covers the screen. The hole is cut out of it with the even-odd rule.
const OUTSIDE = "M-99999 -99999H99999V99999H-99999Z";

export function SpotlightTour({
  steps,
  open,
  onOpenChange,
  step,
  onStepChange,
  onComplete,
  padding = TOUR.padding,
  className,
  overlayClassName,
}: SpotlightTourProps) {
  const id = React.useId();
  const reduceMotion = useReducedMotion() ?? false;
  const [mounted, setMounted] = React.useState(false);
  const [ownIndex, setOwnIndex] = React.useState(0);
  const count = steps.length;
  const index = clamp(step ?? ownIndex, 0, Math.max(count - 1, 0));
  // Which way the text slides: forward or back.
  const [paged, setPaged] = React.useState({ index, direction: 1 });
  if (paged.index !== index) setPaged({ index, direction: index > paged.index ? 1 : -1 });
  const direction = paged.direction;
  const current = steps[index];
  const last = index === count - 1;
  const passThrough = passThroughFor(current);

  const layerRef = React.useRef<HTMLDivElement>(null);
  const dimRef = React.useRef<HTMLDivElement>(null);
  const plugRef = React.useRef<HTMLDivElement>(null);
  const blockerRef = React.useRef<HTMLDivElement>(null);
  const outlineRef = React.useRef<HTMLDivElement>(null);
  const ringRef = React.useRef<HTMLDivElement>(null);
  const cardRef = React.useRef<HTMLDivElement>(null);
  const arrowRef = React.useRef<SVGSVGElement>(null);
  const bodyRef = React.useRef<HTMLDivElement>(null);
  const contentRef = React.useRef<HTMLDivElement>(null);

  const live = React.useRef({
    phase: "closed" as "closed" | "open" | "closing",
    index: 0,
    started: false, // the first trip, which irises open, has begun
    trip: 0, // bumps with every trip, so a stale trip's callbacks do nothing
    generation: 0, // bumps on every open and close
    target: null as Element | null,
    corners: null as Corner[] | null,
    waiting: 0, // when we began looking for the step's element; 0 when not looking
    flight: null as Flight | null,
    side: "center" as Placement, // the card's side for this step
    drawnSide: "center" as Placement, // the side its arrow is on right now
    hole: zeroHole(ORIGIN),
    lastEnd: null as Hole | null,
    exit: null as Hole | null,
    card: ORIGIN,
    drive: [] as ScrollMove[],
    arrived: false,
    resting: false,
    bodyTarget: 0,
    chrome: 0, // the card's height without its text
    velocity: [0, 0, 0, 0], // the hole's x, y, width and height speeds, px/s
    time: 0,
    reduce: false,
    wide: null as MediaQueryList | null,
    returnFocus: null as HTMLElement | null,
  });

  const progress = useMotionValue(0); // 0 where a trip starts, 1 where it ends
  const veil = useMotionValue(0); // the dim
  const shown = useMotionValue(0); // the card
  const plug = useMotionValue(0); // fills the hole, to fade between steps with reduced motion
  const pulse = useMotionValue(0);
  const bodyHeight = useMotionValue(0);

  // ── Trips ───────────────────────────────────────────────

  // A trip that ran its course: stop driving the scroll.
  const settle = (run: Thenable, trip: number) => {
    run.then(() => {
      const s = live.current;
      if (s.trip !== trip) return;
      s.drive = [];
      s.resting = true;
    });
  };

  const depart = (target: Element | null, options: { quiet?: boolean; scroll?: boolean } = {}) => {
    const s = live.current;
    const config = steps[s.index];
    if (!config) return;
    const view = viewport();
    const docked = !s.wide?.matches;
    const opening = !s.started;
    const trip = ++s.trip;
    const previous = s.target;
    const pad = config.padding ?? padding;
    s.started = true;
    s.waiting = 0;
    s.target = target;
    s.corners = target ? readCorners(target) : null;
    s.lastEnd = null;
    s.arrived = !!options.quiet;
    s.resting = false;

    // Bring the element into view, clear of the card when it's docked.
    const cardHeight = s.chrome + s.bodyTarget;
    const moves =
      target && options.scroll !== false
        ? planScroll(target, pad, docked ? cardHeight + TOUR.margin * 2 : 0, view)
        : [];
    const offset = { x: 0, y: 0 };
    for (const move of moves) offset[move.axis] += move.to - move.from;

    // Where the element will be once that's done picks the card's side.
    const now = target && s.corners ? holeAround(target, s.corners, pad, config.radius) : null;
    const end = now ? moveHole(now, -offset.x, -offset.y) : null;
    const width = cardRef.current?.offsetWidth ?? 320;
    s.side = chooseSide(end, config.side, { width, height: cardHeight }, view);

    if (opening) {
      // Nothing is on screen yet, so with reduced motion it can just jump.
      if (s.reduce) for (const move of moves) scrollMove(move, move.to);
      s.drive = s.reduce ? [] : moves;
      s.flight =
        target && now && !s.reduce
          ? { anchor: target, anchorAt: topLeft(target) ?? ORIGIN, shift: ORIGIN, hole: collapse(now), card: null, side: s.side }
          : null;
      progress.jump(s.reduce ? 1 : 0);
      animate(shown, 1, s.reduce ? TOUR.dimIn : TOUR.cardIn);
      if (s.reduce) s.resting = true;
      else settle(animate(progress, 1, TOUR.open), trip);
      return;
    }

    const anchor = previous && topLeft(previous) ? previous : null;
    s.flight = {
      anchor,
      anchorAt: (anchor && topLeft(anchor)) || ORIGIN,
      shift: ORIGIN,
      hole: s.hole,
      card: s.card,
      side: s.drawnSide,
    };
    progress.jump(0);

    if (s.reduce) {
      // Fade the hole shut, move, then fade it open on the new element.
      s.drive = [];
      animate(plug, 1, { duration: 0.12, ease: "easeOut" }).then(() => {
        if (s.trip !== trip) return;
        for (const move of moves) scrollMove(move, move.to);
        s.flight = null;
        progress.jump(1);
        s.resting = true;
        animate(plug, 0, { duration: 0.2, ease: "easeOut" });
      });
      return;
    }

    // Carry the hole's speed into the new trip, so pressing again
    // mid-flight bends its path instead of stopping it dead.
    const goal = end ?? collapse(s.hole);
    const path = [goal.x - s.hole.x, goal.y - s.hole.y, goal.width - s.hole.width, goal.height - s.hole.height];
    const length = dot(path, path);
    const velocity = length > 1 ? clamp(dot(s.velocity, path) / length, -2, 8) : 0;
    s.drive = moves;
    settle(animate(progress, 1, { ...TOUR.travel, velocity }), trip);
  };

  // Head for the current step's element, or wait for it to appear.
  const seek = () => {
    const s = live.current;
    const config = steps[s.index];
    if (!config) return;
    if (!config.target) return depart(null);
    const element = resolve(config.target);
    if (element && topLeft(element)) depart(element);
    else s.waiting = performance.now();
  };

  // ── Open and close ──────────────────────────────────────

  const start = () => {
    const s = live.current;
    if (!steps.length || s.phase === "open") return;
    if (s.phase === "closing") {
      // Opened again on the way out: close back in on the step.
      s.phase = "open";
      s.generation++;
      s.exit = null;
      animate(veil, 1, TOUR.dimIn);
      animate(shown, 1, TOUR.dimIn);
      cardRef.current?.focus({ preventScroll: true });
      seek();
      return;
    }
    const view = viewport();
    s.phase = "open";
    s.generation++;
    s.started = false;
    s.target = null;
    s.corners = null;
    s.flight = null;
    s.exit = null;
    s.waiting = 0;
    s.drive = [];
    s.side = "center";
    s.drawnSide = "center";
    s.hole = zeroHole({ x: view.width / 2, y: view.height / 2 });
    s.velocity = [0, 0, 0, 0];
    s.reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    s.wide = window.matchMedia("(min-width: 40rem)");
    s.returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
    progress.jump(0);
    veil.jump(0);
    shown.jump(0);
    plug.jump(0);
    pulse.jump(0);
    if (step === undefined) setOwnIndex(0);
    setMounted(true);
  };

  const close = () => {
    const s = live.current;
    if (s.phase !== "open") return;
    s.phase = "closing";
    s.waiting = 0;
    s.drive = [];
    s.trip++;
    const generation = ++s.generation;
    const runs: Thenable[] = [animate(shown, 0, TOUR.fadeOut)];
    if (s.reduce) {
      runs.push(animate(veil, 0, TOUR.dimIn));
    } else {
      // The hole widens past the edges of the screen as the dark lifts.
      const anchor = s.target && topLeft(s.target) ? s.target : null;
      s.flight = {
        anchor,
        anchorAt: (anchor && topLeft(anchor)) || ORIGIN,
        shift: ORIGIN,
        hole: s.hole,
        card: s.card,
        side: s.drawnSide,
      };
      s.exit = widen(s.hole, viewport());
      progress.jump(0);
      // Once the dark is gone the rest of the widening can't be seen, so
      // the layer goes away then, without waiting for the spring to rest.
      animate(progress, 1, TOUR.close);
      runs.push(animate(veil, 0, TOUR.dimOut));
    }
    // The page is usable straight away: hand focus back now.
    const layer = layerRef.current;
    const focus = document.activeElement;
    if ((!focus || focus === document.body || layer?.contains(focus)) && s.returnFocus?.isConnected) {
      s.returnFocus.focus({ preventScroll: true });
    }
    Promise.all(runs).then(() => {
      if (live.current.generation === generation) finish();
    });
  };

  // Gone: put the layer away.
  const finish = () => {
    const s = live.current;
    if (s.phase !== "closing") return;
    s.phase = "closed";
    s.target = null;
    s.flight = null;
    s.exit = null;
    const layer = layerRef.current;
    if (layer?.matches(":popover-open")) layer.hidePopover();
    setMounted(false);
  };

  const go = (to: number) => {
    const s = live.current;
    if (s.phase !== "open" || to < 0 || to === s.index) return;
    if (to >= steps.length) {
      onComplete?.();
      onOpenChange?.(false);
      return;
    }
    if (step === undefined) setOwnIndex(to);
    onStepChange?.(to);
  };

  const skip = () => onOpenChange?.(false);

  const clicked = (event: MouseEvent) => {
    const s = live.current;
    if (s.phase !== "open" || !steps[s.index]?.advanceOnClick || !s.target) return;
    if (event.target instanceof Node && s.target.contains(event.target)) go(s.index + 1);
  };

  // A click on the dark: pulse the ring again to point at the element.
  const nudge = (event: React.PointerEvent) => {
    const s = live.current;
    if (!event.isPrimary || s.phase !== "open" || s.reduce || !s.target) return;
    pulse.jump(0);
    animate(pulse, 1, TOUR.pulse);
  };

  // Tab moves through the card and, on interactive steps, the element.
  const cycleFocus = (event: KeyboardEvent) => {
    const s = live.current;
    const card = cardRef.current;
    const config = steps[s.index];
    if (!card) return;
    const inside = passThroughFor(config) && s.target ? focusables(s.target) : [];
    const items = [...inside, ...focusables(card)];
    if (!items.length) return;
    event.preventDefault();
    const at = items.indexOf(document.activeElement as HTMLElement);
    const next =
      at === -1
        ? event.shiftKey
          ? items.length - 1
          : 0
        : (at + (event.shiftKey ? -1 : 1) + items.length) % items.length;
    items[next].focus({ preventScroll: true });
  };

  const measureCard = () => {
    const s = live.current;
    const card = cardRef.current;
    const body = bodyRef.current;
    const content = contentRef.current;
    if (!card || !body || !content) return;
    s.chrome = card.offsetHeight - body.offsetHeight;
    const height = content.offsetHeight;
    if (s.started && Math.abs(height - s.bodyTarget) < 0.5) return;
    s.bodyTarget = height;
    if (!s.started || s.reduce) bodyHeight.jump(height);
    else animate(bodyHeight, height, TOUR.resize);
  };

  // ── Every frame ─────────────────────────────────────────

  const tick = () => {
    const s = live.current;
    const dim = dimRef.current;
    const plugElement = plugRef.current;
    const blocker = blockerRef.current;
    const outline = outlineRef.current;
    const ring = ringRef.current;
    const card = cardRef.current;
    const arrow = arrowRef.current;
    const body = bodyRef.current;
    if (s.phase === "closed" || !dim || !plugElement || !blocker || !outline || !ring || !card || !arrow || !body) {
      return;
    }
    const config = steps[s.index];
    const view = viewport();
    const docked = !s.wide?.matches;
    const width = card.offsetWidth;

    // A step whose element hasn't appeared yet: keep looking, for a while.
    if (s.waiting && config) {
      const element = resolve(config.target);
      if (element && topLeft(element)) depart(element);
      else if (performance.now() - s.waiting > TOUR.wait) depart(null);
    }
    // The element was swapped for a new one, as re-renders can do: follow it.
    if (s.target && !s.target.isConnected && config) {
      const element = resolve(config.target);
      if (element && element !== s.target && topLeft(element)) {
        s.target = element;
        s.corners = readCorners(element);
      }
    }

    const p = progress.get();
    for (const move of s.drive) scrollMove(move, lerp(move.from, move.to, clamp(p, 0, 1)));

    // The hole runs from where the trip started to the element it's headed for.
    const f = s.flight;
    if (f?.anchor) {
      const at = topLeft(f.anchor);
      if (at) f.shift = { x: at.x - f.anchorAt.x, y: at.y - f.anchorAt.y };
    }
    const start = f ? moveHole(f.hole, f.shift.x, f.shift.y) : null;
    let end: Hole;
    if (s.phase === "closing" && s.exit) {
      end = s.exit;
    } else if (s.target && s.corners && config) {
      const measured = holeAround(s.target, s.corners, config.padding ?? padding, config.radius);
      if (measured) s.lastEnd = measured;
      end = measured ?? s.lastEnd ?? s.hole;
    } else {
      end = collapse(start ?? s.hole);
    }
    const hole = start ? mixHole(start, end, p) : end;
    const holeOpen = hole.width > 1 && hole.height > 1;

    const time = performance.now();
    const dt = (time - s.time) / 1000;
    s.velocity =
      dt > 0.001 && dt < 0.1
        ? [(hole.x - s.hole.x) / dt, (hole.y - s.hole.y) / dt, (hole.width - s.hole.width) / dt, (hole.height - s.hole.height) / dt]
        : [0, 0, 0, 0];
    s.time = time;
    s.hole = hole;

    // The card rides along, from where it was to beside the element.
    const size = { width, height: s.chrome + bodyHeight.get() };
    let side = s.side;
    let arrowOpacity = 1;
    if (s.phase === "open") {
      if (docked) {
        s.card = { x: (view.width - width) / 2, y: view.bottom - TOUR.margin - size.height };
      } else {
        const goal = placeCard(s.side === "center" ? null : end, s.side, size, view);
        s.card = f?.card ? mixPoint(movePoint(f.card, f.shift), goal, p) : goal;
      }
      // Changing sides: the arrow fades out on the old one, then in on the new.
      if (f?.card && f.side !== s.side) {
        if (p < 0.5) {
          side = f.side;
          arrowOpacity = 1 - p * 2;
        } else arrowOpacity = Math.min(p * 2 - 1, 1);
      }
      s.drawnSide = side;
    }

    // Pulse once on arrival.
    if (s.phase === "open" && !s.arrived && s.target && p >= 0.96) {
      s.arrived = true;
      if (!s.reduce) {
        pulse.jump(0);
        animate(pulse, 1, TOUR.pulse);
      }
    }

    // Draw.
    const dark = veil.get();
    const plugged = plug.get();
    const active = s.phase === "open";
    write(dim, {
      clipPath: `path(evenodd, "${OUTSIDE}${holePath(hole)}")`,
      opacity: str(dark),
      pointerEvents: active ? "auto" : "none",
    });
    write(blocker, { ...boxStyle(hole), pointerEvents: active && !passThroughFor(config) ? "auto" : "none" });
    write(outline, { ...boxStyle(hole), opacity: holeOpen ? str(dark) : "0" });
    write(plugElement, { ...boxStyle(hole), opacity: holeOpen ? str(plugged) : "0" });
    const q = pulse.get();
    write(
      ring,
      q > 0 && q < 1 && holeOpen && active
        ? { ...boxStyle(growHole(hole, 2 + q * 12)), opacity: str((1 - q) * 0.75 * dark) }
        : { opacity: "0" },
    );

    const presence = shown.get();
    const showArrow = active && !docked && side !== "center" && holeOpen;
    const tip = showArrow ? arrowAt(hole, side as Side, s.card, size) : null;
    const lift = s.reduce
      ? ""
      : docked
        ? `translateY(${num((1 - presence) * 20)}px)`
        : `scale(${num(0.94 + 0.06 * presence)})`;
    write(card, {
      transform: `translate3d(${num(s.card.x)}px, ${num(s.card.y)}px, 0) ${lift}`,
      opacity: str(presence * (1 - plugged)),
      pointerEvents: active ? "auto" : "none",
      ...(active ? { transformOrigin: tip ? `${num(tip.x)}px ${num(tip.y)}px` : "50% 50%" } : null),
    });
    write(body, { height: `${num(bodyHeight.get())}px` });
    if (active) {
      write(
        arrow,
        tip
          ? { transform: `translate(${num(tip.x - 8)}px, ${num(tip.y - 8)}px) rotate(${tip.rotate}deg)`, opacity: str(arrowOpacity) }
          : { opacity: "0" },
      );
    }

    // At rest, with the card squeezed out of its side (the page scrolled,
    // or the window shrank): move it to a side that has room.
    if (active && s.resting && !docked && s.target && s.side !== "center" && config) {
      const need = { width, height: s.chrome + s.bodyTarget };
      const room = roomAround(end, need, view);
      if (room[s.side] < 0) {
        const better = chooseSide(end, config.side, need, view);
        if (better !== s.side && better !== "center" && room[better] >= 0) {
          depart(s.target, { quiet: true, scroll: false });
        }
      }
    }
  };

  // Global listeners and the frame loop call the latest versions of these.
  const actions = React.useRef({ start, close, go, skip, clicked, tick, measureCard, seek, cycleFocus });
  React.useLayoutEffect(() => {
    actions.current = { start, close, go, skip, clicked, tick, measureCard, seek, cycleFocus };
  });

  React.useEffect(() => {
    if (open) actions.current.start();
    else actions.current.close();
  }, [open]);

  // Mounted: lift the layer into the top layer, move focus to the card and
  // start drawing.
  React.useLayoutEffect(() => {
    const layer = layerRef.current;
    if (!mounted || !layer) return;
    if (!layer.matches(":popover-open")) {
      layer.popover = "manual";
      layer.showPopover();
    }
    cardRef.current?.focus({ preventScroll: true });
    animate(veil, 1, TOUR.dimIn);
    const draw = () => actions.current.tick();
    frame.render(draw, true);
    return () => cancelFrame(draw);
  }, [mounted, veil]);

  // Each step: size the card to its text, then head for the step's element.
  React.useLayoutEffect(() => {
    if (!mounted) return;
    const s = live.current;
    const fresh = s.index !== index || !s.started;
    s.index = index;
    actions.current.measureCard();
    if (fresh && s.phase === "open") actions.current.seek();
    // Keep focus in the card, say when the button that had it went away.
    const card = cardRef.current;
    const focus = document.activeElement;
    if (card && !card.contains(focus) && !(passThrough && s.target?.contains(focus))) {
      card.focus({ preventScroll: true });
    }
  }, [mounted, index, passThrough]);

  // The card's height follows its text.
  React.useLayoutEffect(() => {
    const content = contentRef.current;
    if (!mounted || !content) return;
    const observer = new ResizeObserver(() => actions.current.measureCard());
    observer.observe(content);
    return () => observer.disconnect();
  }, [mounted]);

  React.useEffect(() => {
    if (!mounted) return;
    const onKeyDown = (event: KeyboardEvent) => {
      const s = live.current;
      if (s.phase !== "open" || event.defaultPrevented || event.isComposing) return;
      if (event.key === "Escape") {
        event.preventDefault();
        actions.current.skip();
        return;
      }
      if (event.key === "Tab") {
        actions.current.cycleFocus(event);
        return;
      }
      // Arrows and Enter steer the tour only from the card, so they still
      // type and pick inside an interactive element.
      const focus = document.activeElement;
      const free = !focus || focus === document.body || !!cardRef.current?.contains(focus);
      if (!free || event.repeat || event.metaKey || event.ctrlKey || event.altKey) return;
      const onControl = focus instanceof Element && !!focus.closest("button, a[href], input, select, textarea");
      if (event.key === "ArrowRight" || (event.key === "Enter" && !onControl)) {
        event.preventDefault();
        actions.current.go(s.index + 1);
      } else if (event.key === "ArrowLeft") {
        event.preventDefault();
        actions.current.go(s.index - 1);
      }
    };
    window.addEventListener("keydown", onKeyDown);
    return () => window.removeEventListener("keydown", onKeyDown);
  }, [mounted]);

  // Steps that move on when their element is clicked.
  React.useEffect(() => {
    if (!mounted) return;
    const onClick = (event: MouseEvent) => actions.current.clicked(event);
    document.addEventListener("click", onClick);
    return () => document.removeEventListener("click", onClick);
  }, [mounted]);

  // The wheel over the dark scrolls whatever is under it, nested scroll
  // boxes included. Taking over the scroll stops the tour from driving it.
  React.useEffect(() => {
    const layer = layerRef.current;
    if (!mounted || !layer) return;
    const release = () => {
      live.current.drive = [];
    };
    const onWheel = (event: WheelEvent) => {
      if (event.ctrlKey) return; // pinch to zoom
      event.preventDefault();
      const scale = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? window.innerHeight : 1;
      const dx = event.deltaX * scale;
      const dy = event.deltaY * scale;
      const under = document.elementsFromPoint(event.clientX, event.clientY).find((element) => !layer.contains(element));
      scrollerFor(under ?? null, dx, dy).scrollBy({ left: dx, top: dy, behavior: "instant" });
    };
    window.addEventListener("wheel", release, { passive: true, capture: true });
    window.addEventListener("touchstart", release, { passive: true, capture: true });
    layer.addEventListener("wheel", onWheel, { passive: false });
    return () => {
      window.removeEventListener("wheel", release, { capture: true });
      window.removeEventListener("touchstart", release, { capture: true });
      layer.removeEventListener("wheel", onWheel);
    };
  }, [mounted]);

  if (!mounted || !current) return null;

  const button = cn(
    "inline-flex h-8 cursor-pointer items-center justify-center rounded-full px-3 text-[13px] font-medium outline-none select-none [-webkit-tap-highlight-color:transparent]",
    "transition-[background-color,color,scale] duration-150 ease-out active:scale-[0.97] motion-reduce:active:scale-100",
    "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-foreground",
  );
  const text = reduceMotion ? FADE : SLIDE;

  return (
    <div
      ref={layerRef}
      className="pointer-events-none fixed inset-0 m-0 size-full max-h-none max-w-none overflow-hidden border-0 bg-transparent p-0 text-foreground"
    >
      <div
        ref={dimRef}
        aria-hidden
        className={cn("absolute inset-0 opacity-0", DIM, overlayClassName)}
        onPointerDown={nudge}
        // Keep focus in the card.
        onMouseDown={(event) => event.preventDefault()}
      />
      <div ref={plugRef} aria-hidden className={cn("absolute top-0 left-0 opacity-0", DIM, overlayClassName)} />
      <div
        ref={blockerRef}
        aria-hidden
        className="absolute top-0 left-0"
        onPointerDown={nudge}
        onMouseDown={(event) => event.preventDefault()}
      />
      <div ref={outlineRef} aria-hidden className="absolute top-0 left-0 opacity-0 ring-1 ring-white/15 dark:ring-white/25" />
      <div ref={ringRef} aria-hidden className="absolute top-0 left-0 border-[1.5px] border-white opacity-0" />

      <div
        ref={cardRef}
        role="dialog"
        aria-modal={passThrough ? undefined : true}
        aria-labelledby={`${id}-title-${index}`}
        aria-describedby={current.body ? `${id}-body-${index}` : undefined}
        tabIndex={-1}
        className={cn(
          "pointer-events-auto absolute top-0 left-0 w-[calc(100%-1.5rem)] rounded-2xl bg-background p-4 text-foreground opacity-0 outline-none sm:w-80",
          "shadow-[0_24px_60px_-16px_rgb(0_0_0/0.35),0_6px_18px_-8px_rgb(0_0_0/0.15)] ring-1 ring-foreground/10 dark:shadow-[0_24px_60px_-16px_rgb(0_0_0/0.8)]",
          className,
        )}
      >
        <svg
          ref={arrowRef}
          aria-hidden
          viewBox="0 0 16 9"
          className="pointer-events-none absolute top-0 left-0 h-[9px] w-4 overflow-visible opacity-0"
          style={{ transformOrigin: "8px 8px" }}
        >
          <path d="M0 8.5 6.6 1.9Q8 .5 9.4 1.9L16 8.5V9H0Z" className="fill-background" />
          <path d="M0 8.5 6.6 1.9Q8 .5 9.4 1.9L16 8.5" fill="none" className="stroke-foreground/10" />
        </svg>

        <div className="flex h-5 items-center justify-between text-xs text-muted-foreground">
          <span aria-hidden className="flex items-center tabular-nums">
            <span className="relative inline-flex overflow-hidden">
              <AnimatePresence mode="popLayout" initial={false} custom={direction}>
                <motion.span key={index} custom={direction} variants={reduceMotion ? FADE : COUNT} initial="enter" animate="center" exit="exit">
                  {index + 1}
                </motion.span>
              </AnimatePresence>
            </span>
            &nbsp;of {count}
          </span>
          <AnimatePresence initial={false}>
            {last ? null : (
              <motion.button
                key="skip"
                type="button"
                initial={{ opacity: 0 }}
                animate={{ opacity: 1 }}
                exit={{ opacity: 0 }}
                transition={{ duration: 0.15 }}
                onClick={skip}
                className="-mr-1.5 cursor-pointer rounded-md px-1.5 py-0.5 transition-colors outline-none hover:text-foreground focus-visible:outline-2 focus-visible:outline-foreground"
              >
                Skip
              </motion.button>
            )}
          </AnimatePresence>
        </div>

        <div ref={bodyRef} className="-mx-4 overflow-hidden px-4">
          <div ref={contentRef} aria-live="polite" className="relative pt-2.5">
            <AnimatePresence mode="popLayout" initial={false} custom={direction}>
              <motion.div key={index} custom={direction} variants={text} initial="enter" animate="center" exit="exit">
                <span className="sr-only">
                  Step {index + 1} of {count}.{" "}
                </span>
                <h2
                  id={`${id}-title-${index}`}
                  className="text-[15px] leading-snug font-medium tracking-[-0.01em] text-balance"
                >
                  {current.title}
                </h2>
                {current.body ? (
                  <div id={`${id}-body-${index}`} className="mt-1 text-sm leading-relaxed text-pretty text-muted-foreground">
                    {current.body}
                  </div>
                ) : null}
              </motion.div>
            </AnimatePresence>
          </div>
        </div>

        <div className="mt-4 flex items-center justify-between gap-3">
          <div aria-hidden className="flex items-center gap-1">
            {steps.map((_, dot) => (
              <motion.span
                key={dot}
                className="h-1.5 w-1.5 rounded-full bg-foreground"
                initial={false}
                animate={{
                  width: dot === index ? 18 : 6,
                  opacity: dot === index ? 1 : dot < index ? 0.4 : 0.15,
                }}
                transition={reduceMotion ? { duration: 0 } : TOUR.dots}
              />
            ))}
          </div>
          <div className="flex items-center gap-1">
            <AnimatePresence initial={false}>
              {index > 0 ? (
                <motion.button
                  key="back"
                  type="button"
                  initial={{ opacity: 0, scale: 0.9 }}
                  animate={{ opacity: 1, scale: 1 }}
                  exit={{ opacity: 0, scale: 0.9 }}
                  transition={{ duration: 0.16, ease: "easeOut" }}
                  onClick={() => go(index - 1)}
                  className={cn(button, "text-muted-foreground hover:bg-foreground/[0.06] hover:text-foreground")}
                >
                  Back
                </motion.button>
              ) : null}
            </AnimatePresence>
            <button
              type="button"
              onClick={() => go(index + 1)}
              className={cn(button, "min-w-16 bg-foreground px-3.5 text-background hover:bg-foreground/85")}
            >
              {last ? "Done" : "Next"}
            </button>
          </div>
        </div>
      </div>
    </div>
  );
}

const SLIDE: Variants = {
  enter: (direction: number) => ({ opacity: 0, x: direction * 12, filter: "blur(3px)" }),
  center: {
    opacity: 1,
    x: 0,
    filter: "blur(0px)",
    transition: { duration: 0.32, ease: [0.22, 1, 0.36, 1], delay: 0.05 },
  },
  exit: (direction: number) => ({
    opacity: 0,
    x: direction * -12,
    filter: "blur(3px)",
    transition: { duration: 0.16, ease: "easeIn" },
  }),
};

const COUNT: Variants = {
  enter: (direction: number) => ({ opacity: 0, y: direction * 8 }),
  center: { opacity: 1, y: 0, transition: { type: "spring", visualDuration: 0.3, bounce: 0.2 } },
  exit: (direction: number) => ({ opacity: 0, y: direction * -8, transition: { duration: 0.12 } }),
};

const FADE: Variants = {
  enter: { opacity: 0 },
  center: { opacity: 1, transition: { duration: 0.2 } },
  exit: { opacity: 0, transition: { duration: 0.12 } },
};

function passThroughFor(config: SpotlightTourStep | undefined) {
  return !!(config?.interactive || config?.advanceOnClick);
}

// ── Elements ──────────────────────────────────────────────

function resolve(target: SpotlightTourStep["target"]): Element | null {
  if (!target) return null;
  if (typeof target === "string") return document.querySelector(target);
  return target instanceof Element ? target : target.current;
}

/** Where the element's top-left corner is on screen, or null when it isn't laid out. */
function topLeft(element: Element): Point | null {
  if (!element.isConnected) return null;
  const rect = element.getBoundingClientRect();
  return rect.width || rect.height ? { x: rect.left, y: rect.top } : null;
}

function readCorners(element: Element): Corner[] {
  const style = getComputedStyle(element);
  return [
    style.borderTopLeftRadius,
    style.borderTopRightRadius,
    style.borderBottomRightRadius,
    style.borderBottomLeftRadius,
  ].map((value) => {
    const first = value.split(" ")[0] ?? "";
    const amount = parseFloat(first) || 0;
    // `rounded-full` computes to an enormous length; fitRadii scales it down.
    return first.endsWith("%") ? { px: 0, pct: amount / 100 } : { px: Math.min(amount, 1e5), pct: 0 };
  });
}

const FOCUSABLE =
  'a[href], button:not([disabled]), input:not([disabled]):not([type="hidden"]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"]), [contenteditable]:not([contenteditable="false"])';

function focusables(root: Element) {
  return [root, ...root.querySelectorAll(FOCUSABLE)].filter(
    (element): element is HTMLElement =>
      element instanceof HTMLElement && element.matches(FOCUSABLE) && element.getClientRects().length > 0,
  );
}

// ── The hole ──────────────────────────────────────────────

function zeroHole(at: Point): Hole {
  return { ...at, width: 0, height: 0, radii: [0, 0, 0, 0] };
}

/** An element's box plus padding, with its corners grown by the same padding so the curves stay parallel. */
function holeAround(element: Element, corners: Corner[], padding: number, radius?: number): Hole | null {
  if (!element.isConnected) return null;
  const rect = element.getBoundingClientRect();
  if (!rect.width && !rect.height) return null;
  const short = Math.min(rect.width, rect.height);
  const width = rect.width + padding * 2;
  const height = rect.height + padding * 2;
  const radii = corners.map((corner) => radius ?? corner.px + corner.pct * short + padding) as Radii;
  return { x: rect.left - padding, y: rect.top - padding, width, height, radii: fitRadii(radii, width, height) };
}

/** Scales corners down together when they don't fit, the way CSS does. */
function fitRadii(radii: Radii, width: number, height: number): Radii {
  const [tl, tr, br, bl] = radii;
  const ratio = (length: number, sum: number) => (sum > 0 ? length / sum : Infinity);
  const f = clamp(
    Math.min(ratio(width, tl + tr), ratio(width, bl + br), ratio(height, tl + bl), ratio(height, tr + br)),
    0,
    1,
  );
  return [tl * f, tr * f, br * f, bl * f];
}

function collapse(hole: Hole): Hole {
  return { ...zeroHole({ x: hole.x + hole.width / 2, y: hole.y + hole.height / 2 }), radii: hole.radii };
}

function moveHole(hole: Hole, dx: number, dy: number): Hole {
  return { ...hole, x: hole.x + dx, y: hole.y + dy };
}

function growHole(hole: Hole, by: number): Hole {
  return {
    x: hole.x - by,
    y: hole.y - by,
    width: hole.width + by * 2,
    height: hole.height + by * 2,
    radii: hole.radii.map((radius) => radius + by) as Radii,
  };
}

function mixHole(a: Hole, b: Hole, t: number): Hole {
  const width = Math.max(lerp(a.width, b.width, t), 0);
  const height = Math.max(lerp(a.height, b.height, t), 0);
  const radii = a.radii.map((radius, i) => Math.max(lerp(radius, b.radii[i], t), 0)) as Radii;
  return { x: lerp(a.x, b.x, t), y: lerp(a.y, b.y, t), width, height, radii: fitRadii(radii, width, height) };
}

/** A circle centered on the hole, big enough to clear every corner of the screen. */
function widen(hole: Hole, view: View): Hole {
  const open = hole.width > 1 && hole.height > 1;
  const cx = open ? hole.x + hole.width / 2 : view.width / 2;
  const cy = open ? hole.y + hole.height / 2 : view.height / 2;
  const reach = Math.hypot(Math.max(cx, view.width - cx), Math.max(cy, view.height - cy)) + 40;
  return { x: cx - reach, y: cy - reach, width: reach * 2, height: reach * 2, radii: [reach, reach, reach, reach] };
}

function holePath({ x, y, width: w, height: h, radii }: Hole) {
  if (w < 0.5 || h < 0.5) return "";
  const [tl, tr, br, bl] = radii.map(num);
  return (
    `M${num(x + tl)} ${num(y)}H${num(x + w - tr)}A${tr} ${tr} 0 0 1 ${num(x + w)} ${num(y + tr)}` +
    `V${num(y + h - br)}A${br} ${br} 0 0 1 ${num(x + w - br)} ${num(y + h)}` +
    `H${num(x + bl)}A${bl} ${bl} 0 0 1 ${num(x)} ${num(y + h - bl)}` +
    `V${num(y + tl)}A${tl} ${tl} 0 0 1 ${num(x + tl)} ${num(y)}Z`
  );
}

function boxStyle({ x, y, width, height, radii }: Hole) {
  return {
    transform: `translate3d(${num(x)}px, ${num(y)}px, 0)`,
    width: `${num(width)}px`,
    height: `${num(height)}px`,
    borderRadius: radii.map((radius) => `${num(radius)}px`).join(" "),
  };
}

// ── The card ──────────────────────────────────────────────

function roomAround(hole: Hole, size: Size, view: View): Record<Side, number> {
  const { gap, margin } = TOUR;
  return {
    top: hole.y - gap - margin - size.height,
    bottom: view.height - margin - (hole.y + hole.height + gap) - size.height,
    left: hole.x - gap - margin - size.width,
    right: view.width - margin - (hole.x + hole.width + gap) - size.width,
  };
}

/**
 * The asked-for side if the card fits there, otherwise the side with the most
 * room, counted in cards: a card is wider than it is tall, so it needs more
 * room beside an element than above or below it. Above or below reads more
 * naturally, so those win a close call.
 */
function chooseSide(hole: Hole | null, preferred: Side | undefined, size: Size, view: View): Placement {
  if (!hole || hole.width < 1) return "center";
  const room = roomAround(hole, size, view);
  if (preferred && room[preferred] >= 0) return preferred;
  const sides: Side[] = ["bottom", "top", "right", "left"];
  const score = (side: Side) => {
    const vertical = side === "top" || side === "bottom";
    const need = vertical ? size.height : size.width;
    return ((room[side] + need) / need) * (vertical ? 1 : 0.85);
  };
  const fitting = sides.filter((side) => room[side] >= 0);
  return (fitting.length ? fitting : sides).reduce((best, side) => (score(side) > score(best) ? side : best));
}

function placeCard(hole: Hole | null, side: Placement, size: Size, view: View): Point {
  const { gap, margin } = TOUR;
  let x = (view.width - size.width) / 2;
  let y = (view.height - size.height) / 2;
  if (hole && (side === "top" || side === "bottom")) {
    x = hole.x + hole.width / 2 - size.width / 2;
    y = side === "bottom" ? hole.y + hole.height + gap : hole.y - gap - size.height;
  } else if (hole && (side === "left" || side === "right")) {
    x = side === "right" ? hole.x + hole.width + gap : hole.x - gap - size.width;
    y = hole.y + hole.height / 2 - size.height / 2;
  }
  return {
    x: clamp(x, margin, view.width - margin - size.width),
    y: clamp(y, margin, view.height - margin - size.height),
  };
}

/** Where the arrow meets the card's edge, aimed at the hole, and which way it turns. */
function arrowAt(hole: Hole, side: Side, card: Point, size: Size) {
  const inset = 20; // clear of the card's corners
  if (side === "top" || side === "bottom") {
    const x = clamp(hole.x + hole.width / 2 - card.x, inset, size.width - inset);
    return side === "bottom" ? { x, y: 0, rotate: 0 } : { x, y: size.height, rotate: 180 };
  }
  const y = clamp(hole.y + hole.height / 2 - card.y, inset, size.height - inset);
  return side === "right" ? { x: 0, y, rotate: -90 } : { x: size.width, y, rotate: 90 };
}

// ── Scrolling ─────────────────────────────────────────────

const SCROLLS = /(auto|scroll|overlay)/;

function viewport(): View {
  const width = document.documentElement.clientWidth;
  const height = document.documentElement.clientHeight;
  const visual = window.visualViewport;
  return { width, height, bottom: visual ? Math.min(visual.offsetTop + visual.height, height) : height };
}

/**
 * How each scroll box around the element should move to bring it into view,
 * innermost first, ending with the page. `reserve` is room kept free at the
 * bottom of the screen.
 */
function planScroll(target: Element, padding: number, reserve: number, view: View): ScrollMove[] {
  const rect = target.getBoundingClientRect();
  let box = { top: rect.top - padding, bottom: rect.bottom + padding, left: rect.left - padding, right: rect.right + padding };
  const root = document.scrollingElement ?? document.documentElement;
  const moves: ScrollMove[] = [];
  for (let element = target.parentElement; element; element = element.parentElement) {
    const isRoot = element === root;
    const style = getComputedStyle(element);
    const scrollsY = isRoot || (SCROLLS.test(style.overflowY) && element.scrollHeight > element.clientHeight);
    const scrollsX = isRoot || (SCROLLS.test(style.overflowX) && element.scrollWidth > element.clientWidth);
    if (!scrollsY && !scrollsX) continue;

    const bounds = isRoot ? { top: 0, left: 0, bottom: view.bottom - reserve, right: view.width } : clientBox(element);
    const margin = isRoot ? 24 : 12;
    const inset = (value: string) => (parseFloat(value) || 0) + margin;
    let dx = 0;
    let dy = 0;
    if (scrollsY) {
      const delta = scrollDelta(box.top, box.bottom, bounds.top + inset(style.scrollPaddingTop), bounds.bottom - inset(style.scrollPaddingBottom));
      const to = clamp(element.scrollTop + delta, 0, element.scrollHeight - element.clientHeight);
      if (Math.abs(to - element.scrollTop) > 0.5) {
        dy = to - element.scrollTop;
        moves.push({ element, axis: "y", from: element.scrollTop, to });
      }
    }
    if (scrollsX) {
      const delta = scrollDelta(box.left, box.right, bounds.left + inset(style.scrollPaddingLeft), bounds.right - inset(style.scrollPaddingRight));
      const to = clamp(element.scrollLeft + delta, 0, element.scrollWidth - element.clientWidth);
      if (Math.abs(to - element.scrollLeft) > 0.5) {
        dx = to - element.scrollLeft;
        moves.push({ element, axis: "x", from: element.scrollLeft, to });
      }
    }
    box = { top: box.top - dy, bottom: box.bottom - dy, left: box.left - dx, right: box.right - dx };
    if (isRoot) break;
  }
  return moves;
}

/** Nothing if it's already in view; otherwise center it, or line up its start if it's too big to fit. */
function scrollDelta(start: number, end: number, min: number, max: number) {
  if (start >= min && end <= max) return 0;
  if (end - start > max - min) return start - min;
  return (start + end) / 2 - (min + max) / 2;
}

function clientBox(element: Element) {
  const rect = element.getBoundingClientRect();
  const top = rect.top + element.clientTop;
  const left = rect.left + element.clientLeft;
  return { top, left, bottom: top + element.clientHeight, right: left + element.clientWidth };
}

function scrollMove(move: ScrollMove, value: number) {
  move.element.scrollTo(
    move.axis === "y" ? { top: value, behavior: "instant" } : { left: value, behavior: "instant" },
  );
}

/** The box a wheel over `start` should scroll, as the browser would pick it. */
function scrollerFor(start: Element | null, dx: number, dy: number): Element {
  const root = document.scrollingElement ?? document.documentElement;
  for (let element = start; element && element !== root; element = element.parentElement) {
    const style = getComputedStyle(element);
    const y = dy !== 0 && SCROLLS.test(style.overflowY) && element.scrollHeight > element.clientHeight;
    const x = dx !== 0 && SCROLLS.test(style.overflowX) && element.scrollWidth > element.clientWidth;
    if (y && canScroll(element.scrollTop, element.scrollHeight - element.clientHeight, dy)) return element;
    if (x && canScroll(element.scrollLeft, element.scrollWidth - element.clientWidth, dx)) return element;
    // At its end and not letting the scroll chain out.
    if ((y && style.overscrollBehaviorY !== "auto") || (x && style.overscrollBehaviorX !== "auto")) return element;
  }
  return root;
}

function canScroll(position: number, max: number, delta: number) {
  return delta > 0 ? position < max - 1 : position > 1;
}

// ── Utilities ─────────────────────────────────────────────

// What was last written to each element, so unchanged styles aren't rewritten every frame.
const written = new WeakMap<Element, Record<string, string>>();

function write(element: HTMLElement | SVGElement, styles: Record<string, string>) {
  let last = written.get(element);
  if (!last) written.set(element, (last = {}));
  const style = element.style as unknown as Record<string, string>;
  for (const key in styles) {
    if (last[key] === styles[key]) continue;
    last[key] = styles[key];
    style[key] = styles[key];
  }
}

function movePoint(point: Point, by: Point): Point {
  return { x: point.x + by.x, y: point.y + by.y };
}

function mixPoint(a: Point, b: Point, t: number): Point {
  return { x: lerp(a.x, b.x, t), y: lerp(a.y, b.y, t) };
}

function dot(a: number[], b: number[]) {
  return a.reduce((sum, value, i) => sum + value * b[i], 0);
}

function num(value: number) {
  return Math.round(value * 100) / 100;
}

function str(value: number) {
  return String(num(value));
}

function lerp(from: number, to: number, t: number) {
  return from + (to - from) * t;
}

function clamp(value: number, min: number, max: number) {
  return Math.min(Math.max(value, min), max);
}
```
