Thinking indicator
Four quiet loaders for the gap before the first token, with a label, a rolling timer and a finish that draws itself.
Install with the shadcn CLI
$ npx shadcn@latest add https://hairlineui.com/r/thinking-indicator.jsonOverview
The second between sending and the first word is where an AI product feels fast or slow. Pick an orbit, dots, a pulse or a scan; each pairs with a label that a light sweeps across and a timer whose seconds roll up as they pass ("Churning 44s"). When it’s done nothing is swapped: the mark blurs into a check that draws itself, the label morphs to "Done in" and the time gains its tenths, "44.6s".
States
- running
- The mark animates, a 1.4s sweep of light crosses the label and the seconds roll up once a second. At a minute, the minutes slide open ("1m 05s").
- done
- The mark shrinks away through a blur and pauses; a check draws itself (420ms); the label morphs to "Done in" and the tenths slide open: "Done in 12.4s". A doneLabel replaces the whole line, and the timer folds away.
Usage
import { ThinkingIndicator } from "@/components/thinking-indicator";
<ThinkingIndicator variant="orbit" label="Churning" startedAt={sentAt} />Works with
Installs with it
With the AI SDK
Map the Vercel AI SDK’s message parts and chat status onto the component’s props.
import { useChat } from "@ai-sdk/react";
import { useState } from "react";
import { ThinkingIndicator } from "@/components/thinking-indicator";
function Waiting() {
const { status, sendMessage } = useChat();
const [sentAt, setSentAt] = useState<number>();
const send = (text: string) => {
setSentAt(Date.now());
sendMessage({ text });
};
// "submitted" is the gap between sending and the first streamed token.
return status === "submitted" ? <ThinkingIndicator startedAt={sentAt} /> : null;
}Props
| Prop | Type | Description |
|---|---|---|
| variant | "orbit" | "dots" | "pulse" | "scan"default "orbit" | The mark. |
| status | "running" | "done"default "running" | Animating, or settled with a check. |
| label / doneLabel | stringdefault "Thinking" | What it says while running, and once done. |
| startedAt | number | Start time in ms; shows the rolling timer and times "Done in". |
Accessibility and motion
- A status region announces the label and then the done message ("Done in 12.4s"); the ticking timer itself is hidden from screen readers.
- The timer re-renders once a second, on the second, and stops when done or unmounted. The mark’s animation is paused once it has faded out.
- Installs Number roll and Text morph. With reduced motion the marks hold still, nothing sweeps or rolls, and states change in place.
Requirements
React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style). Icons from lucide-react.
The CLI also installs Number roll, Text morph.
Source
›Show the full source of thinking-indicator.tsx (200 lines)
"use client";
import * as React from "react";
import { NumberRoll } from "./number-roll";
import { TextMorph } from "./text-morph";
/* ─────────────────────────────────────────────────────────
* THINKING INDICATOR: the gap before the first token
*
* orbit a dot circling a hairline ring
* dots three dots in a soft wave
* pulse a breathing dot
* scan a bar sweeping a short track
*
* A label with a sweep of light and a timer whose seconds roll
* ("Churning 44s") while running. When it's done the mark
* blurs into a check that draws itself, the label morphs to
* "Done in" and the time gains its tenths: "Done in 44.6s".
* ───────────────────────────────────────────────────────── */
export type IndicatorVariant = "orbit" | "dots" | "pulse" | "scan";
export interface ThinkingIndicatorProps extends React.HTMLAttributes<HTMLDivElement> {
variant?: IndicatorVariant;
status?: "running" | "done";
label?: string;
doneLabel?: string;
/** Start time (ms); shows a live timer. */
startedAt?: number;
}
const MORPH = "cubic-bezier(0.16,1,0.3,1)";
// A light that sweeps across the label. A mask, not a text clip, so it reaches letters that are mid-morph.
const SHEEN =
"text-foreground [mask-image:linear-gradient(90deg,rgb(0_0_0/0.45)_35%,#000_50%,rgb(0_0_0/0.45)_65%)] [mask-size:200%_100%] animate-[ui-sheen_1.4s_linear_infinite] motion-reduce:animate-none motion-reduce:[mask-image:none] motion-reduce:text-foreground/70";
const TENTHS = { minimumFractionDigits: 1, maximumFractionDigits: 1 };
const WHOLE = { maximumFractionDigits: 0 };
const TWO_DIGITS = { minimumIntegerDigits: 2, maximumFractionDigits: 0 };
const reducedQuery = "(prefers-reduced-motion: reduce)";
const subscribeReduced = (onChange: () => void) => {
const query = window.matchMedia(reducedQuery);
query.addEventListener("change", onChange);
return () => query.removeEventListener("change", onChange);
};
const useReducedMotion = () =>
React.useSyncExternalStore(subscribeReduced, () => window.matchMedia(reducedQuery).matches, () => false);
/** Icons trade places through a blur: the old one shrinks away as the new one grows in. */
const swap = (on: boolean, reduced: boolean): React.CSSProperties => ({
opacity: on ? 1 : 0,
transform: on ? "none" : "scale(0.6)",
filter: on ? "none" : "blur(3px)",
transition: reduced ? "none" : `opacity 260ms ${MORPH}, transform 380ms ${MORPH}, filter 260ms ${MORPH}`,
});
function formatElapsed(ms: number) {
const s = Math.max(0, ms) / 1000;
return s < 60 ? `${s.toFixed(1)}s` : `${Math.floor(s / 60)}m ${String(Math.floor(s % 60)).padStart(2, "0")}s`;
}
/** Seconds that roll up as they pass; minutes slide open at the first minute; tenths join once it's settled. */
function Elapsed({ ms, settled, reduced }: { ms: number; settled: boolean; reduced: boolean }) {
const total = Math.max(0, ms) / 1000;
const minutes = Math.floor(total / 60);
const seconds = minutes ? Math.floor(total % 60) : settled ? Math.floor(total * 10) / 10 : Math.floor(total);
return (
<span className="inline-flex items-baseline whitespace-nowrap">
<span
className="grid"
style={{ gridTemplateColumns: minutes ? "1fr" : "0fr", opacity: minutes ? 1 : 0, transition: reduced ? "none" : `grid-template-columns 420ms ${MORPH}, opacity 300ms ${MORPH}` }}
>
<span className="min-w-0 [clip-path:inset(-4px_0)]">
<NumberRoll value={minutes} suffix="m" duration={500} />
</span>
</span>
<NumberRoll value={seconds} format={minutes ? TWO_DIGITS : settled ? TENTHS : WHOLE} suffix="s" direction="up" duration={500} />
</span>
);
}
function Mark({ variant }: { variant: IndicatorVariant }) {
if (variant === "dots")
return (
<span aria-hidden="true" className="flex h-4 items-center gap-[3px]">
{[0, 150, 300].map((delay) => (
<span
key={delay}
className="size-1 rounded-full bg-foreground animate-[ui-bounce_1.2s_ease-in-out_infinite] motion-reduce:animate-none"
style={{ animationDelay: `${delay}ms` }}
/>
))}
</span>
);
if (variant === "pulse")
return (
<span aria-hidden="true" className="flex size-4 items-center justify-center">
<span className="size-2.5 rounded-full bg-foreground animate-[ui-breathe_1.6s_ease-in-out_infinite] motion-reduce:animate-none" />
</span>
);
if (variant === "scan")
return (
<span aria-hidden="true" className="flex h-4 items-center">
<span className="relative h-[3px] w-6 overflow-hidden rounded-full bg-border">
<span className="absolute inset-y-0 left-0 w-2/5 rounded-full bg-foreground animate-[ui-scan_1.1s_cubic-bezier(0.45,0,0.55,1)_infinite] motion-reduce:animate-none" />
</span>
</span>
);
return (
<span aria-hidden="true" className="relative flex size-4 items-center justify-center">
<span className="absolute inset-[1.5px] rounded-full border border-border" />
<span className="absolute inset-0 animate-spin [animation-duration:1.4s] motion-reduce:animate-none">
<span className="absolute left-1/2 top-0 size-[5px] -translate-x-1/2 rounded-full bg-foreground" />
</span>
</span>
);
}
export function ThinkingIndicator({
variant = "orbit",
status = "running",
label = "Thinking",
doneLabel,
startedAt,
className = "",
...props
}: ThinkingIndicatorProps) {
const reduced = useReducedMotion();
const running = status === "running";
const [now, setNow] = React.useState(startedAt ?? 0);
const [endedAt, setEndedAt] = React.useState<number | undefined>(undefined);
// Re-render on each whole second of the run (not every frame), and remember when it settled.
React.useEffect(() => {
if (!running) {
setEndedAt((prev) => prev ?? Date.now());
return;
}
setEndedAt(undefined);
if (startedAt === undefined) return;
let timer = 0;
const tick = () => {
const t = Date.now();
setNow(t);
timer = window.setTimeout(tick, 1000 - ((t - startedAt) % 1000) + 5);
};
tick();
return () => window.clearTimeout(timer);
}, [running, startedAt]);
const elapsed = startedAt === undefined ? undefined : (running ? now : (endedAt ?? now)) - startedAt;
const showTime = elapsed !== undefined && (running || doneLabel === undefined);
const text = running ? label : (doneLabel ?? (elapsed !== undefined ? "Done in" : "Done"));
return (
<div className={`inline-flex items-center gap-2.5 text-[13px] ${className}`} {...props}>
<span aria-hidden="true" className="relative flex h-4 min-w-4 items-center justify-center">
{/* The mark stops moving once it has faded out. */}
<span className={running ? "" : "[&_*]:[animation-play-state:paused]"} style={swap(running, reduced)}>
<Mark variant={variant} />
</span>
<span className="absolute inset-0 flex items-center justify-center text-muted-foreground" style={swap(!running, reduced)}>
<svg viewBox="0 0 16 16" fill="none" className="size-4">
<path
d="M3.5 8.5 6.5 11.5 12.5 4.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
pathLength={1}
strokeDasharray={1}
style={{ strokeDashoffset: running ? 1 : 0, transition: !running && !reduced ? `stroke-dashoffset 420ms ${MORPH} 120ms` : "none" }}
/>
</svg>
</span>
</span>
<span aria-hidden="true" className="inline-flex items-baseline gap-2">
<span className={`font-medium transition-colors duration-300 ${running ? SHEEN : "text-foreground/75"}`}>
<TextMorph>{text}</TextMorph>
</span>
{elapsed !== undefined && (
<span
className="-ml-2 grid"
style={{ gridTemplateColumns: showTime ? "1fr" : "0fr", opacity: showTime ? 1 : 0, transition: reduced ? "none" : `grid-template-columns 420ms ${MORPH}, opacity 300ms ${MORPH}` }}
>
<span className="min-w-0 pl-2 tabular-nums text-muted-foreground [clip-path:inset(-4px_0)]">
<Elapsed ms={elapsed} settled={!running} reduced={reduced} />
</span>
</span>
)}
</span>
{/* What it's doing, for screen readers: the label while it works, then the outcome. Never the ticking timer. */}
<span role="status" aria-live="polite" className="sr-only">
{running ? label : showTime && elapsed !== undefined ? `${text} ${formatElapsed(elapsed)}` : text}
</span>
</div>
);
}
Registry item: /r/thinking-indicator.json