Thinking Indicator UI
Animated pre-stream indicator — bouncing dots, a caret, or a labeled status line.
thinkingindicatorloadingprogress
AIThinking…
Props
Component source
Single-file React + Tailwind component. No dependencies — drop it into any project with Tailwind configured.
thinking-indicator.tsx
"use client";
import * as React from "react";
/* ------------------------------------------------------------------ */
/* Types */
/* ------------------------------------------------------------------ */
export type ThinkingIndicatorProps = {
/** Visual style — bouncing dots, a blinking caret, or a labeled pulse. */
variant?: "dots" | "caret" | "label";
/** Short status line shown alongside the animation (default "Thinking"). */
label?: string;
className?: string;
};
/* ------------------------------------------------------------------ */
/* Animations — one shared keyframes block */
/* ------------------------------------------------------------------ */
const KEYFRAMES = `
@keyframes aiui-think-bounce{0%,80%,100%{opacity:.25;transform:scale(.85)}40%{opacity:1;transform:scale(1)}}
@keyframes aiui-think-caret{50%{opacity:0}}
/* .85, not .4: this one pulses real text, and at .4 the label rendered
1.68:1 against the bubble — unreadable for part of every cycle. The dots
above may fade further because they are decorative and aria-hidden. */
@keyframes aiui-think-pulse{0%,100%{opacity:.85}50%{opacity:1}}
`;
function Dots() {
return (
<span className="flex items-center gap-1" aria-hidden>
{[0, 1, 2].map((i) => (
<span
key={i}
className="h-1.5 w-1.5 rounded-full bg-current"
style={{ animation: `aiui-think-bounce 1.2s ${i * 0.15}s infinite ease-in-out` }}
/>
))}
</span>
);
}
function Caret() {
return (
<span
aria-hidden
className="inline-block h-[1em] w-[2px] translate-y-[2px] rounded-full bg-current"
style={{ animation: "aiui-think-caret 1s steps(1) infinite" }}
/>
);
}
/* ------------------------------------------------------------------ */
/* ThinkingIndicator */
/* ------------------------------------------------------------------ */
export function ThinkingIndicator({
variant = "dots",
label = "Thinking",
className = "",
}: ThinkingIndicatorProps) {
return (
<div
className={`inline-flex items-center gap-2 text-sm text-zinc-500 dark:text-zinc-400 ${className}`}
>
<style>{KEYFRAMES}</style>
<span className="flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-lg bg-zinc-900 text-xs font-semibold text-white dark:bg-zinc-100 dark:text-zinc-900">
AI
</span>
<span className="flex min-h-8 items-center gap-2 rounded-2xl rounded-tl-md border border-zinc-200 bg-zinc-50 px-3.5 py-2 dark:border-zinc-800 dark:bg-zinc-800/60">
{variant === "dots" && <Dots />}
{variant === "caret" && <Caret />}
{variant === "label" && (
<span
/* zinc-600 so the trough of the pulse still clears AA (5.05:1). */
className="inline-block text-zinc-600 dark:text-zinc-300"
style={{ animation: "aiui-think-pulse 1.4s infinite ease-in-out" }}
>
{label}…
</span>
)}
{variant !== "label" && <span>{label}…</span>}
</span>
</div>
);
}
Usage Guidelines
- Use the thinking indicator only for the pre-stream gap; the moment text starts flowing, it should disappear.
- Pick a label that says what the model is doing, not a generic spinner word — 'Thinking' beats 'Loading'.
- Keep the indicator inside the message thread so the coming reply has a home.
- Animate with CSS keyframes, not per-frame JS — a three-dot bounce shouldn't cost a re-render.
Common UX Mistakes
- Letting the indicator linger after the first token — it directly contradicts the streaming reply it was announcing.
- Replacing it with an infinite spinner for a wait you can't explain; a label is almost always more honest.
- Animating dots with setInterval instead of CSS, causing layout jank on every tick.
Related Components
Thinking / Reasoning
Collapsible reasoning trace with elapsed time and step indicators.
Reasoning Steps
Collapsible multi-step reasoning trace with per-step status and an elapsed timer.
Prompt Input
The core input for AI chat products — with attachments, model selector, tools, voice and loading states.
Prompt Input + Attachments
Prompt input with file attachment chips, upload progress and removal.