← All components

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

    terminal1 line
    npm i mihirmodi-ui motion

    The 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

    ReasoningTrace — usage8 lines
    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

    PropNotes
    stepsReactNode[]Every step, in order.
    revealednumberHow many have arrived. Stream into this.
    donebooleanTrue once the last step has finished, not merely arrived.
    labelReactNodeThe small label above the list. Pass null for none.
    useStepReveal(total, opts)hookReveals 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.

    packages/ui/src/components/ReasoningTrace.tsx66 lines
    "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>
      );
    }
    
    packages/ui/src/lib/useStepReveal.ts31 lines
    "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 };
    }