# Waypoint — AI Chat UI Component for React

> Live agent plan checklist — status markers on a vertical rail where one continuous accent line grows like mercury to the active step, which pings softly. Done steps draw a check and dim, skipped go dashed, failed turn rust; when the plan is revised, inserted steps expand in and dropped ones collapse out. Rolling counter, progress bar, ordered-list semantics with aria-current and polite announcements.

- Page: https://crucibleui.com/components/ai/waypoint
- Category: [ai](https://crucibleui.com/components/ai)
- Tier: pro (one-time purchase, see https://crucibleui.com/pricing)
- Engine: motion
- npm dependencies: motion

## Install

```bash
npx shadcn@latest add @crucible/waypoint
```

Pro install requires adding the token-bearing `@crucible` registry to components.json (issued at https://crucibleui.com/account after purchase).

Import from `@/components/crucible/waypoint`.

## Props (`WaypointProps`)

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `title?` | `string` | `"Plan"` | Plan heading. |
| `steps?` | `WaypointStep[]` | `a static sample plan` | The plan's current state (controlled). Drive it from your agent's events; change the array (insert/remove ids) to animate a re-plan. |
| `collapsedDone?` | `boolean` | `true` | Hide details and sub-steps of completed steps. |
| `accentColor?` | `string` | `"#a78bfa" (ion violet)` | Accent for the rail fill, active marker, and progress bar. |
| `showHeader?` | `boolean` | `true` | Show the header (title, rolling counter, progress bar). |

Also accepts: `Omit<React.ComponentPropsWithoutRef<"div">, "title">`.

## Usage example

```tsx
"use client";

import * as React from "react";

import { useVisibilityPause } from "@/hooks/use-visibility-pause";
import {
  Waypoint,
  type WaypointStatus,
  type WaypointStep,
} from "@/components/crucible/waypoint";

type Def = Omit<WaypointStep, "status" | "substeps"> & { substeps?: string[] };

const DEFS: Record<string, Def> = {
  read: { id: "read", title: "Read the request", detail: "Parsing scope and acceptance criteria" },
  search: {
    id: "search",
    title: "Search the codebase",
    detail: "Looking for every place the setting is read",
    substeps: ["Find usages", "Read related tests"],
  },
  draft: { id: "draft", title: "Draft the approach", detail: "Choosing the smallest safe change" },
  update: { id: "update", title: "Update the components", detail: "Editing the settings panel" },
  hook: {
    id: "hook",
    title: "Extract a shared hook",
    detail: "Two components duplicate the same logic",
  },
  sites: { id: "sites", title: "Update call sites", detail: "Swapping both components to the hook" },
  visual: { id: "visual", title: "Run a visual check", detail: "Capturing before and after" },
  summary: { id: "summary", title: "Summarize changes", detail: "Writing up what changed and why" },
};

const PLAN_A = ["read", "search", "draft", "update", "visual", "summary"];
const PLAN_B = ["read", "search", "draft", "hook", "sites", "visual", "summary"];

/** Build one frame: ids in order, a status per id (default todo), optional substep statuses. */
function frame(
  plan: string[],
  statuses: Record<string, WaypointStatus>,
  subs: WaypointStatus[] = ["todo", "todo"]
): WaypointStep[] {
  return plan.map((id) => {
    const def = DEFS[id];
    const status = statuses[id] ?? "todo";
    return {
      id,
      title: def.title,
      detail: def.detail,
      status,
      substeps: def.substeps?.map((t, i) => ({
        id: `${id}-${i}`,
        title: t,
        status: status === "done" ? "done" : status === "active" ? subs[i] : "todo",
      })),
    };
  });
}

const D = "done" as const;
const A = "active" as const;

/** Scripted run: [steps, hold seconds]. Deterministic — identical on server and client. */
const SCRIPT: [WaypointStep[], number][] = [
  [frame(PLAN_A, { read: A }), 1.4],
  [frame(PLAN_A, { read: D, search: A }, ["active", "todo"]), 1.2],
  [frame(PLAN_A, { read: D, search: A }, ["done", "active"]), 1.2],
  [frame(PLAN_A, { read: D, search: D, draft: A }), 1.5],
  [frame(PLAN_A, { read: D, search: D, draft: D, update: A }), 1.6],
  // Re-plan: "update" is split into two new steps.
  [frame(PLAN_B, { read: D, search: D, draft: D, hook: A }), 1.8],
  [frame(PLAN_B, { read: D, search: D, draft: D, hook: D, sites: A }), 1.5],
  [frame(PLAN_B, { read: D, search: D, draft: D, hook: D, sites: D, visual: A }), 1.3],
  [
    frame(PLAN_B, {
      read: D,
      search: D,
      draft: D,
      hook: D,
      sites: D,
      visual: "skipped",
      summary: A,
    }),
    1.5,
  ],
  [
    frame(PLAN_B, {
      read: D,
      search: D,
      draft: D,
      hook: D,
      sites: D,
      visual: "skipped",
      summary: D,
    }),
    3,
  ],
];

const STARTS = SCRIPT.reduce<number[]>((acc, _entry, i) => {
  acc.push(i === 0 ? 0 : acc[i - 1] + SCRIPT[i - 1][1]);
  return acc;
}, []);
const CYCLE = STARTS[STARTS.length - 1] + SCRIPT[SCRIPT.length - 1][1];

export default function WaypointDemo() {
  const [index, setIndex] = React.useState(0);
  const indexRef = React.useRef(0);

  // Timeline advances only while visible (offscreen / hidden tab pauses it).
  const stageRef = useVisibilityPause<HTMLDivElement>((elapsed) => {
    const t = elapsed % CYCLE;
    let next = 0;
    for (let i = STARTS.length - 1; i >= 0; i--) {
      if (t >= STARTS[i]) {
        next = i;
        break;
      }
    }
    if (next !== indexRef.current) {
      indexRef.current = next;
      setIndex(next);
    }
  });

  return (
    <div
      ref={stageRef}
      className="relative isolate flex h-full min-h-[360px] w-full items-center justify-center overflow-hidden rounded-xl bg-neutral-950"
    >
      <div className="relative z-10 w-full max-w-sm px-6 py-8">
        <Waypoint title="Refactor the settings panel" steps={SCRIPT[index][0]} />
        <p className="mt-3 text-center text-[11px] font-medium uppercase tracking-[0.2em] text-neutral-500">
          Watch the agent revise its plan mid-run.
        </p>
      </div>
    </div>
  );
}
```

## Accessibility & performance

Honors `prefers-reduced-motion` with a designed static frame or fade (never a blank box) and passes `className` through.
