# 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.

Tier: free. Type: pattern. Category: ai. Tags: agent, run, streaming, motion.

## Install

```bash
npx shadcn@latest add @pixilui/thinking-state
```

One-time setup: add the registry to `components.json`:

```json
{
  "registries": {
    "@pixilui": "https://pixilui.com/r/{name}.json"
  }
}
```

Files: `components/pixilui/thinking-state.tsx`. npm: `motion`.

## Preview

Live preview: https://pixilui.com/components/thinking-state

Knobs:

- `state`: `play`, `working`, `done`
- `steps`: `with steps`, `line only`

Signature: 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`

```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

When to use:

- 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.

When not to use:

- 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.

```tsx
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>
  );
}
```

## Examples

None.

## 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 accepts 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

- 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`.

## Related

- [Streaming text](https://pixilui.com/components/streaming-text/markdown): Renders a model's answer as it streams: only new words rise into place at reading speed, a caret holds the end, and half-arrived words wait until they are whole.
- [Run timeline](https://pixilui.com/components/run-timeline/markdown): The steps of one agent run as a vertical timeline: a connector that fills as steps finish, a live timer on the active step, and a waiting step that holds an approval.
- [Tool call row](https://pixilui.com/components/tool-call-row/markdown): One line per tool call: what it did in plain words, the tool's name, a live timer, and a fold-out with the input and output as copyable JSON. ToolCallRowGroup sums several calls into one line.
