AI Patterns

loaders

Thinking Loader

A loader with a shimmering label and a live elapsed-time counter.

Thinking0s
"use client";

import * as React from "react";
import { Brain } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";

import { cn } from "@/lib/utils";

const DEFAULT_WORDS = [
  "Thinking",
  "Reasoning",
  "Pondering",
  "Analyzing",
  "Considering",
  "Working it out",
];

export interface ThinkingLoaderProps {
  /** Words to cycle through as the label. Defaults to a set of generic synonyms. */
  words?: string[];
  /** How often the label changes, in ms. */
  interval?: number;
  className?: string;
}

export function ThinkingLoader({
  words = DEFAULT_WORDS,
  interval = 6000,
  className,
}: ThinkingLoaderProps) {
  const elapsed = useElapsedSeconds();
  const word = useCyclingWord(words, interval);

  return (
    <div
      className={cn("flex items-center gap-1 px-4 py-3 text-muted-foreground", className)}
    >
      <Brain className="size-3.5 shrink-0" aria-hidden />
      <ShimmerWord word={word} />
      <span className="ml-auto flex items-center font-mono text-xs tabular-nums">
        <SlidingNumber value={elapsed} />s
      </span>
    </div>
  );
}

function useCyclingWord(words: string[], interval: number) {
  const [index, setIndex] = React.useState(0);

  React.useEffect(() => {
    if (words.length <= 1) return;
    const id = window.setInterval(() => {
      setIndex((i) => (i + 1) % words.length);
    }, interval);
    return () => window.clearInterval(id);
  }, [words, interval]);

  return words[index % words.length];
}

function useElapsedSeconds() {
  const [elapsed, setElapsed] = React.useState("0");

  React.useEffect(() => {
    const start = Date.now();
    const id = window.setInterval(() => {
      setElapsed(Math.floor((Date.now() - start) / 1000).toString());
    }, 1000);
    return () => window.clearInterval(id);
  }, []);

  return elapsed;
}

function ShimmerWord({ word }: { word: string }) {
  return (
    <span className="relative inline-block h-[1.2em] overflow-hidden text-xs font-medium">
      <AnimatePresence mode="popLayout" initial={false}>
        <motion.span
          key={word}
          className="inline-block whitespace-nowrap bg-clip-text text-transparent"
          style={{
            backgroundImage:
              "linear-gradient(90deg, var(--muted-foreground) 30%, var(--foreground) 50%, var(--muted-foreground) 70%)",
            backgroundSize: "200% 100%",
          }}
          initial={{ y: 10, opacity: 0, backgroundPositionX: "150%" }}
          animate={{ y: 0, opacity: 1, backgroundPositionX: "-50%" }}
          exit={{ y: -10, opacity: 0 }}
          transition={{
            y: { duration: 0.08, ease: "easeOut" },
            opacity: { duration: 0.08, ease: "easeOut" },
            backgroundPositionX: { repeat: Infinity, repeatType: "loop", duration: 1.4, ease: "linear", repeatDelay: 0.8 },
          }}
        >
          {word}
        </motion.span>
      </AnimatePresence>
    </span>
  );
}

/** Each digit slides up and out when it changes, and the new one slides up into place. */
function SlidingNumber({ value }: { value: string }) {
  return (
    <span className="inline-flex">
      {value.split("").map((char, i) => (
        <span key={i} className="relative inline-block h-[1.2em] w-[0.62em] overflow-hidden">
          <AnimatePresence mode="popLayout" initial={false}>
            <motion.span
              key={`${i}-${char}`}
              initial={{ y: 10, opacity: 0 }}
              animate={{ y: 0, opacity: 1 }}
              exit={{ y: -10, opacity: 0 }}
              transition={{ duration: 0.25, ease: "easeOut" }}
              className="absolute inset-0 flex items-center justify-center"
            >
              {char}
            </motion.span>
          </AnimatePresence>
        </span>
      ))}
    </span>
  );
}

A UX spec for this pattern — written for agents implementing or reusing it, not the code.

# Thinking Loader

## Summary
An inline status indicator shown while an agent is working on a response: a small looping glyph, a shimmering verb label, and a live elapsed-time counter. It tells the user the system is alive and roughly how long it has been going, without claiming a specific progress percentage it doesn't have.

## When to use
- Any moment an agent or background process is working and the duration is unknown or variable (LLM generation, tool calls, search, a long-running job).
- As the "in progress" half of a working → done sequence (see Related).

## When not to use
- When you actually know a determinate progress percentage — use a progress bar instead, it's more honest and more useful.
- As a permanent decoration left running after the work is actually done. The instant work finishes, replace this component with a completed-state summary; do not just freeze or hide it in place.
- More than one active at a time in the same conversation/view. One "the system is working" signal per turn.

## Anatomy
- Brain icon: a plain, solid glyph — it doesn't shimmer, so it stays legible as a fixed anchor next to the moving label.
- Shimmering label: a present-participle verb or short phrase describing the current activity, cycling through a small set of synonyms ("Thinking", "Reasoning", "Pondering", ...) on a timer.
- Elapsed-time counter: whole seconds, counting up from 0s.

## Behavior
- Starts counting the instant work begins.
- The label cycles to the next word in its list every 6 seconds, fading/sliding out the old word and in the new one — the shimmer (a single sweep, pause, repeat, not a relentless loop) communicates "still working" even between word changes.
- If real sub-step information is available ("Thinking" → "Searching" → "Writing"), drive the label from that instead of the generic cycle.
- The counter's changed digit animates in place (a short roll/slide) rather than the whole number re-rendering, so it doesn't read as flicker.
- On completion, replace the whole component with a result — for an agent trace, that's typically the collapsed header of an Expandable Trace ("Thought for 4 seconds").

## Content guidelines
- Each word is one or two words, present-participle or short verb phrase, no punctuation.
- Keep it truthful: don't cycle through "Searching" unless a search is actually happening.

## Accessibility
- Wrap in an `aria-live="polite"` region so screen readers announce label changes without interrupting other content.
- Don't rely on the animated counter alone to signal "still working" for assistive tech — the live-region label carries that meaning.
- Respect `prefers-reduced-motion`: keep the counter (it's informational) but reduce or remove the shimmer motion.

## Related patterns
- Expandable Trace is the "done" counterpart — replace this component with that one the moment work completes.