Steps with their own icons. Sources collect as chips while the agent searches, pages tick off as it reads, and the answer cites what it found once the trail folds away. Open Found 9 sources to see them again.
@keyframeui
registry/demos/thought-trail-research-demo.tsx
"use client";
import * as React from "react";
import { AnimatePresence, motion, useInView } from "motion/react";
import { cn } from "@/lib/utils";
import { ThoughtTrail, ThoughtTrailStep } from "@/components/ui/thought-trail";
const question = "Which vector database should we use for 10M embeddings?";
type Source = { domain: string; tint: string };
// Monograms instead of favicons, so the demo needs no network.
const sources: Source[] = [
{ domain: "github.com", tint: "bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900" },
{ domain: "qdrant.tech", tint: "bg-rose-500/15 text-rose-700 dark:text-rose-300" },
{ domain: "pinecone.io", tint: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300" },
{ domain: "news.ycombinator.com", tint: "bg-orange-500/15 text-orange-700 dark:text-orange-300" },
{ domain: "supabase.com", tint: "bg-teal-500/15 text-teal-700 dark:text-teal-300" },
{ domain: "weaviate.io", tint: "bg-lime-500/15 text-lime-700 dark:text-lime-300" },
{ domain: "timescale.com", tint: "bg-amber-500/15 text-amber-700 dark:text-amber-300" },
{ domain: "ann-benchmarks.com", tint: "bg-sky-500/15 text-sky-700 dark:text-sky-300" },
{ domain: "aws.amazon.com", tint: "bg-violet-500/15 text-violet-700 dark:text-violet-300" },
];
const searches = [
"pgvector vs dedicated vector databases at 10M rows",
"HNSW memory use for 1536-dimension embeddings",
"Filtered vector search at high query rates",
];
const reads = [
{ title: "HNSW vs IVFFlat indexes in pgvector", source: 0 },
{ title: "Payload indexes and filtered search", source: 1 },
{ title: "Choosing an index for tens of millions of vectors", source: 2 },
{ title: "Ask HN: Running pgvector in production?", source: 3 },
];
const comparison =
"Ten million 1536-dimension vectors is about 60 GB of floats before the index, which still fits one large Postgres instance. pgvector keeps vectors next to the rows they describe, so there's no second system to sync. Qdrant and Pinecone pull ahead when filters get heavy at high query rates, or when the index outgrows one machine.";
type Phase = { title: string; summary: string; ms: number; icon: React.ReactNode };
const phases: Phase[] = [
{ title: "Planning the search", summary: "Planned 3 searches", ms: 2200, icon: <ListIcon /> },
{ title: "Searching the web", summary: `Found ${sources.length} sources`, ms: 3000, icon: <GlobeIcon /> },
{ title: `Reading ${reads.length} pages`, summary: `Read ${reads.length} pages`, ms: 3600, icon: <PageIcon /> },
{ title: "Comparing the options", summary: "Compared 3 options", ms: 3400, icon: <ScaleIcon /> },
];
const LEAD = 600;
const starts = phases.map((_, i) => LEAD + phases.slice(0, i).reduce((sum, p) => sum + p.ms, 0));
const END = LEAD + phases.reduce((sum, p) => sum + p.ms, 0);
/** 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;
}
// A research agent. Sources collect as chips while it searches, pages tick
// off as it reads them, and once it's done the trail folds away and the
// answer cites what it found. Open "Found 9 sources" to see them all again.
export function ThoughtTrailResearchDemo() {
const ref = React.useRef<HTMLDivElement>(null);
const playing = useInView(ref, { once: true, amount: 0.4 });
const [run, setRun] = React.useState(0);
const t = usePlayhead(run, playing, END + 300);
const finished = t >= END;
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 items-start gap-4 border-b border-zinc-100 px-5 py-4 dark:border-zinc-800/80">
<div className="min-w-0 flex-1">
<span className="inline-flex items-center gap-1 rounded-full bg-zinc-100 px-2 py-0.5 text-[11px] font-medium text-zinc-600 dark:bg-zinc-800 dark:text-zinc-300">
<span aria-hidden className="size-3 [&_svg]:size-3">
<GlobeIcon />
</span>
Deep research
</span>
<p className="mt-2 text-[15px] leading-snug font-medium text-pretty text-zinc-900 dark:text-zinc-100">
{question}
</p>
</div>
<button
type="button"
onClick={() => setRun((r) => r + 1)}
className="inline-flex h-8 shrink-0 items-center 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"
>
Run again
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto px-5 py-4">
{playing ? (
<ThoughtTrail
running={!finished}
label="Researching"
doneLabel={({ seconds }) => `Researched for ${seconds ?? 0}s · ${sources.length} sources`}
>
{phases.map((phase, i) => {
if (t < starts[i]) return null;
const progress = Math.min(1, (t - starts[i]) / (phase.ms * 0.85));
const done = t >= starts[i] + phase.ms;
return (
<ThoughtTrailStep
key={phase.title}
status={done ? "done" : "active"}
title={phase.title}
summary={phase.summary}
icon={phase.icon}
meta={i === 1 ? `${Math.ceil(progress * sources.length)}` : undefined}
>
<PhaseDetails index={i} progress={progress} />
</ThoughtTrailStep>
);
})}
</ThoughtTrail>
) : null}
<AnimatePresence>
{finished ? (
<motion.div
key={run}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.35, ease: "easeOut", delay: 0.2 }}
className="mt-4 space-y-3 text-sm leading-relaxed text-pretty text-zinc-800 dark:text-zinc-200"
>
<p>
<strong className="font-semibold text-zinc-950 dark:text-zinc-50">Start with pgvector</strong> if
you're already on Postgres. At 10M vectors an HNSW index still fits one large instance, and you
keep a single database to run and back up. <Cite n={1} /> <Cite n={4} />
</p>
<p>
Move to a dedicated engine like Qdrant when filtered queries at high traffic start to dominate, or
the index needs to shard across machines. <Cite n={2} /> <Cite n={3} />
</p>
</motion.div>
) : null}
</AnimatePresence>
</div>
</div>
);
}
function PhaseDetails({ index, progress }: { index: number; progress: number }) {
if (index === 0) {
return (
<ul className="space-y-1">
{searches.slice(0, Math.ceil(progress * searches.length)).map((query) => (
<li key={query} className="flex items-center gap-2">
<span aria-hidden className="size-3 shrink-0 text-zinc-400 dark:text-zinc-500 [&_svg]:size-3">
<SearchIcon />
</span>
<span className="truncate">{query}</span>
</li>
))}
</ul>
);
}
if (index === 1) {
return (
<div className="flex flex-wrap gap-1.5">
{sources.slice(0, Math.ceil(progress * sources.length)).map((source) => (
<motion.span
key={source.domain}
initial={{ opacity: 0, scale: 0.85 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: "spring", visualDuration: 0.25, bounce: 0.3 }}
className="inline-flex items-center gap-1.5 rounded-full border border-zinc-200 bg-white py-0.5 pr-2 pl-0.5 text-xs text-zinc-600 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-400"
>
<Monogram source={source} />
{source.domain}
</motion.span>
))}
</div>
);
}
if (index === 2) {
return (
<ul className="space-y-1.5">
{reads.map((page, i) => {
// Pages are read one after another across the step.
const read = progress >= (i + 1) / reads.length;
const reading = !read && progress >= i / reads.length;
return (
<li
key={page.title}
className={cn(
"flex min-w-0 items-center gap-2 transition-opacity duration-300",
!read && !reading && "opacity-40",
)}
>
<Monogram source={sources[page.source]} />
<span className={cn("truncate", reading && "text-zinc-900 dark:text-zinc-100")}>{page.title}</span>
<span className="ml-auto shrink-0 text-xs text-zinc-400 dark:text-zinc-500">
{read ? "Read" : reading ? "Reading…" : ""}
</span>
</li>
);
})}
</ul>
);
}
const words = comparison.split(" ");
return <p className="text-pretty">{words.slice(0, Math.ceil(progress * words.length)).join(" ")}</p>;
}
function Monogram({ source }: { source: Source }) {
return (
<span
aria-hidden
className={cn(
"flex size-4 shrink-0 items-center justify-center rounded-full text-[9px] font-semibold uppercase",
source.tint,
)}
>
{source.domain[0]}
</span>
);
}
function Cite({ n }: { n: number }) {
const source = sources[reads[n - 1].source];
return (
<span
title={source.domain}
className="inline-flex h-4 min-w-4 -translate-y-px items-center justify-center rounded bg-zinc-100 px-1 align-middle text-[10px] font-medium text-zinc-500 tabular-nums dark:bg-zinc-800 dark:text-zinc-400"
>
{n}
</span>
);
}
function GlobeIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden>
<circle cx="8" cy="8" r="6.25" stroke="currentColor" strokeWidth="1.4" />
<path d="M1.75 8h12.5M8 1.75c1.8 1.7 2.6 3.8 2.6 6.25S9.8 12.55 8 14.25C6.2 12.55 5.4 10.45 5.4 8S6.2 3.45 8 1.75Z" stroke="currentColor" strokeWidth="1.4" />
</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 ListIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden>
<path d="M6 4h7.5M6 8h7.5M6 12h7.5M2.75 4h.5M2.75 8h.5M2.75 12h.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>
);
}
function ScaleIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden>
<path
d="M8 2v12M4.5 14h7M3 4.5h10M3 4.5 1.25 9a2 2 0 0 0 3.5 0L3 4.5Zm10 0L11.25 9a2 2 0 0 0 3.5 0L13 4.5Z"
stroke="currentColor"
strokeWidth="1.3"
strokeLinejoin="round"
strokeLinecap="round"
/>
</svg>
);
}
Live preview, examples and props: https://keyframeui.com/components/thought-trail#thought-trail-research-demo





























