# Tour

> Product tour: the page dims around a lit outline that glides from target to target, with a focus-trapped step card.

Category: Overlay. Docs: https://neonkit.heyaadi.com/components/tour

## Installation

```bash
npx shadcn@latest add https://neonkit.heyaadi.com/r/tour.json
```

With the `@neonkit` namespace configured: `npx shadcn@latest add @neonkit/tour`.

npm dependencies: `@floating-ui/react-dom`, `motion`. Also needs `lib/utils.ts` (`cn`) and `lib/neon.ts` (`neonVars`), which the CLI adds.

## Usage

```tsx
import { Tour } from "@/components/ui/tour"

const [open, setOpen] = React.useState(false)

<Tour
  open={open}
  onOpenChange={setOpen}
  steps={[
    { target: "#deploy", title: "Ship from main", body: "Builds the latest commit in about two minutes." },
    { target: logsRef, title: "Logs stay for 14 days", side: "right" },
  ]}
/>
```

## Examples

### Onboarding a dashboard

Press Take the tour. The page dims except a lit outline around each target, and the outline glides from one to the next. container keeps the dimming inside the dashboard.

```tsx
"use client";

import * as React from "react";
import { ChevronDown, FileText, Folder, LayoutDashboard, Rocket, RotateCcw, Settings } from "@/components/ui/icons";
import { Tour, type TourStep } from "@/components/ui/tour";

const DEPLOYS = [
  { id: "dpl_8f3a", commit: "Cache product images at the edge", who: "ada", age: "12 min", live: true },
  { id: "dpl_7c21", commit: "Fix cart total rounding for JPY", who: "lin", age: "3 h", live: false },
  { id: "dpl_77e0", commit: "Move checkout to the new API", who: "ada", age: "yesterday", live: false },
];

export default function TourDemo() {
  const frame = React.useRef<HTMLDivElement>(null);
  const env = React.useRef<HTMLButtonElement>(null);
  const deploy = React.useRef<HTMLButtonElement>(null);
  const stats = React.useRef<HTMLDListElement>(null);
  const rollback = React.useRef<HTMLButtonElement>(null);
  const logs = React.useRef<HTMLAnchorElement>(null);
  const [open, setOpen] = React.useState(false);
  const [finished, setFinished] = React.useState(false);

  const steps: TourStep[] = [
    {
      target: env,
      title: "This is production",
      body: "Changes here reach about 41,000 people a day. Switch to Preview to try a branch first.",
    },
    {
      target: deploy,
      title: "Ship from main",
      body: "Builds the latest commit on main in about two minutes. The current version keeps serving until the new one passes health checks.",
      side: "bottom",
      align: "end",
    },
    {
      target: stats,
      title: "Watch the first ten minutes",
      body: "These refresh every 10 seconds. An error rate over 1% rolls the deploy back on its own.",
    },
    {
      target: rollback,
      title: "Roll back in one click",
      body: "Any of the last 30 deploys can go live again. Nothing rebuilds, so it takes about 4 seconds.",
      side: "left",
    },
    {
      target: logs,
      title: "Logs stay for 14 days",
      body: "Every error links here with its request ID already filtered.",
      side: "right",
    },
  ];

  return (
    <div className="grid w-full max-w-3xl gap-3">
      <div
        ref={frame}
        className="relative flex min-h-[380px] overflow-hidden rounded-xl border border-white/10 bg-[#090a10] text-[13px] text-white/75"
      >
        <nav aria-label="Project" className="hidden w-44 shrink-0 flex-col gap-0.5 border-r border-white/[0.06] p-3 sm:flex">
          <p className="mb-3 px-2 pt-1 text-[13.5px] font-medium text-white/90">lumen-web</p>
          {[
            { icon: LayoutDashboard, label: "Overview", current: true },
            { icon: Rocket, label: "Deployments" },
            { icon: FileText, label: "Logs", ref: logs },
            { icon: Folder, label: "Storage" },
            { icon: Settings, label: "Settings" },
          ].map(({ icon: Icon, label, current, ref }) => (
            <a
              key={label}
              ref={ref}
              href={`#${label.toLowerCase()}`}
              onClick={(event) => event.preventDefault()}
              aria-current={current ? "page" : undefined}
              className="flex items-center gap-2.5 rounded-md px-2 py-1.5 text-white/55 outline-none hover:bg-white/[0.04] hover:text-white/85 focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] aria-[current=page]:bg-white/[0.05] aria-[current=page]:text-white/90"
            >
              <Icon aria-hidden className="size-4" />
              {label}
            </a>
          ))}
        </nav>

        <div className="min-w-0 flex-1 p-4 sm:p-5">
          <div className="flex flex-wrap items-center gap-2">
            <button
              ref={env}
              type="button"
              className="inline-flex h-8 items-center gap-2 rounded-md border border-white/10 px-2.5 text-white/85 outline-none hover:border-white/20 focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)]"
            >
              <span className="size-1.5 rounded-full bg-[#34d399] shadow-[0_0_6px_rgb(52_211_153/CALC(0.8*var(--neon-glow,1)))]" />
              Production
              <ChevronDown aria-hidden className="size-3.5 text-white/55" />
            </button>
            <div className="ml-auto flex items-center gap-2">
              <button
                type="button"
                onClick={() => {
                  setFinished(false);
                  setOpen(true);
                }}
                className="h-8 rounded-md px-2.5 text-[rgb(var(--neon))] outline-none hover:bg-[rgb(var(--neon)/0.08)] focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)]"
              >
                Take the tour
              </button>
              <button
                ref={deploy}
                type="button"
                className="inline-flex h-8 items-center gap-1.5 rounded-md bg-white/90 px-3 font-medium text-[#05060a] outline-none hover:bg-white focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#090a10]"
              >
                <Rocket aria-hidden className="size-3.5" />
                Deploy
              </button>
            </div>
          </div>

          <dl ref={stats} className="mt-5 grid grid-cols-3 gap-4 rounded-lg border border-white/[0.06] p-3.5">
            {[
              ["Requests", "1.24M", "last 24 h"],
              ["p95", "182 ms", "−11 ms"],
              ["Errors", "0.04%", "38 total"],
            ].map(([label, value, note]) => (
              <div key={label} className="min-w-0">
                <dt className="text-white/55">{label}</dt>
                <dd className="mt-1 font-mono text-[17px] tabular-nums text-white/90">{value}</dd>
                <dd className="mt-0.5 truncate text-[12px] text-white/55">{note}</dd>
              </div>
            ))}
          </dl>

          <p className="mt-5 text-white/55">Recent deployments</p>
          <ul className="mt-2 divide-y divide-white/[0.05]">
            {DEPLOYS.map((d, i) => (
              <li key={d.id} className="flex items-center gap-3 py-2.5">
                <span
                  aria-hidden
                  className={
                    d.live
                      ? "size-1.5 shrink-0 rounded-full bg-[rgb(var(--neon))] shadow-[0_0_6px_rgb(var(--neon)/CALC(0.8*var(--neon-glow,1)))]"
                      : "size-1.5 shrink-0 rounded-full bg-white/20"
                  }
                />
                <span className="min-w-0 flex-1 truncate text-white/85">{d.commit}</span>
                <span className="hidden shrink-0 text-white/55 sm:inline">
                  {d.who}, {d.age}
                </span>
                {d.live ? (
                  <span className="w-[5.5rem] shrink-0 text-right text-[12px] text-[rgb(var(--neon))]">Live</span>
                ) : (
                  <button
                    ref={i === 1 ? rollback : undefined}
                    type="button"
                    className="inline-flex h-7 w-[5.5rem] shrink-0 items-center justify-center gap-1.5 rounded-md border border-white/10 text-[12px] text-white/70 outline-none hover:border-white/20 hover:text-white focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)]"
                  >
                    <RotateCcw aria-hidden className="size-3" />
                    Roll back
                  </button>
                )}
              </li>
            ))}
          </ul>
        </div>

        <Tour
          container={frame}
          steps={steps}
          open={open}
          onOpenChange={setOpen}
          onComplete={() => setFinished(true)}
        />
      </div>
      <p aria-live="polite" className="px-1 text-[13px] text-white/50">
        {finished ? "That’s the tour. It’s under Take the tour if you want it again." : "Arrow keys step through it, Escape skips."}
      </p>
    </div>
  );
}
```

### Controlled, whole page

step and open are both controlled, so the links above jump to any step. The first step has no target, so its card sits in the middle of the screen.

```tsx
"use client";

import * as React from "react";
import { KeyRound, Smartphone } from "@/components/ui/icons";
import { Tour, type TourStep } from "@/components/ui/tour";

const steps: TourStep[] = [
  {
    title: "Two-factor sign-in",
    body: "Three steps, about two minutes. You’ll need your phone and somewhere safe to keep 10 backup codes.",
  },
  {
    target: "#tour-2fa-app",
    title: "Scan with an authenticator",
    body: "1Password, Authy and Google Authenticator all work. The code changes every 30 seconds.",
    side: "right",
  },
  {
    target: "#tour-2fa-codes",
    title: "Save your backup codes",
    body: "Each one works once. They’re the only way in if you lose the phone.",
    side: "right",
  },
  {
    target: "#tour-2fa-enforce",
    title: "Require it for the team",
    body: "Members without two-factor are signed out at their next request and asked to set it up.",
    side: "top",
  },
];

const titles = ["Intro", "Authenticator", "Backup codes", "Team policy"];

export default function TourControlled() {
  const [open, setOpen] = React.useState(false);
  const [step, setStep] = React.useState(0);

  return (
    <div className="grid w-full max-w-xl gap-4">
      <div className="flex flex-wrap items-center justify-between gap-3">
        <ol aria-label="Tour progress" className="flex flex-wrap items-center gap-1 text-[12.5px]">
          {titles.map((title, i) => (
            <li key={title}>
              <button
                type="button"
                onClick={() => {
                  setStep(i);
                  setOpen(true);
                }}
                aria-current={open && step === i ? "step" : undefined}
                className="whitespace-nowrap rounded-md px-2 py-1 text-white/50 outline-none hover:text-white/85 focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] aria-[current=step]:bg-[rgb(var(--neon)/0.1)] aria-[current=step]:text-[rgb(var(--neon))]"
              >
                {title}
              </button>
            </li>
          ))}
        </ol>
        <span className="font-mono text-[12px] tabular-nums text-white/45">step={step}</span>
      </div>

      <section className="grid gap-3 rounded-xl border border-white/10 bg-white/[0.02] p-4">
        <div id="tour-2fa-app" className="flex items-center gap-3 rounded-lg border border-white/[0.07] p-3">
          <Smartphone aria-hidden className="size-5 shrink-0 text-white/50" />
          <div className="min-w-0 flex-1">
            <p className="text-[13.5px] text-white/85">Authenticator app</p>
            <p className="text-[12.5px] text-white/45">Not set up</p>
          </div>
          <span className="rounded-md border border-white/10 px-2.5 py-1 text-[12.5px] text-white/70">Set up</span>
        </div>
        <div id="tour-2fa-codes" className="flex items-center gap-3 rounded-lg border border-white/[0.07] p-3">
          <KeyRound aria-hidden className="size-5 shrink-0 text-white/50" />
          <div className="min-w-0 flex-1">
            <p className="text-[13.5px] text-white/85">Backup codes</p>
            <p className="text-[12.5px] text-white/45">10 codes, generated after setup</p>
          </div>
        </div>
        <div id="tour-2fa-enforce" className="flex items-center justify-between gap-3 rounded-lg px-3 py-2">
          <p className="text-[13.5px] text-white/70">Require two-factor for all 14 members</p>
          <span aria-hidden className="h-5 w-9 rounded-full bg-white/10" />
        </div>
      </section>

      <Tour steps={steps} open={open} onOpenChange={setOpen} step={step} onStepChange={setStep} />
    </div>
  );
}
```

### One-step coachmark

A single step drops the count and Skip, and labels renames Done.

```tsx
"use client";

import * as React from "react";
import { Command, Search } from "@/components/ui/icons";
import { Tour } from "@/components/ui/tour";

/** One step, so no count: a "what's new" pointer. */
export default function TourCoachmark() {
  const search = React.useRef<HTMLButtonElement>(null);
  const [seen, setSeen] = React.useState(false);
  const [open, setOpen] = React.useState(false);

  return (
    <div className="grid w-full max-w-md justify-items-start gap-4">
      <button
        ref={search}
        type="button"
        className="flex h-9 w-full items-center gap-2 rounded-lg border border-white/10 bg-white/[0.03] px-3 text-[13px] text-white/50 outline-none hover:border-white/20 focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)]"
      >
        <Search aria-hidden className="size-4" />
        Search docs, issues and runs
        <kbd className="ml-auto inline-flex items-center gap-0.5 rounded-[4px] border border-white/10 px-1.5 font-mono text-[11px] text-white/55">
          <Command aria-hidden className="size-3" />K
        </kbd>
      </button>
      <button
        type="button"
        onClick={() => setOpen(true)}
        className="rounded-md text-[13px] text-[rgb(var(--neon))] underline-offset-4 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)]"
      >
        {seen ? "Show it again" : "What’s new in 4.2"}
      </button>
      <Tour
        steps={[
          {
            target: search,
            title: "Search now covers CI runs",
            body: "Paste a run ID or a failing test name. Results from the last 90 days.",
            side: "bottom",
            align: "start",
          },
        ]}
        open={open}
        onOpenChange={(next) => {
          setOpen(next);
          if (!next) setSeen(true);
        }}
        labels={{ done: "Got it" }}
      />
    </div>
  );
}
```

## API reference

### Tour

Renders nothing until open. Accepts div attributes for the card; the ref points at it.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `steps` | `TourStep[]` | — | The stops, in order. |
| `open` | `boolean` | — | Controlled open state. |
| `defaultOpen` | `boolean` | `false` | Starting open state when uncontrolled. |
| `onOpenChange` | `(open: boolean) => void` | — | Called on Skip, Escape and Done. |
| `step` | `number` | — | Controlled step, zero-based. |
| `defaultStep` | `number` | `0` | Where an uncontrolled tour starts. It starts there again each time it opens. |
| `onStepChange` | `(step: number) => void` | — | Called on Next, Back and the arrow keys. |
| `onComplete` | `() => void` | — | Next on the last step. The tour then closes. |
| `onSkip` | `(step: number) => void` | — | Skip or Escape, with the step it was on. |
| `container` | `RefObject<HTMLElement> \| HTMLElement` | — | Dims only this element instead of the page. Give it position: relative. The card still renders in the body, so it's never clipped. |
| `labels` | `{ next?, back?, skip?, done? }` | `Next, Back, Skip tour, Done` | Button text. |
| `color` | `NeonColor` | `inherited` | Outline and button color. Falls back to the color where the tour was opened, then cyan. |

### TourStep

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `target` | `string \| RefObject \| HTMLElement \| () => HTMLElement` | — | A selector, ref, element or function. Leave it out, or point at a hidden element, for a centered card with no cutout. |
| `title` | `ReactNode` | — | Labels the dialog. |
| `body` | `ReactNode` | — | Describes the dialog. |
| `side` | `"top" \| "right" \| "bottom" \| "left"` | `"bottom"` | Where the card sits. It flips, then falls back to the other axis, when there isn't room. |
| `align` | `"start" \| "center" \| "end"` | `"center"` | Alignment along that side. |
| `padding` | `number` | `6` | Space between the target and the outline, in px. |
| `radius` | `number` | `target's radius + padding` | Corner radius of the outline. |

## Accessibility

- The card is a modal dialog labelled by the step's title and described by its body. A polite live region says "Step 2 of 5" as you move.
- Focus moves to Next when the tour opens, stays inside the card (Tab wraps), and returns to where it was when the tour closes.
- Escape skips. Left and Right go back and forward, unless focus is in a text field inside the card.
- Targets scroll into view when they're near an edge, and the outline follows them through scrolling and resizing. A target that mounts a frame late is waited for.
- The outline and the dimmed page are one SVG whose geometry is written straight to the DOM each frame of a glide, so stepping never re-renders the page or animates layout. The card is placed with floating-ui on the same frame.
- The outline strikes once when the tour opens. With reduced motion it appears without the flicker and jumps between steps.

## Source

`components/ui/tour.tsx`

```tsx
"use client";

import * as React from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion, useIsPresent, useReducedMotion } from "motion/react";
import { computePosition, flip, offset, shift, type Placement, type VirtualElement } from "@floating-ui/react-dom";

import { cn } from "@/lib/utils";
import { neonVars, type NeonColor } from "@/lib/neon";

// The tube strikes once when the tour opens. Between steps it glides; it never flickers again.
const css = `
@keyframes nk-tour-ignite{0%{opacity:0}8%{opacity:.9}14%{opacity:.25}24%{opacity:1}34%{opacity:.6}100%{opacity:1}}
.nk-tour-tube{animation:nk-tour-ignite 520ms linear both}
.nk-tour-leak{display:none}
[data-side=bottom] .nk-tour-leak[data-edge=top],[data-side=top] .nk-tour-leak[data-edge=bottom],
[data-side=left] .nk-tour-leak[data-edge=right],[data-side=right] .nk-tour-leak[data-edge=left]{display:block}
@media (prefers-reduced-motion:reduce){.nk-tour-tube{animation:none}}
`;

const EASE = [0.23, 1, 0.32, 1] as const;
const GLIDE = 380;
const easeOutQuint = (t: number) => 1 - Math.pow(1 - t, 5);

type Side = "top" | "right" | "bottom" | "left";
type Align = "start" | "center" | "end";
type Rect = { x: number; y: number; w: number; h: number; r: number };
/** Where the overlay sits on screen, and how much an ancestor transform scales it. */
type Origin = { x: number; y: number; s: number };

/** A CSS selector, a ref, an element or a function that returns one. Leave it out for a centered step. */
export type TourTarget =
  | string
  | React.RefObject<HTMLElement | null>
  | HTMLElement
  | (() => HTMLElement | null | undefined);

export interface TourStep {
  target?: TourTarget | null;
  title: React.ReactNode;
  body?: React.ReactNode;
  /** Where the card sits. Flips when there isn't room. */
  side?: Side;
  align?: Align;
  /** Space between the target and the lit outline, in px. */
  padding?: number;
  /** Corner radius of the outline. Defaults to the target's own radius plus `padding`. */
  radius?: number;
}

export interface TourLabels {
  next?: React.ReactNode;
  back?: React.ReactNode;
  skip?: React.ReactNode;
  done?: React.ReactNode;
}

export interface TourProps
  extends Omit<React.HTMLAttributes<HTMLDivElement>, "color" | "title" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> {
  steps: TourStep[];
  open?: boolean;
  defaultOpen?: boolean;
  onOpenChange?: (open: boolean) => void;
  /** Zero-based. */
  step?: number;
  defaultStep?: number;
  onStepChange?: (step: number) => void;
  /** Next on the last step. The tour closes afterwards. */
  onComplete?: () => void;
  /** Skip or Escape, with the step it was on. */
  onSkip?: (step: number) => void;
  /** Dims only this element instead of the whole page. It should be positioned. */
  container?: React.RefObject<HTMLElement | null> | HTMLElement | null;
  labels?: TourLabels;
  /** Palette name or hex. Inherits `--neon-accent` from the page when omitted. */
  color?: NeonColor;
}

const subscribe = () => () => {};
const useIsClient = () =>
  React.useSyncExternalStore(
    subscribe,
    () => true,
    () => false,
  );

const TABBABLE = "a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex='-1'])";

function resolveTarget(target: TourTarget | null | undefined, scope: ParentNode): HTMLElement | null {
  if (!target) return null;
  if (typeof target === "string") return scope.querySelector<HTMLElement>(target);
  if (typeof target === "function") return target() ?? null;
  if (typeof HTMLElement !== "undefined" && target instanceof HTMLElement) return target;
  return (target as React.RefObject<HTMLElement | null>).current ?? null;
}

function resolveContainer(container: TourProps["container"]) {
  if (!container) return null;
  return container instanceof HTMLElement ? container : container.current;
}

const round = (n: number) => Math.round(n * 10) / 10;

/** The page outside a rounded-rect hole, as one even-odd path. */
function dimPath({ x, y, w, h, r }: Rect) {
  const outer = "M-10000 -10000H100000V100000H-10000Z";
  if (w < 1 || h < 1) return outer;
  const k = Math.min(r, w / 2, h / 2);
  return `${outer}M${round(x + k)} ${round(y)}H${round(x + w - k)}A${k} ${k} 0 0 1 ${round(x + w)} ${round(y + k)}V${round(y + h - k)}A${k} ${k} 0 0 1 ${round(x + w - k)} ${round(y + h)}H${round(x + k)}A${k} ${k} 0 0 1 ${round(x)} ${round(y + h - k)}V${round(y + k)}A${k} ${k} 0 0 1 ${round(x + k)} ${round(y)}Z`;
}

const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
const lerpRect = (a: Rect, b: Rect, t: number): Rect => ({
  x: lerp(a.x, b.x, t),
  y: lerp(a.y, b.y, t),
  w: lerp(a.w, b.w, t),
  h: lerp(a.h, b.h, t),
  r: lerp(a.r, b.r, t),
});

const Tour = React.forwardRef<HTMLDivElement, TourProps>(
  (
    {
      steps,
      open: openProp,
      defaultOpen = false,
      onOpenChange,
      step: stepProp,
      defaultStep = 0,
      onStepChange,
      onComplete,
      onSkip,
      container,
      labels,
      color,
      ...props
    },
    ref,
  ) => {
    const isClient = useIsClient();
    const [innerOpen, setInnerOpen] = React.useState(defaultOpen);
    const open = openProp ?? innerOpen;
    // A ref container is filled when its element commits, which can be after this component's layout
    // effects (the container is usually an ancestor), so it's read in a passive effect.
    const [host, setHost] = React.useState<HTMLElement | null>(null);
    React.useEffect(() => {
      // Syncing from a ref, which has no event to subscribe to.
      // eslint-disable-next-line react-hooks/set-state-in-effect
      setHost(resolveContainer(container));
    }, [container, open]);
    const [innerStep, setInnerStep] = React.useState(defaultStep);
    const count = steps.length;
    const index = Math.min(Math.max(stepProp ?? innerStep, 0), Math.max(count - 1, 0));

    // An uncontrolled tour starts over each time it opens.
    const [wasOpen, setWasOpen] = React.useState(open);
    if (open !== wasOpen) {
      setWasOpen(open);
      if (open && stepProp === undefined) setInnerStep(defaultStep);
    }

    const setOpen = (next: boolean) => {
      if (openProp === undefined) setInnerOpen(next);
      onOpenChange?.(next);
    };
    const go = (next: number) => {
      if (next < 0 || next >= count || next === index) return;
      if (stepProp === undefined) setInnerStep(next);
      onStepChange?.(next);
    };
    const actions = {
      next: () => {
        if (index >= count - 1) {
          onComplete?.();
          setOpen(false);
        } else go(index + 1);
      },
      back: () => go(index - 1),
      skip: () => {
        onSkip?.(index);
        setOpen(false);
      },
    };

    if (!isClient || (container && !host)) return null;

    return (
      <AnimatePresence>
        {open && count > 0 && (
          <TourLayer
            key="tour"
            ref={ref}
            steps={steps}
            index={index}
            host={host}
            labels={labels}
            color={color}
            actions={actions}
            {...props}
          />
        )}
      </AnimatePresence>
    );
  },
);
Tour.displayName = "Tour";

interface TourLayerProps extends Omit<TourProps, "open" | "defaultOpen" | "onOpenChange" | "step" | "defaultStep" | "onStepChange" | "onComplete" | "onSkip" | "container"> {
  index: number;
  host: HTMLElement | null;
  actions: { next: () => void; back: () => void; skip: () => void };
}

const TourLayer = React.forwardRef<HTMLDivElement, TourLayerProps>(
  ({ steps, index, host, labels, color, actions, className, style, onKeyDown, ...props }, forwardedRef) => {
    const reduceMotion = useReducedMotion();
    // False while the exit animation plays: the tour has already closed, so it lets go of focus and keys.
    const present = useIsPresent();
    const id = React.useId();
    const step = steps[index];
    const last = index === steps.length - 1;

    const overlayRef = React.useRef<HTMLDivElement>(null);
    const dimRef = React.useRef<SVGPathElement>(null);
    const tubeRef = React.useRef<SVGGElement>(null);
    const wrapperRef = React.useRef<HTMLDivElement>(null);
    const cardRef = React.useRef<HTMLDivElement>(null);
    const nextRef = React.useRef<HTMLButtonElement>(null);
    React.useImperativeHandle(forwardedRef, () => cardRef.current as HTMLDivElement);

    // The color where the tour started (its container, or the button that opened it), carried into the portals.
    const [inherited] = React.useState(() => {
      const source = host ?? (document.activeElement as HTMLElement | null) ?? document.body;
      const styles = getComputedStyle(source);
      const rgb = styles.getPropertyValue("--neon").trim() || styles.getPropertyValue("--neon-accent").trim();
      return rgb ? ({ "--neon": rgb, "--neon-accent": rgb } as React.CSSProperties) : undefined;
    });
    // Where focus was when the tour opened, read during the first render, before focus moves into the card.
    const [previous] = React.useState(() => document.activeElement as HTMLElement | null);

    const actionsRef = React.useRef(actions);
    React.useEffect(() => {
      actionsRef.current = actions;
    });

    // Geometry lives in refs and is written straight to the SVG and the card: no render per frame.
    const geo = React.useRef({ shown: null as Rect | null, from: null as Rect | null, start: 0, raf: 0, target: null as HTMLElement | null, tries: 0 });
    const stepRef = React.useRef(step);
    React.useEffect(() => {
      stepRef.current = step;
    }, [step]);

    const measure = React.useCallback((): { rect: Rect; origin: Origin } | null => {
      const overlay = overlayRef.current;
      if (!overlay) return null;
      const box = overlay.getBoundingClientRect();
      const s = overlay.offsetWidth ? box.width / overlay.offsetWidth : 1;
      const origin = { x: box.left, y: box.top, s };
      const target = geo.current.target;
      if (!target || !target.isConnected) {
        const w = overlay.offsetWidth || window.innerWidth;
        const h = overlay.offsetHeight || window.innerHeight;
        return { rect: { x: w / 2, y: h / 2, w: 0, h: 0, r: 0 }, origin };
      }
      const t = target.getBoundingClientRect();
      if (t.width === 0 && t.height === 0) {
        // Hidden (display: none at this breakpoint): the step falls back to a centered card.
        const w = overlay.offsetWidth || window.innerWidth;
        const h = overlay.offsetHeight || window.innerHeight;
        return { rect: { x: w / 2, y: h / 2, w: 0, h: 0, r: 0 }, origin };
      }
      const { padding = 6, radius } = stepRef.current;
      const own = parseFloat(getComputedStyle(target).borderTopLeftRadius) || 0;
      return {
        rect: {
          x: (t.left - box.left) / s - padding,
          y: (t.top - box.top) / s - padding,
          w: t.width / s + padding * 2,
          h: t.height / s + padding * 2,
          r: radius ?? Math.min(own + padding, 24),
        },
        origin,
      };
    }, []);

    const place = React.useCallback((rect: Rect, origin: Origin, centered: boolean) => {
      const wrapper = wrapperRef.current;
      if (!wrapper) return;
      const { side = "bottom", align = "center" } = stepRef.current;
      if (centered) {
        const w = wrapper.offsetWidth;
        const h = wrapper.offsetHeight;
        const cx = origin.x + (rect.x + rect.w / 2) * origin.s;
        const cy = origin.y + (rect.y + rect.h / 2) * origin.s;
        const x = Math.min(Math.max(cx - w / 2, 12), window.innerWidth - w - 12);
        const y = Math.min(Math.max(cy - h / 2, 12), window.innerHeight - h - 12);
        wrapper.style.transform = `translate3d(${Math.round(x)}px, ${Math.round(y)}px, 0)`;
        wrapper.dataset.side = "none";
        wrapper.style.opacity = "";
        return;
      }
      const reference: VirtualElement = {
        getBoundingClientRect: () => {
          const left = origin.x + rect.x * origin.s;
          const top = origin.y + rect.y * origin.s;
          const width = rect.w * origin.s;
          const height = rect.h * origin.s;
          return { x: left, y: top, left, top, width, height, right: left + width, bottom: top + height };
        },
      };
      computePosition(reference, wrapper, {
        strategy: "fixed",
        placement: (align === "center" ? side : `${side}-${align}`) as Placement,
        // Opposite side first, then the perpendicular ones; on a phone a side card often has to go above or below.
        middleware: [offset(12), flip({ padding: 12, fallbackAxisSideDirection: "start" }), shift({ padding: 12, crossAxis: true })],
      }).then(({ x, y, placement }) => {
        wrapper.style.transform = `translate3d(${Math.round(x)}px, ${Math.round(y)}px, 0)`;
        // The light leak sits opposite the target's centre, kept clear of the card's corners.
        const box = reference.getBoundingClientRect();
        const clamp = (value: number, max: number) => `${Math.round(Math.min(Math.max(value, 48), Math.max(max - 48, 48)))}px`;
        wrapper.style.setProperty("--nk-leak-x", clamp(box.left + box.width / 2 - x, wrapper.offsetWidth));
        wrapper.style.setProperty("--nk-leak-y", clamp(box.top + box.height / 2 - y, wrapper.offsetHeight));
        wrapper.dataset.side = placement.split("-")[0];
        wrapper.style.opacity = "";
      });
    }, []);

    const write = React.useCallback(
      (rect: Rect, origin: Origin, centered: boolean) => {
        geo.current.shown = rect;
        dimRef.current?.setAttribute("d", dimPath(rect));
        const tube = tubeRef.current;
        if (tube) {
          tube.style.display = rect.w < 2 || rect.h < 2 ? "none" : "";
          for (const node of Array.from(tube.children) as SVGRectElement[]) {
            const inset = Number(node.dataset.inset ?? 0);
            node.setAttribute("x", String(round(rect.x + inset)));
            node.setAttribute("y", String(round(rect.y + inset)));
            node.setAttribute("width", String(round(Math.max(0, rect.w - inset * 2))));
            node.setAttribute("height", String(round(Math.max(0, rect.h - inset * 2))));
            node.setAttribute("rx", String(round(Math.max(0, Math.min(rect.r - inset, rect.w / 2, rect.h / 2)))));
          }
        }
        place(rect, origin, centered);
      },
      [place],
    );

    const frameRef = React.useRef<(now: number) => void>(() => {});
    const frame = React.useCallback(
      (now: number) => {
        const g = geo.current;
        g.raf = 0;
        const measured = measure();
        if (!measured) return;
        const { rect, origin } = measured;
        const t = reduceMotion || !g.from ? 1 : Math.min(1, (now - g.start) / GLIDE);
        write(g.from && t < 1 ? lerpRect(g.from, rect, easeOutQuint(t)) : rect, origin, rect.w < 1 && rect.h < 1);
        if (t < 1) g.raf = requestAnimationFrame((time) => frameRef.current(time));
        else g.from = null;
      },
      [measure, write, reduceMotion],
    );
    React.useEffect(() => {
      frameRef.current = frame;
    }, [frame]);

    const schedule = React.useCallback(() => {
      if (!geo.current.raf) geo.current.raf = requestAnimationFrame(frame);
    }, [frame]);

    // New step: find its target (it may mount a frame or two late), bring it into view, glide to it.
    React.useEffect(() => {
      const g = geo.current;
      let cancelled = false;
      let retry = 0;
      const scope = host ?? document;
      const begin = () => {
        if (cancelled) return;
        const target = resolveTarget(step.target, scope);
        if (step.target && !target && g.tries < 30) {
          g.tries += 1;
          retry = requestAnimationFrame(begin);
          return;
        }
        g.tries = 0;
        g.target = target;
        const measured = measure();
        if (!measured) return;
        const { rect } = measured;
        // The first step contracts onto its target, like a lens focusing; later steps glide from the last.
        g.from = g.shown ?? { x: rect.x - 14, y: rect.y - 14, w: rect.w + 28, h: rect.h + 28, r: rect.r + 14 };
        g.start = performance.now();
        if (target) {
          const box = target.getBoundingClientRect();
          const margin = 96;
          if (box.top < margin || box.bottom > window.innerHeight - margin || box.left < 0 || box.right > window.innerWidth) {
            target.scrollIntoView({ block: "center", inline: "nearest", behavior: reduceMotion ? "auto" : "smooth" });
          }
        }
        cancelAnimationFrame(g.raf);
        g.raf = 0;
        schedule();
      };
      begin();
      return () => {
        cancelled = true;
        cancelAnimationFrame(retry);
      };
      // Keyed on the index and target, not the step object, so an inline steps array doesn't restart the glide.
    }, [index, step.target, host, measure, schedule, reduceMotion]);

    // Follow the target through scrolling, resizing and its own layout changes.
    React.useEffect(() => {
      const g = geo.current;
      window.addEventListener("scroll", schedule, { capture: true, passive: true });
      window.addEventListener("resize", schedule);
      const observer = typeof ResizeObserver !== "undefined" ? new ResizeObserver(schedule) : null;
      if (g.target) observer?.observe(g.target);
      if (wrapperRef.current) observer?.observe(wrapperRef.current);
      return () => {
        window.removeEventListener("scroll", schedule, { capture: true });
        window.removeEventListener("resize", schedule);
        observer?.disconnect();
      };
    }, [schedule, index]);

    React.useEffect(() => {
      const g = geo.current;
      return () => cancelAnimationFrame(g.raf);
    }, []);

    // Focus moves into the card, and back to wherever it was as soon as the tour closes.
    React.useEffect(() => {
      if (present) {
        nextRef.current?.focus({ preventScroll: true });
        return;
      }
      if (previous && previous.isConnected && previous !== document.body) previous.focus({ preventScroll: true });
    }, [present, previous]);

    // A step change can hide the focused button (Back on the first step): keep focus in the card.
    React.useEffect(() => {
      const card = cardRef.current;
      if (card && !card.contains(document.activeElement)) nextRef.current?.focus({ preventScroll: true });
    }, [index]);

    React.useEffect(() => {
      if (!present) return;
      const onKey = (event: KeyboardEvent) => {
        if (event.defaultPrevented) return;
        const target = event.target as HTMLElement | null;
        const typing = target?.closest("input, textarea, select, [contenteditable='true']");
        if (event.key === "Escape") {
          event.preventDefault();
          actionsRef.current.skip();
        } else if (!typing && event.key === "ArrowRight") {
          event.preventDefault();
          actionsRef.current.next();
        } else if (!typing && event.key === "ArrowLeft") {
          event.preventDefault();
          actionsRef.current.back();
        }
      };
      const onFocusIn = (event: FocusEvent) => {
        const card = cardRef.current;
        if (card && event.target instanceof Node && !card.contains(event.target)) nextRef.current?.focus({ preventScroll: true });
      };
      document.addEventListener("keydown", onKey);
      document.addEventListener("focusin", onFocusIn);
      return () => {
        document.removeEventListener("keydown", onKey);
        document.removeEventListener("focusin", onFocusIn);
      };
    }, [present]);

    const trapTab = (event: React.KeyboardEvent<HTMLDivElement>) => {
      onKeyDown?.(event);
      if (event.defaultPrevented || event.key !== "Tab") return;
      const items = Array.from(event.currentTarget.querySelectorAll<HTMLElement>(TABBABLE));
      if (!items.length) return;
      const first = items[0];
      const lastItem = items[items.length - 1];
      if (event.shiftKey && document.activeElement === first) {
        event.preventDefault();
        lastItem.focus();
      } else if (!event.shiftKey && document.activeElement === lastItem) {
        event.preventDefault();
        first.focus();
      }
    };

    const fid = `${id}-glow`.replace(/:/g, "");
    const titleId = `${id}-title`;
    const bodyId = `${id}-body`;
    const fade = { duration: reduceMotion ? 0.12 : 0.2, ease: EASE };

    const overlay = (
      <motion.div
        ref={overlayRef}
        aria-hidden
        data-nk-tour-overlay=""
        initial={{ opacity: 0 }}
        animate={{ opacity: 1 }}
        exit={{ opacity: 0 }}
        transition={fade}
        style={{ ...inherited, ...neonVars(color) }}
        className={cn(
          "[--neon:var(--neon-accent,34_211_238)]",
          host ? "absolute inset-0 z-40 rounded-[inherit]" : "fixed inset-0 z-[60]",
          "overflow-hidden",
          !present && "pointer-events-none",
        )}
      >
        <style href="nk-tour" precedence="nk">{css}</style>
        <svg className="absolute inset-0 size-full">
          <defs>
            <filter id={fid} x="-50%" y="-50%" width="200%" height="200%">
              <feGaussianBlur stdDeviation="5" />
            </filter>
          </defs>
          <path ref={dimRef} fillRule="evenodd" style={{ fill: "rgb(3 4 8 / 0.74)" }} d={dimPath({ x: 0, y: 0, w: 0, h: 0, r: 0 })} />
          <g ref={tubeRef} className="nk-tour-tube" style={{ display: "none" }}>
            <rect
              filter={`url(#${fid})`}
              style={{ fill: "none", stroke: "rgb(var(--neon))", strokeWidth: 5, opacity: "calc(0.55 * var(--neon-glow, 1))" }}
            />
            <rect style={{ fill: "rgb(var(--neon) / 0.035)", stroke: "rgb(var(--neon))", strokeWidth: 1.5 }} />
            <rect data-inset="0.3" style={{ fill: "none", stroke: "color-mix(in srgb, rgb(var(--neon)) 45%, white)", strokeWidth: 0.6 }} />
          </g>
        </svg>
      </motion.div>
    );

    const card = (
      <div
        ref={wrapperRef}
        // Transparent, not hidden, until placed: a hidden button can't take focus.
        style={{ ...inherited, ...neonVars(color), opacity: 0 }}
        className={cn("[--neon:var(--neon-accent,34_211_238)] fixed left-0 top-0 z-[61] w-max", !present && "pointer-events-none")}
      >
        <motion.div
          {...props}
          ref={cardRef}
          role="dialog"
          aria-modal="true"
          aria-labelledby={titleId}
          aria-describedby={step.body ? bodyId : undefined}
          initial={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.96 }}
          animate={{ opacity: 1, scale: 1 }}
          exit={reduceMotion ? { opacity: 0 } : { opacity: 0, scale: 0.96 }}
          transition={fade}
          onKeyDown={trapTab}
          style={style}
          className={cn(
            "relative w-[min(20rem,calc(100vw-24px))] rounded-xl border border-white/10 bg-[#0a0b11] p-4 text-white/90 outline-none",
            "shadow-[0_18px_48px_-12px_rgb(0_0_0/0.85),0_0_0_1px_rgb(var(--neon)/0.06),0_0_28px_-10px_rgb(var(--neon)/CALC(0.45*var(--neon-glow,1)))]",
            className,
          )}
        >
          {/* Light leaks in along the edge facing the target. */}
          <span aria-hidden className="pointer-events-none absolute -inset-px overflow-hidden rounded-[inherit]">
            <span data-edge="top" className="nk-tour-leak absolute left-[var(--nk-leak-x,50%)] top-0 h-px w-40 -translate-x-1/2 bg-[linear-gradient(90deg,transparent,rgb(var(--neon)/0.75),transparent)]" />
            <span data-edge="bottom" className="nk-tour-leak absolute bottom-0 left-[var(--nk-leak-x,50%)] h-px w-40 -translate-x-1/2 bg-[linear-gradient(90deg,transparent,rgb(var(--neon)/0.75),transparent)]" />
            <span data-edge="left" className="nk-tour-leak absolute left-0 top-[var(--nk-leak-y,50%)] h-24 w-px -translate-y-1/2 bg-[linear-gradient(180deg,transparent,rgb(var(--neon)/0.75),transparent)]" />
            <span data-edge="right" className="nk-tour-leak absolute right-0 top-[var(--nk-leak-y,50%)] h-24 w-px -translate-y-1/2 bg-[linear-gradient(180deg,transparent,rgb(var(--neon)/0.75),transparent)]" />
          </span>

          <motion.div
            key={index}
            initial={reduceMotion ? false : { opacity: 0, y: 3 }}
            animate={{ opacity: 1, y: 0 }}
            transition={{ duration: 0.22, ease: EASE, delay: 0.04 }}
          >
            <div className="flex items-baseline justify-between gap-4">
              <h2 id={titleId} className="text-[15px] font-medium leading-snug text-white">
                {step.title}
              </h2>
              {steps.length > 1 && (
                <span className="shrink-0 text-xs tabular-nums text-white/45">
                  {index + 1} of {steps.length}
                </span>
              )}
            </div>
            {step.body && (
              <div id={bodyId} className="mt-1.5 text-[13.5px] leading-[1.55] text-white/60">
                {step.body}
              </div>
            )}
          </motion.div>
          <span className="sr-only" aria-live="polite">{steps.length > 1 ? `Step ${index + 1} of ${steps.length}` : ""}</span>

          <div className="mt-4 flex items-center gap-2">
            {!last && (
              <button
                type="button"
                onClick={() => actions.skip()}
                className="-ml-1.5 h-8 cursor-pointer rounded-md px-1.5 text-[13px] text-white/50 outline-none transition-colors duration-150 hover:text-white/85 focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)]"
              >
                {labels?.skip ?? "Skip tour"}
              </button>
            )}
            <div className="ml-auto flex items-center gap-2">
              {index > 0 && (
                <button
                  type="button"
                  onClick={() => actions.back()}
                  className="h-8 cursor-pointer rounded-md border border-white/10 px-3 text-[13px] text-white/75 outline-none transition-[border-color,color,transform] duration-150 hover:border-white/20 hover:text-white active:scale-[0.97] focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#0a0b11]"
                >
                  {labels?.back ?? "Back"}
                </button>
              )}
              <button
                ref={nextRef}
                type="button"
                onClick={() => actions.next()}
                className={cn(
                  "h-8 cursor-pointer rounded-md bg-[rgb(var(--neon))] px-3.5 text-[13px] font-medium text-[#05060a] outline-none transition-[box-shadow,transform] duration-150 active:scale-[0.97]",
                  "shadow-[0_0_0_1px_rgb(var(--neon)),0_0_14px_rgb(var(--neon)/CALC(0.4*var(--neon-glow,1))),inset_0_1px_0_rgb(255_255_255/0.45)] hover:shadow-[0_0_0_1px_rgb(var(--neon)),0_0_22px_rgb(var(--neon)/CALC(0.6*var(--neon-glow,1))),inset_0_1px_0_rgb(255_255_255/0.45)]",
                  "focus-visible:ring-2 focus-visible:ring-[rgb(var(--neon)/0.6)] focus-visible:ring-offset-2 focus-visible:ring-offset-[#0a0b11]",
                )}
              >
                {last ? (labels?.done ?? "Done") : (labels?.next ?? "Next")}
              </button>
            </div>
          </div>
        </motion.div>
      </div>
    );

    return (
      <>
        {createPortal(overlay, host ?? document.body)}
        {createPortal(card, document.body)}
      </>
    );
  },
);
TourLayer.displayName = "TourLayer";

export { Tour };
```
