Browse all components
Thinking Indicator UI
The loading state before the first token — bouncing dots, a blinking caret, or a labeled status line while the model thinks.
Preview
Presets
Props
Agent promptClaude Code · Cursor · any agent
Add the Thinking Indicator UI component from the Scrim UI registry to this project and use it in the configuration described below.
Thinking Indicator UI — The loading state before the first token — bouncing dots, a blinking caret, or a labeled status line while the model thinks.
## 1. Install
```bash
npx shadcn@latest add @scrimui/thinking-indicator
```
This writes a single file to `components/ui/thinking-indicator.tsx`. It is plain React + Tailwind with no runtime dependencies — no Radix, no CVA, nothing to add to package.json. If this project does not use the shadcn CLI, copy the source from https://scrimui.dev/components/thinking-indicator to the same path by hand; nothing in the file depends on shadcn.
## 2. Use it
```tsx
import { ThinkingIndicator } from "@/components/ui/thinking-indicator";
<ThinkingIndicator
variant="dots"
label="Thinking"
/>
```
## 3. The configuration that matters
This is the component's "Dots" state — The most recognised 'working on it' signal in chat UIs.
Every prop is at its default value. Keep the call site minimal — do not write out default values.
Reference: https://scrimui.dev/components/thinking-indicatorFollows the props below — change a control and the prompt changes with it, so an agent reproduces that configuration instead of the defaults.
Install
npx shadcn@latest add @scrimui/thinking-indicatorComponent source
React + Tailwind component using your shadcn theme. No additional component dependencies.
"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}}
@media(prefers-reduced-motion:reduce){.scrim-thinking *{animation:none!important}}
`;
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={`scrim-thinking inline-flex items-center gap-2 text-sm text-muted-foreground ${className}`}
>
<style>{KEYFRAMES}</style>
<span className="flex min-h-8 items-center gap-2">
{variant === "dots" && <Dots />}
{variant === "caret" && <Caret />}
{variant === "label" && (
<span
className="inline-block text-muted-foreground"
style={{ animation: "aiui-think-pulse 1.4s infinite ease-in-out" }}
>
{label}…
</span>
)}
{variant !== "label" && <span>{label}…</span>}
</span>
</div>
);
}
When to use it
- 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.
What breaks in production
- 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
A collapsible chain-of-thought panel — show the model's reasoning trace with elapsed time, folded away by default.
A multi-step reasoning trace — each step with its own status and timer, so a long think reads as progress, not a hang.
The message input at the heart of an AI chat app — file attachments, model picker, tool toggles, voice, and a send button that turns into stop.
A chat input that accepts file uploads — attachment chips with upload progress, type icons, and one-click removal.