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.

View as markdownFreeMITSince 0.1.0

Install#

npx shadcn@latest add @pixilui/thinking-state

Once per app, add the registry to components.json:

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

Adds components/pixilui/thinking-state.tsx. Needs motion, clsx, tailwind-merge.

Preview#

State
Steps
Theme
Accent

The shimmer runs across the line while the timer digits roll, then the steps fold away and the line becomes "Thought for 4s".

Code#

components/pixilui/thinking-state.tsx
"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.
Example
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#

PropTypeDefaultDescription
labelstring"Thinking"What the agent is doing right now. Changing it crossfades the line.
stepsThinkingStateStep[][]The steps behind the line, shown when expanded. Leave it out for a single quiet line.
startedAtnumberWhen the work started (epoch ms). Drives a live timer that stops when done.
elapsedMsnumberElapsed time you track yourself (ms). Wins over startedAt; pass it for a finished run you render later.
expandedbooleanControlled: whether the steps are unfolded.
defaultExpandedbooleanfalseStarting fold when uncontrolled.
onExpandedChange(expanded: boolean) => voidCalled when the person toggles the steps, and with false when the work finishes.
donebooleanfalseThe work is finished: the shimmer stops, the timer freezes and the line folds into "Thought for 12s".
doneLabelstring | ((elapsedMs: number | null) => string)defaultDoneLabelThe finished line. A function receives the elapsed time in ms (null when there is no timer).
showTimerbooleantrueShow the live timer beside the label.
iconReactNodeThe 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
  1. Pass startedAt when the run begins and done when the first answer token arrives; the timer freezes and the line folds itself.
  2. For a run rendered later from history, pass elapsedMs with done so the line reads the real duration.
  3. 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.
  4. Pass the agent's mark as icon. Never a sparkle; the component bakes in no icon.
  5. doneLabel can be a function of elapsed milliseconds for translation: (ms) => Pensó durante ${Math.round(ms / 1000)} s.