Thinking indicator
A live status line that names what the agent is doing right now, with elapsed time.
Installation
npx shadcn@latest add "@assistant-ui/elements-thinking-indicator"First time? Set up a runtime
Runtime components read their state from an assistant-ui runtime. Add one to an existing project:
npx assistant-ui@latest initThen wrap your app in a runtime provider:
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { useChatRuntime, AssistantChatTransport } from "@assistant-ui/ai-sdk";
export default function App() {
const runtime = useChatRuntime({
transport: new AssistantChatTransport({ api: "/api/chat" }),
});
return (
<AssistantRuntimeProvider runtime={runtime}>
{/* your components */}
</AssistantRuntimeProvider>
);
}The installation guide covers new projects, templates, and API routes.
npx shadcn@latest add "@assistant-ui/elements-thinking-indicator"Props-driven: no runtime or provider required.
A pulsing dot, a label that fades in fresh every time it changes, and an optional elapsed-time badge. With a runtime you derive the label from whatever the message can tell you and tick the elapsed badge yourself; standalone you pass both in directly.
Getting started
The runtime does not hand you a phrase like "Reading the docs"; it hands you the parts that make up the message so far. The most concrete label you can build from that is the name of whatever tool call is still pending, falling back to a plain "Thinking" while the run is active and nothing else is happening yet.
Name what is happening from the message parts
"use client";
import { useAuiState } from "@assistant-ui/react";
function useThinkingLabel() {
return useAuiState((s) => {
if (s.message.status?.type !== "running") return undefined;
const pending = s.message.parts.find(
(part) => part.type === "tool-call" && part.result === undefined,
);
if (pending?.type === "tool-call") return `Running ${pending.toolName}`;
return s.message.parts.length === 0 ? "Thinking" : undefined;
});
}Once the assistant has streamed visible text and no tool call is pending, this returns undefined: that is your cue to stop rendering the indicator and let the real content show instead.
Tick the elapsed badge
import { useEffect, useState } from "react";
import { ThinkingIndicator } from "@/components/assistant-ui/elements/thinking-indicator";
function useElapsedLabel(active: boolean) {
const [label, setLabel] = useState<string | undefined>(undefined);
useEffect(() => {
if (!active) {
setLabel(undefined);
return;
}
const start = Date.now();
const id = setInterval(() => {
setLabel(`${Math.round((Date.now() - start) / 1000)}s`);
}, 1000);
return () => clearInterval(id);
}, [active]);
return label;
}
function AssistantThinking() {
const label = useThinkingLabel();
const elapsed = useElapsedLabel(label !== undefined);
if (label === undefined) return null;
return <ThinkingIndicator label={label} elapsed={elapsed} />;
}There is no runtime selector for "seconds elapsed so far": metadata.timing only finalizes once the message stops streaming, so a live badge needs its own timer, started the moment you have a label to show.
Standalone, label is a plain string and elapsed is a plain string; the component only handles the fade between labels and the layout.
Hold the label yourself
"use client";
import { useState } from "react";
import { ThinkingIndicator } from "@/components/assistant-ui/elements/thinking-indicator";
export function Status() {
const [label, setLabel] = useState("Thinking");
return <ThinkingIndicator label={label} />;
}Changing label replays the shimmer and slide-in on the new text; the component keys its inner label on the string itself.
Add elapsed time
<ThinkingIndicator label={label} elapsed="12s" />Omit elapsed entirely to hide the badge; passing an empty string still renders it.
Examples
Reacting to a pending tool call
The label only needs to change; the fade-in is automatic because the element keys its shimmer span on the label text.
const pending = message.parts.find(
(part) => part.type === "tool-call" && part.result === undefined,
);
const label = pending?.type === "tool-call" ? `Running ${pending.toolName}` : "Thinking";Restyle the status line
The dot is fixed to bg-blue-500; the label uses ShimmerLabel and the elapsed badge uses the shared mono token from surfaces.tsx. className on the root only affects layout (it starts as flex items-center gap-2.5).
<ThinkingIndicator label={label} elapsed={elapsed} className="gap-1.5 text-xs" />API reference
Message state
| Selector | Type | Description |
|---|---|---|
s.message.status | MessageStatus | undefined | status.type === "running" while the assistant is still producing this message. |
s.message.parts | readonly PartState[] | Scan for a "tool-call" part with no result yet to name what is running; an empty array with the message still running means nothing has arrived at all. |
ThinkingIndicator
| Prop | Type | Default | Description |
|---|---|---|---|
label | string | required | Status text; changing it replays the fade-in. |
elapsed | string | undefined | Preformatted elapsed time shown after the label. Omit to hide the badge. | |
className | string | Merged onto the root. |
All other div props except children are forwarded to the root.