Thinking state
A quiet line that says what the agent is doing while it works, with a live timer, steps that unfold, and a fold into "Thought for 12s" when it is done.
Install#
npx shadcn@latest add @pixilui/thinking-stateOnce per app, add the registry to components.json:
{
"registries": {
"@pixilui": "https://pixilui.com/r/{name}.json"
}
}Or install from the URL, no namespace needed: npx shadcn@latest add https://pixilui.com/r/thinking-state.json
- 1
Install the npm packages.
npm install motion clsx tailwind-merge - 2
Add the items it depends on, the same way.
- 3
Copy each file to the path shown.
"use client"; import { forwardRef, useCallback, useEffect, useId, useRef, useState, useSyncExternalStore, type ComponentPropsWithoutRef, type ReactNode, } from "react"; import { AnimatePresence, motion, useReducedMotion, type Variants } from "motion/react"; import { cn } from "./lib/utils"; import { useMorphSize } from "./lib/use-morph-size"; import { motionTokens, reducedTransition } from "./motion-tokens"; const noSubscription = () => () => {}; /** * useReducedMotion, held at false until hydration finishes. The server cannot know the * preference, so reading it on the first client render would change the markup and break * hydration; the reduced branch takes over on the next render instead. */ function useReducedMotionAfterHydration() { const hydrated = useSyncExternalStore(noSubscription, () => true, () => false); const reduced = useReducedMotion(); return hydrated && (reduced ?? false); } export type ThinkingStateStepStatus = "pending" | "active" | "done"; export type ThinkingStateStep = { /** Stable key for the step. */ id: string; /** What the agent is doing or did, in plain words: "Reading the incident timeline". */ text: string; /** Pending steps are muted, the active one reads in full colour, done steps get a drawn check. */ status: ThinkingStateStepStatus; }; export type ThinkingStateProps = Omit<ComponentPropsWithoutRef<"div">, "children"> & { /** What the agent is doing right now. Changing it crossfades the line. */ label?: string; /** The steps behind the line, shown when expanded. Leave it out for a single quiet line. */ steps?: ThinkingStateStep[]; /** When the work started (epoch ms). Drives a live timer that stops when done. */ startedAt?: number; /** Elapsed time you track yourself (ms). Wins over startedAt; pass it for a finished run you render later. */ elapsedMs?: number; /** Controlled: whether the steps are unfolded. */ expanded?: boolean; /** Starting fold when uncontrolled. */ defaultExpanded?: boolean; /** Called when the person toggles the steps, and with false when the work finishes. */ onExpandedChange?: (expanded: boolean) => void; /** The work is finished: the shimmer stops, the timer freezes and the line folds into "Thought for 12s". */ done?: boolean; /** The finished line. A function receives the elapsed time in ms (null when there is no timer). */ doneLabel?: string | ((elapsedMs: number | null) => string); /** Show the live timer beside the label. */ showTimer?: boolean; /** The agent's mark, shown small before the label. */ icon?: ReactNode; }; /** One pass of the shimmer across the label: 1.92s, linear. */ const SWEEP = motionTokens.duration.considered * 4; const TICK_MS = 250; /** "12s", "1m 04s". */ function formatThinkingTime(ms: number) { const s = Math.floor(ms / 1000); if (s < 60) return `${s}s`; return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`; } const defaultDoneLabel = (ms: number | null) => (ms === null ? "Thought it through" : `Thought for ${formatThinkingTime(Math.max(ms, 1000))}`); function useElapsed(startedAt: number | undefined, running: boolean) { const [now, setNow] = useState<number | null>(null); useEffect(() => { if (startedAt === undefined || !running) return; const tick = () => setNow(Date.now()); tick(); const id = setInterval(tick, TICK_MS); return () => clearInterval(id); }, [startedAt, running]); return startedAt === undefined || now === null ? null : Math.max(0, now - startedAt); } /** Digits keyed by place that roll up as the time grows. */ function ThinkingStateTicker({ value, reduced }: { value: string; reduced: boolean }) { const chars = value.split(""); return ( <span role="timer" aria-label={value} className="numeric inline-flex shrink-0 text-12 text-muted"> {chars.map((char, index) => { const place = chars.length - index; if (reduced || !/\d/.test(char)) { return ( <span key={place} aria-hidden="true"> {char} </span> ); } return ( <span key={place} aria-hidden="true" className="relative inline-flex h-[1lh] overflow-hidden"> <AnimatePresence initial={false} mode="popLayout"> <motion.span key={char} initial={{ y: "100%" }} animate={{ y: 0 }} exit={{ y: "-100%" }} transition={motionTokens.spring.snappy} > {char} </motion.span> </AnimatePresence> </span> ); })} </span> ); } function ThinkingStateGlyph({ status, reduced }: { status: ThinkingStateStepStatus; reduced: boolean }) { if (status === "done") { return ( <svg viewBox="0 0 12 12" fill="none" aria-hidden="true" className="size-3 text-muted"> <motion.path d="M2.5 6.25l2.25 2.25 4.75-5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" initial={{ pathLength: reduced ? 1 : 0 }} animate={{ pathLength: 1 }} transition={reduced ? reducedTransition : { duration: motionTokens.duration.slow, ease: motionTokens.ease.enter }} /> </svg> ); } return ( <span aria-hidden="true" className={cn("size-1.5 rounded-pill transition-colors duration-fast ease-swap", status === "active" ? "bg-foreground" : "bg-border-strong")} /> ); } export const ThinkingState = forwardRef<HTMLDivElement, ThinkingStateProps>(function ThinkingState( { label = "Thinking", steps = [], startedAt, elapsedMs, expanded: expandedProp, defaultExpanded = false, onExpandedChange, done = false, doneLabel = defaultDoneLabel, showTimer = true, icon, className, ...rest }, forwardedRef, ) { const reduced = useReducedMotionAfterHydration(); const stepsId = useId(); const [innerExpanded, setInnerExpanded] = useState(defaultExpanded); const expanded = expandedProp ?? innerExpanded; const hasSteps = steps.length > 0; const open = expanded && hasSteps; const live = useElapsed(startedAt, !done); const elapsed = elapsedMs ?? live; const finished = typeof doneLabel === "function" ? doneLabel(elapsed) : doneLabel; const line = done ? finished : label; // Finishing folds the steps away, once, when done turns true. const [foldedFor, setFoldedFor] = useState(done); if (done !== foldedFor) { setFoldedFor(done); if (done && expandedProp === undefined) setInnerExpanded(false); } const last = useRef({ done, expanded }); useEffect(() => { if (done && !last.current.done && last.current.expanded) onExpandedChange?.(false); last.current = { done, expanded }; }, [done, expanded, onExpandedChange]); const toggle = () => { const next = !expanded; if (expandedProp === undefined) setInnerExpanded(next); onExpandedChange?.(next); }; const { containerRef, measureRef } = useMorphSize<HTMLDivElement, HTMLDivElement>("height", `${open}:${steps.length}:${done}`); const setRef = useCallback( (node: HTMLDivElement | null) => { containerRef.current = node; if (typeof forwardedRef === "function") forwardedRef(node); else if (forwardedRef) forwardedRef.current = node; }, [forwardedRef, containerRef], ); const shimmer = !done && !reduced; const swap = reduced ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: reducedTransition } : { initial: { opacity: 0, y: "0.3em", filter: `blur(${motionTokens.blur.soft}px)` }, animate: { opacity: 1, y: 0, filter: "blur(0px)" }, exit: { opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: motionTokens.ease.exit } }, transition: { duration: motionTokens.duration.fast, ease: motionTokens.ease.enter }, }; const listVariants: Variants = { hidden: {}, shown: { transition: { staggerChildren: reduced ? 0 : motionTokens.stagger.item } } }; const stepVariants: Variants = reduced ? { hidden: { opacity: 0 }, shown: { opacity: 1, transition: reducedTransition } } : { hidden: { opacity: 0, y: motionTokens.distance.micro, filter: `blur(${motionTokens.blur.subtle}px)` }, shown: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: motionTokens.duration.fast, ease: motionTokens.ease.enter } }, }; const headerContent = ( <> {icon ? <span className="grid size-4 shrink-0 place-items-center text-muted [&_svg]:size-4">{icon}</span> : null} <span className="relative min-w-0 truncate"> <AnimatePresence initial={false} mode="popLayout"> <motion.span key={line} className="block truncate" {...swap}> {shimmer ? ( <motion.span data-slot="thinking-state-shimmer" className="bg-linear-to-r from-foreground/60 from-40% via-foreground via-50% to-foreground/60 to-60% bg-clip-text text-transparent" style={{ backgroundSize: "200% 100%" }} initial={{ backgroundPositionX: "100%" }} animate={{ backgroundPositionX: "-100%" }} transition={{ duration: SWEEP, ease: motionTokens.ease.linear, repeat: Infinity }} > {line} </motion.span> ) : ( <span className="text-secondary">{line}</span> )} </motion.span> </AnimatePresence> </span> {showTimer && !done && elapsed !== null ? <ThinkingStateTicker value={formatThinkingTime(elapsed)} reduced={reduced} /> : null} {hasSteps ? ( <svg viewBox="0 0 12 12" fill="none" aria-hidden="true" className={cn("size-3 shrink-0 text-muted transition-transform duration-fast ease-swap motion-reduce:transition-none", open && "rotate-90")} > <path d="M4.5 3l3 3-3 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" /> </svg> ) : null} </> ); const headerClass = "flex max-w-full min-w-0 items-center gap-2 rounded-control py-1 text-left"; return ( <div ref={setRef} data-status={done ? "done" : "thinking"} data-expanded={open} aria-busy={!done} className={cn("relative w-full min-w-0 overflow-hidden font-body text-14 text-foreground", className)} {...rest} > <div ref={measureRef} className="flex flex-col items-start"> {hasSteps ? ( <button type="button" aria-expanded={open} aria-controls={stepsId} onClick={toggle} className={cn( headerClass, "-mx-1.5 px-1.5 outline-none transition-colors duration-instant ease-swap hover-fine:bg-surface-muted focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-focus", )} > {headerContent} </button> ) : ( <div className={headerClass}>{headerContent}</div> )} <AnimatePresence initial={false} mode="popLayout"> {open ? ( <motion.ol key="steps" id={stepsId} data-slot="thinking-state-steps" className="mt-1 mb-1 ms-1.5 flex flex-col gap-1.5 border-s border-border-subtle ps-3.5" variants={listVariants} initial="hidden" animate="shown" exit={reduced ? { opacity: 0, transition: reducedTransition } : { opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: motionTokens.ease.exit } }} > {steps.map((step) => ( <motion.li key={step.id} data-status={step.status} variants={stepVariants} className="flex min-w-0 items-baseline gap-2.5"> <span className="grid size-3 shrink-0 translate-y-0.5 place-items-center self-start"> <ThinkingStateGlyph status={step.status} reduced={reduced} /> </span> <span className={cn( "min-w-0 text-pretty transition-colors duration-fast ease-swap", step.status === "active" ? "text-foreground" : step.status === "done" ? "text-secondary" : "text-muted", )} > {step.text} <span className="sr-only">{step.status === "done" ? ", done" : step.status === "active" ? ", in progress" : ", up next"}</span> </span> </motion.li> ))} </motion.ol> ) : null} </AnimatePresence> </div> <p className="sr-only" aria-live="polite"> {line} </p> </div> ); }); - 4
Check that your global stylesheet imports the foundation, after Tailwind.
@import "tailwindcss"; @import "../components/pixilui/foundation.css";
Adds components/pixilui/thinking-state.tsx. Needs motion, clsx, tailwind-merge.
Preview#
The shimmer runs across the line while the timer digits roll, then the steps fold away and the line becomes "Thought for 4s".
Code#
"use client";
import {
forwardRef,
useCallback,
useEffect,
useId,
useRef,
useState,
useSyncExternalStore,
type ComponentPropsWithoutRef,
type ReactNode,
} from "react";
import { AnimatePresence, motion, useReducedMotion, type Variants } from "motion/react";
import { cn } from "./lib/utils";
import { useMorphSize } from "./lib/use-morph-size";
import { motionTokens, reducedTransition } from "./motion-tokens";
const noSubscription = () => () => {};
/**
* useReducedMotion, held at false until hydration finishes. The server cannot know the
* preference, so reading it on the first client render would change the markup and break
* hydration; the reduced branch takes over on the next render instead.
*/
function useReducedMotionAfterHydration() {
const hydrated = useSyncExternalStore(noSubscription, () => true, () => false);
const reduced = useReducedMotion();
return hydrated && (reduced ?? false);
}
export type ThinkingStateStepStatus = "pending" | "active" | "done";
export type ThinkingStateStep = {
/** Stable key for the step. */
id: string;
/** What the agent is doing or did, in plain words: "Reading the incident timeline". */
text: string;
/** Pending steps are muted, the active one reads in full colour, done steps get a drawn check. */
status: ThinkingStateStepStatus;
};
export type ThinkingStateProps = Omit<ComponentPropsWithoutRef<"div">, "children"> & {
/** What the agent is doing right now. Changing it crossfades the line. */
label?: string;
/** The steps behind the line, shown when expanded. Leave it out for a single quiet line. */
steps?: ThinkingStateStep[];
/** When the work started (epoch ms). Drives a live timer that stops when done. */
startedAt?: number;
/** Elapsed time you track yourself (ms). Wins over startedAt; pass it for a finished run you render later. */
elapsedMs?: number;
/** Controlled: whether the steps are unfolded. */
expanded?: boolean;
/** Starting fold when uncontrolled. */
defaultExpanded?: boolean;
/** Called when the person toggles the steps, and with false when the work finishes. */
onExpandedChange?: (expanded: boolean) => void;
/** The work is finished: the shimmer stops, the timer freezes and the line folds into "Thought for 12s". */
done?: boolean;
/** The finished line. A function receives the elapsed time in ms (null when there is no timer). */
doneLabel?: string | ((elapsedMs: number | null) => string);
/** Show the live timer beside the label. */
showTimer?: boolean;
/** The agent's mark, shown small before the label. */
icon?: ReactNode;
};
/** One pass of the shimmer across the label: 1.92s, linear. */
const SWEEP = motionTokens.duration.considered * 4;
const TICK_MS = 250;
/** "12s", "1m 04s". */
function formatThinkingTime(ms: number) {
const s = Math.floor(ms / 1000);
if (s < 60) return `${s}s`;
return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`;
}
const defaultDoneLabel = (ms: number | null) => (ms === null ? "Thought it through" : `Thought for ${formatThinkingTime(Math.max(ms, 1000))}`);
function useElapsed(startedAt: number | undefined, running: boolean) {
const [now, setNow] = useState<number | null>(null);
useEffect(() => {
if (startedAt === undefined || !running) return;
const tick = () => setNow(Date.now());
tick();
const id = setInterval(tick, TICK_MS);
return () => clearInterval(id);
}, [startedAt, running]);
return startedAt === undefined || now === null ? null : Math.max(0, now - startedAt);
}
/** Digits keyed by place that roll up as the time grows. */
function ThinkingStateTicker({ value, reduced }: { value: string; reduced: boolean }) {
const chars = value.split("");
return (
<span role="timer" aria-label={value} className="numeric inline-flex shrink-0 text-12 text-muted">
{chars.map((char, index) => {
const place = chars.length - index;
if (reduced || !/\d/.test(char)) {
return (
<span key={place} aria-hidden="true">
{char}
</span>
);
}
return (
<span key={place} aria-hidden="true" className="relative inline-flex h-[1lh] overflow-hidden">
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={char}
initial={{ y: "100%" }}
animate={{ y: 0 }}
exit={{ y: "-100%" }}
transition={motionTokens.spring.snappy}
>
{char}
</motion.span>
</AnimatePresence>
</span>
);
})}
</span>
);
}
function ThinkingStateGlyph({ status, reduced }: { status: ThinkingStateStepStatus; reduced: boolean }) {
if (status === "done") {
return (
<svg viewBox="0 0 12 12" fill="none" aria-hidden="true" className="size-3 text-muted">
<motion.path
d="M2.5 6.25l2.25 2.25 4.75-5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: reduced ? 1 : 0 }}
animate={{ pathLength: 1 }}
transition={reduced ? reducedTransition : { duration: motionTokens.duration.slow, ease: motionTokens.ease.enter }}
/>
</svg>
);
}
return (
<span
aria-hidden="true"
className={cn("size-1.5 rounded-pill transition-colors duration-fast ease-swap", status === "active" ? "bg-foreground" : "bg-border-strong")}
/>
);
}
export const ThinkingState = forwardRef<HTMLDivElement, ThinkingStateProps>(function ThinkingState(
{
label = "Thinking",
steps = [],
startedAt,
elapsedMs,
expanded: expandedProp,
defaultExpanded = false,
onExpandedChange,
done = false,
doneLabel = defaultDoneLabel,
showTimer = true,
icon,
className,
...rest
},
forwardedRef,
) {
const reduced = useReducedMotionAfterHydration();
const stepsId = useId();
const [innerExpanded, setInnerExpanded] = useState(defaultExpanded);
const expanded = expandedProp ?? innerExpanded;
const hasSteps = steps.length > 0;
const open = expanded && hasSteps;
const live = useElapsed(startedAt, !done);
const elapsed = elapsedMs ?? live;
const finished = typeof doneLabel === "function" ? doneLabel(elapsed) : doneLabel;
const line = done ? finished : label;
// Finishing folds the steps away, once, when done turns true.
const [foldedFor, setFoldedFor] = useState(done);
if (done !== foldedFor) {
setFoldedFor(done);
if (done && expandedProp === undefined) setInnerExpanded(false);
}
const last = useRef({ done, expanded });
useEffect(() => {
if (done && !last.current.done && last.current.expanded) onExpandedChange?.(false);
last.current = { done, expanded };
}, [done, expanded, onExpandedChange]);
const toggle = () => {
const next = !expanded;
if (expandedProp === undefined) setInnerExpanded(next);
onExpandedChange?.(next);
};
const { containerRef, measureRef } = useMorphSize<HTMLDivElement, HTMLDivElement>("height", `${open}:${steps.length}:${done}`);
const setRef = useCallback(
(node: HTMLDivElement | null) => {
containerRef.current = node;
if (typeof forwardedRef === "function") forwardedRef(node);
else if (forwardedRef) forwardedRef.current = node;
},
[forwardedRef, containerRef],
);
const shimmer = !done && !reduced;
const swap = reduced
? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 }, transition: reducedTransition }
: {
initial: { opacity: 0, y: "0.3em", filter: `blur(${motionTokens.blur.soft}px)` },
animate: { opacity: 1, y: 0, filter: "blur(0px)" },
exit: { opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: motionTokens.ease.exit } },
transition: { duration: motionTokens.duration.fast, ease: motionTokens.ease.enter },
};
const listVariants: Variants = { hidden: {}, shown: { transition: { staggerChildren: reduced ? 0 : motionTokens.stagger.item } } };
const stepVariants: Variants = reduced
? { hidden: { opacity: 0 }, shown: { opacity: 1, transition: reducedTransition } }
: {
hidden: { opacity: 0, y: motionTokens.distance.micro, filter: `blur(${motionTokens.blur.subtle}px)` },
shown: { opacity: 1, y: 0, filter: "blur(0px)", transition: { duration: motionTokens.duration.fast, ease: motionTokens.ease.enter } },
};
const headerContent = (
<>
{icon ? <span className="grid size-4 shrink-0 place-items-center text-muted [&_svg]:size-4">{icon}</span> : null}
<span className="relative min-w-0 truncate">
<AnimatePresence initial={false} mode="popLayout">
<motion.span key={line} className="block truncate" {...swap}>
{shimmer ? (
<motion.span
data-slot="thinking-state-shimmer"
className="bg-linear-to-r from-foreground/60 from-40% via-foreground via-50% to-foreground/60 to-60% bg-clip-text text-transparent"
style={{ backgroundSize: "200% 100%" }}
initial={{ backgroundPositionX: "100%" }}
animate={{ backgroundPositionX: "-100%" }}
transition={{ duration: SWEEP, ease: motionTokens.ease.linear, repeat: Infinity }}
>
{line}
</motion.span>
) : (
<span className="text-secondary">{line}</span>
)}
</motion.span>
</AnimatePresence>
</span>
{showTimer && !done && elapsed !== null ? <ThinkingStateTicker value={formatThinkingTime(elapsed)} reduced={reduced} /> : null}
{hasSteps ? (
<svg
viewBox="0 0 12 12"
fill="none"
aria-hidden="true"
className={cn("size-3 shrink-0 text-muted transition-transform duration-fast ease-swap motion-reduce:transition-none", open && "rotate-90")}
>
<path d="M4.5 3l3 3-3 3" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
) : null}
</>
);
const headerClass = "flex max-w-full min-w-0 items-center gap-2 rounded-control py-1 text-left";
return (
<div
ref={setRef}
data-status={done ? "done" : "thinking"}
data-expanded={open}
aria-busy={!done}
className={cn("relative w-full min-w-0 overflow-hidden font-body text-14 text-foreground", className)}
{...rest}
>
<div ref={measureRef} className="flex flex-col items-start">
{hasSteps ? (
<button
type="button"
aria-expanded={open}
aria-controls={stepsId}
onClick={toggle}
className={cn(
headerClass,
"-mx-1.5 px-1.5 outline-none transition-colors duration-instant ease-swap hover-fine:bg-surface-muted focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-focus",
)}
>
{headerContent}
</button>
) : (
<div className={headerClass}>{headerContent}</div>
)}
<AnimatePresence initial={false} mode="popLayout">
{open ? (
<motion.ol
key="steps"
id={stepsId}
data-slot="thinking-state-steps"
className="mt-1 mb-1 ms-1.5 flex flex-col gap-1.5 border-s border-border-subtle ps-3.5"
variants={listVariants}
initial="hidden"
animate="shown"
exit={reduced ? { opacity: 0, transition: reducedTransition } : { opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: motionTokens.ease.exit } }}
>
{steps.map((step) => (
<motion.li key={step.id} data-status={step.status} variants={stepVariants} className="flex min-w-0 items-baseline gap-2.5">
<span className="grid size-3 shrink-0 translate-y-0.5 place-items-center self-start">
<ThinkingStateGlyph status={step.status} reduced={reduced} />
</span>
<span
className={cn(
"min-w-0 text-pretty transition-colors duration-fast ease-swap",
step.status === "active" ? "text-foreground" : step.status === "done" ? "text-secondary" : "text-muted",
)}
>
{step.text}
<span className="sr-only">{step.status === "done" ? ", done" : step.status === "active" ? ", in progress" : ", up next"}</span>
</span>
</motion.li>
))}
</motion.ol>
) : null}
</AnimatePresence>
</div>
<p className="sr-only" aria-live="polite">
{line}
</p>
</div>
);
});Usage#
Use it when
- A model or agent is working before it answers and the person needs to know it is alive and what it is on.
- You want to show the reasoning steps on demand without pushing them into the answer.
Use something else when
- A run with tool calls, durations and approvals to audit: use run-timeline.
- A single tool call: use tool-call-row.
- Page or data loading with no agent behind it: use a skeleton.
import { useEffect, useState, useSyncExternalStore } from "react";
import { useReducedMotion } from "motion/react";
import { Activity, RotateCcw } from "lucide-react";
import { Button } from "@/components/pixilui/button";
import { ThinkingState, type ThinkingStateStep } from "@/components/pixilui/thinking-state";
const noSubscription = () => () => {};
/**
* useReducedMotion, held at false until hydration finishes. The server cannot know the
* preference, so reading it on the first client render would change the markup and break
* hydration; the reduced branch takes over on the next render instead.
*/
function useReducedMotionAfterHydration() {
const hydrated = useSyncExternalStore(noSubscription, () => true, () => false);
const reduced = useReducedMotion();
return hydrated && (reduced ?? false);
}
// The first option of each knob is the demo's default; the docs preview starts there.
export const knobs = {
state: ["play", "working", "done"],
steps: ["with steps", "line only"],
} as const;
const STEP_TEXT = [
"Reading INC-2291 and the deploy log for checkout-api",
"Comparing p95 latency before and after deploy 4f3a9c1",
"Counting merchants that saw 502s between 09:42 and 09:58",
"Drafting the summary for #ops-incidents",
];
const stepsAt = (active: number): ThinkingStateStep[] =>
STEP_TEXT.slice(0, active + 1).map((text, index) => ({
id: `s${index}`,
text,
status: index < active ? "done" : "active",
}));
const allDone: ThinkingStateStep[] = STEP_TEXT.map((text, index) => ({ id: `s${index}`, text, status: "done" }));
// One step every 0.9s, then the fold.
const STEP_MS = 900;
type Frame = { steps: ThinkingStateStep[]; done: boolean; expanded: boolean };
export default function ThinkingStateDemo({ state = "play", steps = "with steps" }: KnobValues<typeof knobs>) {
const reduced = useReducedMotionAfterHydration();
const [run, setRun] = useState(0);
const [startedAt, setStartedAt] = useState<number | undefined>(undefined);
const [frame, setFrame] = useState<Frame>({ steps: [], done: false, expanded: true });
const playing = state === "play" && !reduced;
useEffect(() => {
if (!playing) return;
const timers = [
setTimeout(() => {
setStartedAt(Date.now());
setFrame({ steps: [], done: false, expanded: true });
}, 0),
...STEP_TEXT.map((_, index) => setTimeout(() => setFrame({ steps: stepsAt(index), done: false, expanded: true }), 300 + index * STEP_MS)),
setTimeout(() => setFrame({ steps: allDone, done: false, expanded: true }), 300 + STEP_TEXT.length * STEP_MS),
setTimeout(() => setFrame({ steps: allDone, done: true, expanded: false }), 700 + STEP_TEXT.length * STEP_MS),
];
return () => timers.forEach(clearTimeout);
}, [playing, run]);
// The working state still ticks: it started six seconds before the preview opened.
useEffect(() => {
if (state !== "working") return;
const id = setTimeout(() => setStartedAt(Date.now() - 6000), 0);
return () => clearTimeout(id);
}, [state]);
const shown: Frame & { elapsedMs?: number; startedAt?: number } = playing
? { ...frame, startedAt }
: state === "working"
? { steps: stepsAt(2), done: false, expanded: true, startedAt }
: { steps: allDone, done: true, expanded: false, elapsedMs: 4300 };
return (
<div className="flex w-full min-w-0 max-w-md flex-col gap-5">
<p className="self-end rounded-panel bg-surface px-4 py-2.5 font-body text-14 text-foreground hairline">Why did checkout errors spike this morning?</p>
<ThinkingState
key={`${state}-${steps}-${run}`}
label="Looking into the checkout errors"
icon={<Activity strokeWidth={1.5} />}
steps={steps === "with steps" ? shown.steps : undefined}
startedAt={shown.startedAt}
elapsedMs={shown.elapsedMs}
defaultExpanded={shown.expanded}
done={shown.done}
/>
<div className="flex h-control-sm justify-center">
{playing && shown.done ? (
<Button variant="ghost" size="sm" icon={<RotateCcw strokeWidth={1.5} />} onClick={() => {
setFrame({ steps: [], done: false, expanded: true });
setStartedAt(undefined);
setRun((n) => n + 1);
}}>
Replay
</Button>
) : null}
</div>
</div>
);
}API#
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | "Thinking" | What the agent is doing right now. Changing it crossfades the line. |
steps | ThinkingStateStep[] | [] | The steps behind the line, shown when expanded. Leave it out for a single quiet line. |
startedAt | number | When the work started (epoch ms). Drives a live timer that stops when done. | |
elapsedMs | number | Elapsed time you track yourself (ms). Wins over startedAt; pass it for a finished run you render later. | |
expanded | boolean | Controlled: whether the steps are unfolded. | |
defaultExpanded | boolean | false | Starting fold when uncontrolled. |
onExpandedChange | (expanded: boolean) => void | Called when the person toggles the steps, and with false when the work finishes. | |
done | boolean | false | The work is finished: the shimmer stops, the timer freezes and the line folds into "Thought for 12s". |
doneLabel | string | ((elapsedMs: number | null) => string) | defaultDoneLabel | The finished line. A function receives the elapsed time in ms (null when there is no timer). |
showTimer | boolean | true | Show the live timer beside the label. |
icon | ReactNode | The agent's mark, shown small before the label. |
Also takes every native <div> attribute, and a ref to it.
Keyboard#
- Tab
- Focuses the line when it has steps.
- Enter
- Unfolds or folds the steps.
- Space
- Unfolds or folds the steps.
Accessibility#
- The line is a real button with aria-expanded and aria-controls pointing at the step list; with no steps it is plain text.
- The root sets aria-busy while working. A polite live region announces the label and the finished line, never the ticking timer.
- The timer has role timer, so screen readers do not read every tick.
- Each step carries its status in text (done, in progress, up next) after the words, so meaning never rests on the glyph.
Motion#
- A soft shimmer sweeps across the label on linear easing, one pass every 1.92s, and stops when the work is done.
- Timer digits are keyed by place and roll up on the snappy spring.
- Steps arrive in reading order 35ms apart, rising 4px out of a 2px blur; a step that finishes draws its check.
- Finishing folds the steps away on the smooth spring while the label crossfades into "Thought for 12s". Unfolding grows on the morph spring.
- Reduced motion: no shimmer, no rolling digits, instant fold, short fades.
Responsive#
- Fills its container; the label truncates on one line and the steps wrap.
- No surface of its own, so it sits inside a message list or a card without doubling borders.
Performance#
- One ResizeObserver drives the fold. The timer re-renders four times a second only while working.
- The shimmer animates background-position on one span; no layout work per frame.
Notes for AI#
Also in the markdown page and llms.txt
- Pass startedAt when the run begins and done when the first answer token arrives; the timer freezes and the line folds itself.
- For a run rendered later from history, pass elapsedMs with done so the line reads the real duration.
- Keep label to what the agent is doing in plain words ("Looking into the checkout errors"); put the steps in steps, newest last, with one active.
- Pass the agent's mark as icon. Never a sparkle; the component bakes in no icon.
- doneLabel can be a function of elapsed milliseconds for translation: (ms) =>
Pensó durante ${Math.round(ms / 1000)} s.