Skip to content
Scrim UI

Thinking Indicator UI

The loading state before the first token — bouncing dots, a blinking caret, or a labeled status line while the model thinks.

thinkingindicatorloadingprogress

Preview

Thinking…

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

Follows 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-indicator

Component source

React + Tailwind component using your shadcn theme. No additional component dependencies.

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}}
@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