# Thought Trail: Chat Reasoning

> A chat reply that thinks first. The message's parts are shaped like the AI SDK's, and each reasoning or tool part becomes a step. The trail folds into "Thought for 9s" as soon as the answer starts streaming below it.

A shadcn/ui block from the [@keyframeui](https://keyframeui.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/keyframeui/thought-trail-chat-demo).

- Page, with a live preview: https://shadcn.exchange/r/keyframeui/thought-trail-chat-demo
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/keyframeui/thought-trail-chat-demo-light-fe49fa4ecffd5c02.png
- Registry item: https://keyframeui.com/r/thought-trail-chat-demo.json
- Categories: ai, status, motion
- Listed: 2026-10-14, source last changed 2026-10-15

## Install

```bash
npx shadcn@latest add https://keyframeui.com/r/thought-trail-chat-demo.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- Registry items: `https://keyframeui.com/r/thought-trail.json`

## Notes from the registry

Live preview, examples and props: https://keyframeui.com/components/thought-trail#thought-trail-chat-demo

## Files

### `registry/demos/thought-trail-chat-demo.tsx`

````tsx
"use client";

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

import { ThoughtTrail, ThoughtTrailStep } from "@/components/ui/thought-trail";

/* Parts shaped like the AI SDK's UIMessage parts. With useChat, drop the
 * script below and render:
 *
 *   <ReasoningTrail parts={message.parts} streaming={status === "streaming"} />
 */
type ToolState = "input-streaming" | "input-available" | "output-available" | "output-error";
type Page = { title: string; path: string };
type Part =
  | { type: "reasoning"; text: string; state: "streaming" | "done" }
  | {
      type: "tool-searchDocs";
      toolCallId: string;
      state: ToolState;
      input: { query: string };
      output?: { pages: Page[] };
    }
  | {
      type: "tool-readPage";
      toolCallId: string;
      state: ToolState;
      input: { path: string };
      output?: { title: string; excerpt: string };
    }
  | { type: "text"; text: string; state: "streaming" | "done" };

/** Every part but the answer becomes a step. The last one works until the next begins, as it does in a stream. */
function ReasoningTrail({ parts, streaming }: { parts: Part[]; streaming: boolean }) {
  const steps = parts.filter((part) => part.type !== "text");
  const answering = parts.some((part) => part.type === "text");
  return (
    <ThoughtTrail
      running={streaming && !answering}
      label="Thinking"
      doneLabel={({ seconds }) => (seconds ? `Thought for ${seconds}s` : "Thought")}
      icon={<Sparkle />}
    >
      {steps.map((part, i) => {
        const last = i === steps.length - 1 && streaming && !answering;
        const failed = "toolCallId" in part && part.state === "output-error";
        const status = failed ? "error" : last ? "active" : "done";
        return <PartStep key={"toolCallId" in part ? part.toolCallId : `reasoning-${i}`} part={part} status={status} />;
      })}
    </ThoughtTrail>
  );
}

function PartStep({ part, status }: { part: Part; status: "active" | "done" | "error" }) {
  switch (part.type) {
    case "reasoning": {
      // Reasoning summaries open with a bold heading; it names the step.
      const [, heading, body] = part.text.match(/^\*\*(.+?)\*\*\s*([\s\S]*)$/) ?? [null, "Thinking", part.text];
      return (
        <ThoughtTrailStep status={status} title={heading}>
          {body ? <p className="text-pretty">{body}</p> : null}
        </ThoughtTrailStep>
      );
    }
    case "tool-searchDocs":
      return (
        <ThoughtTrailStep
          status={status}
          title="Searching the React docs"
          summary={part.output ? `Found ${part.output.pages.length} pages` : "Search failed"}
          icon={<SearchIcon />}
        >
          <p className="text-zinc-400 dark:text-zinc-500">“{part.input.query}”</p>
          {part.output ? (
            <ul className="mt-1.5 space-y-1">
              {part.output.pages.map((page) => (
                <li key={page.path} className="flex min-w-0 items-baseline gap-2">
                  <span className="shrink-0 text-zinc-700 dark:text-zinc-300">{page.title}</span>
                  <span className="truncate font-mono text-[11px] text-zinc-400 dark:text-zinc-500">{page.path}</span>
                </li>
              ))}
            </ul>
          ) : null}
        </ThoughtTrailStep>
      );
    case "tool-readPage":
      return (
        <ThoughtTrailStep
          status={status}
          title={`Reading react.dev${part.input.path}`}
          summary={part.output ? `Read “${part.output.title}”` : "Couldn't read the page"}
          icon={<PageIcon />}
        >
          {part.output ? (
            <blockquote className="border-l-2 border-zinc-200 pl-3 text-pretty text-zinc-600 dark:border-zinc-700 dark:text-zinc-400">
              {part.output.excerpt}
            </blockquote>
          ) : (
            <p className="font-mono text-[11px] text-zinc-400 dark:text-zinc-500">GET react.dev{part.input.path}</p>
          )}
        </ThoughtTrailStep>
      );
    default:
      return null;
  }
}

/* ── The scripted reply ─────────────────────────────────── */

const question = "Why does my useEffect run twice in development?";

const thoughts = [
  "**Considering Strict Mode**\n\nAn effect that fires twice, but only in development, is almost always Strict Mode remounting the component. Worth checking the docs for the exact behavior before answering.",
  "**Drafting the answer**\n\nConfirmed: development only, setup → cleanup → setup. The fix is a cleanup function, not turning Strict Mode off. A connection makes the clearest example.",
];

const pages: Page[] = [
  { title: "<StrictMode>", path: "/reference/react/StrictMode" },
  { title: "Synchronizing with Effects", path: "/learn/synchronizing-with-effects" },
  { title: "useEffect", path: "/reference/react/useEffect" },
  { title: "You Might Not Need an Effect", path: "/learn/you-might-not-need-an-effect" },
];

const answer = [
  "That's Strict Mode, and it only happens in development. React mounts each component, unmounts it and mounts it again, so every effect runs setup, cleanup, then setup. It's there to catch effects that don't clean up after themselves.",
  "Keep Strict Mode on. Return a cleanup that undoes what the effect did:",
  "```useEffect(() => {\n  const connection = createConnection(roomId);\n  connection.connect();\n  return () => connection.disconnect();\n}, [roomId]);",
  "With the cleanup in place the second run is harmless, and production runs the effect once.",
];

// When each part starts and finishes, in ms.
const T = {
  think: [600, 3300],
  search: [3300, 4800], // the tool returns at 4.8s; the step works until the next part
  read: [5600, 7000],
  draft: [7800, 9700],
  answer: [9900, 13900],
};

const words = (text: string, share: number) => {
  const all = text.split(" ");
  return all.slice(0, Math.ceil(all.length * Math.min(1, Math.max(0, share)))).join(" ");
};
const share = (t: number, [from, to]: number[]) => (t - from) / (to - from);

/** The message's parts at `t` ms. */
function partsAt(t: number): Part[] {
  const parts: Part[] = [];
  const reasoning = (text: string, span: number[]) => {
    if (t < span[0]) return;
    const done = t >= span[1];
    // The heading lands whole, then the body streams.
    const [heading, body] = text.split("\n\n");
    parts.push({ type: "reasoning", text: `${heading}\n\n${words(body, share(t, span) * 1.1)}`, state: done ? "done" : "streaming" });
  };
  reasoning(thoughts[0], T.think);
  if (t >= T.search[0]) {
    parts.push({
      type: "tool-searchDocs",
      toolCallId: "call_search",
      state: t >= T.search[1] ? "output-available" : "input-available",
      input: { query: "useEffect runs twice in development" },
      output: t >= T.search[1] ? { pages } : undefined,
    });
  }
  if (t >= T.read[0]) {
    parts.push({
      type: "tool-readPage",
      toolCallId: "call_read",
      state: t >= T.read[1] ? "output-available" : "input-available",
      input: { path: "/reference/react/StrictMode" },
      output:
        t >= T.read[1]
          ? {
              title: "<StrictMode>",
              excerpt:
                "Your components will re-run Effects an extra time to find bugs caused by missing Effect cleanup.",
            }
          : undefined,
    });
  }
  reasoning(thoughts[1], T.draft);
  if (t >= T.answer[0]) {
    const chars = answer.join("\n\n").length * Math.min(1, share(t, T.answer));
    let left = Math.ceil(chars);
    const shown: string[] = [];
    for (const block of answer) {
      if (left <= 0) break;
      shown.push(block.slice(0, left));
      left -= block.length + 2;
    }
    parts.push({ type: "text", text: shown.join("\n\n"), state: t >= T.answer[1] ? "done" : "streaming" });
  }
  return parts;
}

/** Milliseconds into the current run, from when the demo first scrolls into view. Stops at `end`. */
function usePlayhead(run: number, playing: boolean, end: number) {
  const [clock, setClock] = React.useState({ run, t: 0 });
  React.useEffect(() => {
    if (!playing) return;
    const start = performance.now();
    const timer = window.setInterval(() => {
      const t = Math.min(end, performance.now() - start);
      setClock({ run, t });
      if (t >= end) window.clearInterval(timer);
    }, 50);
    return () => window.clearInterval(timer);
  }, [run, playing, end]);
  return clock.run === run ? clock.t : 0;
}

/** Keeps a scrolling box at its bottom while its content grows, until the reader scrolls up. */
function useStickToBottom(ref: React.RefObject<HTMLDivElement | null>) {
  React.useEffect(() => {
    const box = ref.current;
    const content = box?.firstElementChild;
    if (!box || !content) return;
    let stuck = true;
    const onScroll = () => {
      stuck = box.scrollHeight - box.scrollTop - box.clientHeight < 24;
    };
    const observer = new ResizeObserver(() => {
      if (stuck) box.scrollTop = box.scrollHeight;
    });
    observer.observe(content);
    box.addEventListener("scroll", onScroll, { passive: true });
    return () => {
      observer.disconnect();
      box.removeEventListener("scroll", onScroll);
    };
  }, [ref]);
}

// A chat reply that thinks first. The trail opens under the question, folds
// into "Thought for 9s" once the answer starts, and the answer streams below.
export function ThoughtTrailChatDemo() {
  const ref = React.useRef<HTMLDivElement>(null);
  const scrollRef = React.useRef<HTMLDivElement>(null);
  const playing = useInView(ref, { once: true, amount: 0.4 });
  const [run, setRun] = React.useState(0);
  const t = usePlayhead(run, playing, T.answer[1]);
  const parts = playing ? partsAt(t) : [];
  const text = parts.find((part) => part.type === "text");
  useStickToBottom(scrollRef);

  return (
    <div
      ref={ref}
      className="flex h-[34rem] w-full max-w-2xl flex-col overflow-hidden rounded-2xl border border-zinc-200 bg-white shadow-sm shadow-zinc-950/5 dark:border-zinc-800 dark:bg-zinc-950 dark:shadow-black/40"
    >
      <div className="flex h-12 shrink-0 items-center justify-between gap-3 border-b border-zinc-100 px-4 dark:border-zinc-800/80">
        <p className="text-sm font-medium text-zinc-900 dark:text-zinc-100">React help</p>
        <button
          type="button"
          onClick={() => setRun((r) => r + 1)}
          className="inline-flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-xs font-medium text-zinc-600 transition-[background-color,color,scale] duration-150 outline-none select-none hover:bg-zinc-100 hover:text-zinc-950 focus-visible:ring-2 focus-visible:ring-zinc-400 active:scale-[0.96] dark:text-zinc-400 dark:hover:bg-zinc-800 dark:hover:text-zinc-50 dark:focus-visible:ring-zinc-600"
        >
          Ask again
        </button>
      </div>

      <div ref={scrollRef} className="min-h-0 flex-1 overflow-y-auto">
        <div className="flex flex-col gap-5 px-4 py-5 sm:px-6">
          <p className="max-w-[80%] self-end rounded-2xl rounded-br-md bg-zinc-100 px-3.5 py-2 text-sm text-zinc-900 dark:bg-zinc-800 dark:text-zinc-100">
            {question}
          </p>
          {playing ? (
            <div className="min-w-0">
              <ReasoningTrail parts={parts} streaming={t < T.answer[1]} />
              {text && text.type === "text" ? <Answer text={text.text} /> : null}
            </div>
          ) : null}
        </div>
      </div>

      <div className="shrink-0 border-t border-zinc-100 p-3 dark:border-zinc-800/80">
        <div className="flex h-10 items-center rounded-xl border border-zinc-200 px-3.5 text-sm text-zinc-400 dark:border-zinc-800 dark:text-zinc-500">
          Ask a follow-up…
        </div>
      </div>
    </div>
  );
}

function Answer({ text }: { text: string }) {
  return (
    <div className="mt-3 space-y-3 text-sm leading-relaxed text-pretty text-zinc-800 dark:text-zinc-200">
      {text.split("\n\n").map((block, i) =>
        block.startsWith("```") ? (
          <pre
            key={i}
            className="overflow-x-auto rounded-lg border border-zinc-200 bg-zinc-50 px-3.5 py-2.5 font-mono text-[12px] leading-5 text-zinc-700 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-300"
          >
            {block.slice(3)}
          </pre>
        ) : (
          <p key={i}>{block}</p>
        ),
      )}
    </div>
  );
}

function Sparkle() {
  return (
    <svg viewBox="0 0 16 16" fill="none">
      <path
        d="M8 1.75c.35 2.9 1.35 3.9 4.25 4.25-2.9.35-3.9 1.35-4.25 4.25C7.65 7.35 6.65 6.35 3.75 6 6.65 5.65 7.65 4.65 8 1.75Z M12.5 10.5c.15 1.2.55 1.6 1.75 1.75-1.2.15-1.6.55-1.75 1.75-.15-1.2-.55-1.6-1.75-1.75 1.2-.15 1.6-.55 1.75-1.75Z"
        fill="currentColor"
      />
    </svg>
  );
}

function SearchIcon() {
  return (
    <svg viewBox="0 0 16 16" fill="none" aria-hidden>
      <circle cx="7" cy="7" r="4.25" stroke="currentColor" strokeWidth="1.5" />
      <path d="M10.25 10.25 13.5 13.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
    </svg>
  );
}

function PageIcon() {
  return (
    <svg viewBox="0 0 16 16" fill="none" aria-hidden>
      <path d="M4 1.75h5L12.25 5v9.25H4z M9 1.75V5h3.25" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round" />
    </svg>
  );
}
````
