ReasoningTrace
The steps an answer took, arriving one at a time, with the live one breathing.
Reasoning
The idea
A spinner says something is happening and nothing about what. A trace says what, and a breathing dot on the newest step says which part is live, while the steps behind it settle to a quieter grey. It is drawn from a count rather than from time, so the same component shows a trace streaming from a server and one scripted for a demo.
Install
npm i mihirmodi-ui motionThe tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.
Usage
import { ReasoningTrace, useStepReveal } from "mihirmodi-ui";
// Streaming from a server: pass the count as steps arrive.
<ReasoningTrace steps={steps} revealed={steps.length} done={finished} />
// Scripted: let the hook pace it.
const { revealed, done } = useStepReveal(STEPS.length, { first: 400, every: 700 });
<ReasoningTrace steps={STEPS} revealed={revealed} done={done} />Props
| Prop | Type | Notes |
|---|---|---|
| stepsReactNode[] | ReactNode[] | Every step, in order. |
| revealednumber | number= steps.length | How many have arrived. Stream into this. |
| doneboolean | boolean= revealed === steps.length | True once the last step has finished, not merely arrived. |
| labelReactNode | ReactNode= "Reasoning" | The small label above the list. Pass null for none. |
| useStepReveal(total, opts)hook | hook | Reveals steps on a timer — `first` ms, then every `every` ms. For scripted traces; change `runKey` to replay. |
Source
Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.
"use client";
import type { ReactNode } from "react";
import { LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import { EASE_GEIST, FLAT } from "../lib/motion.js";
export type ReasoningTraceProps = {
/** Every step, in order. Only the first `revealed` are drawn. */
steps: ReactNode[];
/** How many have arrived. Defaults to all of them — a finished trace. */
revealed?: number;
/** True once the last step has finished, not merely arrived. Defaults to `revealed === steps.length`. */
done?: boolean;
/** The small label above the list. */
label?: ReactNode;
className?: string;
};
/**
* The steps an answer took, arriving one at a time.
*
* The step still running carries a breathing dot and the full ink; the ones
* behind it drop to a quieter grey with a still dot. So at a glance the trace
* says two things — how far it has got, and which step is live — without a
* spinner or a percentage that would claim to know how long is left.
*
* Drawn from `revealed` rather than from time, so it is the same component for
* a trace that streams from a server and one that is scripted. Pair it with
* `useStepReveal` for the second.
*/
export function ReasoningTrace({ steps, revealed = steps.length, done, label = "Reasoning", className = "" }: ReasoningTraceProps) {
const reduce = useReducedMotion();
const finished = done ?? revealed >= steps.length;
return (
<LazyMotion features={domAnimation} strict>
<div className={className}>
{label && <p className="text-[12px] text-faint">{label}</p>}
<ol className="mt-2 flex flex-col gap-2" aria-live="polite">
{steps.slice(0, revealed).map((s, i) => {
const running = !finished && i === revealed - 1;
return (
<m.li
key={i}
initial={reduce ? { opacity: 0 } : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={reduce ? FLAT : { duration: 0.2, ease: EASE_GEIST }}
className="flex items-start gap-3"
>
<span
aria-hidden
className={`mt-[8px] h-1.5 w-1.5 shrink-0 rounded-full ${running ? "thinking-dot bg-ink" : "bg-gray-500"}`}
/>
<span
className={`text-[14px] leading-[1.55] transition-colors duration-200 ${running ? "text-gray-1000" : "text-gray-900"}`}
>
{s}
</span>
</m.li>
);
})}
</ol>
</div>
</LazyMotion>
);
}
"use client";
import { useEffect, useState } from "react";
/**
* Reveals `total` steps one at a time: the first after `first` ms, each one
* after that `every` ms later. Returns how many are showing and whether the
* last has landed.
*
* For a scripted or simulated trace. A real one should pass `revealed` straight
* to the component as the steps stream in — this is only the stand-in for a
* server. Change `runKey` to run it again from the top.
*/
export function useStepReveal(
total: number,
{ first = 400, every = 700, start = true, runKey }: { first?: number; every?: number; start?: boolean; runKey?: unknown } = {}
) {
const [revealed, setRevealed] = useState(0);
useEffect(() => {
setRevealed(0);
}, [runKey, total]);
useEffect(() => {
if (!start || revealed >= total) return;
const t = window.setTimeout(() => setRevealed((n) => n + 1), revealed === 0 ? first : every);
return () => window.clearTimeout(t);
}, [start, revealed, total, first, every]);
return { revealed, done: revealed >= total };
}