Agent Thinking
The “agent is working” row above a chat composer: four animated indicators, a shimmering label and a live timer.
npx shadcn@latest add https://sundaeui.dev/r/agent-thinking.json'use client'
import { useEffect, useRef, useState } from 'react'
import './agent-thinking.css'
export type ThinkingVariant = 'wave' | 'spin' | 'stars' | 'infinity'
export type ThinkingTone = 'neutral' | 'cherry' | 'mint' | 'blueberry' | 'butterscotch'
export interface AgentThinkingProps {
/** Which animation. Default "wave". */
variant?: ThinkingVariant
/** Text beside the indicator. Default "Thinking". */
label?: string
/** Colour of the indicator (and the shimmer highlight). Default "neutral". */
tone?: ThinkingTone
/** Any CSS colour; overrides tone. */
color?: string
/** Light sweeping across the label. Default true. */
shimmer?: boolean
/** Show elapsed time. Default true. */
showTimer?: boolean
/** When the work started (Date.now()). Defaults to when this mounted. */
startedAt?: number
/** False once the agent is done: the animation stops and the label becomes doneLabel. Default true. */
running?: boolean
/** Shown when running is false. Receives the seconds taken. Default "Thought for 4s". */
doneLabel?: (seconds: number) => string
size?: 'sm' | 'md' | 'lg'
className?: string
style?: React.CSSProperties
}
const fmt = (ms: number) => (ms < 60_000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.floor(ms / 60_000)}m ${String(Math.floor((ms % 60_000) / 1000)).padStart(2, '0')}s`)
/* Each variant is pure CSS/SVG animation: no JavaScript frame loop. */
function Wave() {
return (
<span className="sd-at__grid sd-at__grid--4" aria-hidden>
{Array.from({ length: 16 }, (_, i) => (
<i key={i} style={{ '--d': (i % 4) + Math.floor(i / 4) } as React.CSSProperties} />
))}
</span>
)
}
/** Outer ring of a 3×3 grid, clockwise from the top-left. */
const RING = [0, 1, 2, 5, 8, 7, 6, 3]
function Spin() {
return (
<span className="sd-at__grid sd-at__grid--3" aria-hidden>
{Array.from({ length: 9 }, (_, i) => {
const k = RING.indexOf(i)
return <i key={i} data-center={k < 0 || undefined} style={{ '--k': k < 0 ? 0 : k } as React.CSSProperties} />
})}
</span>
)
}
const STAR = 'M12 2.5c.5 4.6 2.4 6.5 7 7-4.6.5-6.5 2.4-7 7-.5-4.6-2.4-6.5-7-7 4.6-.5 6.5-2.4 7-7Z'
function Stars() {
return (
<svg className="sd-at__stars" viewBox="0 0 24 24" aria-hidden>
<path d={STAR} style={{ '--s': 0 } as React.CSSProperties} transform="translate(-1.6 3.6) scale(0.95)" />
<path d={STAR} style={{ '--s': 1 } as React.CSSProperties} transform="translate(10.2 -0.8) scale(0.62)" />
<path d={STAR} style={{ '--s': 2 } as React.CSSProperties} transform="translate(9.4 8.6) scale(0.74)" />
</svg>
)
}
const LEMNISCATE = 'M12 12c-2-2.6-3.6-4.4-6-4.4a4.4 4.4 0 0 0 0 8.8c2.4 0 4-1.8 6-4.4s3.6-4.4 6-4.4a4.4 4.4 0 0 1 0 8.8c-2.4 0-4-1.8-6-4.4Z'
function Infinity_() {
return (
<svg className="sd-at__inf" viewBox="0 0 24 24" aria-hidden>
<path className="sd-at__track" d={LEMNISCATE} pathLength={100} />
<path className="sd-at__tail sd-at__tail--3" d={LEMNISCATE} pathLength={100} />
<path className="sd-at__tail sd-at__tail--2" d={LEMNISCATE} pathLength={100} />
<path className="sd-at__comet" d={LEMNISCATE} pathLength={100} />
</svg>
)
}
const INDICATORS: Record<ThinkingVariant, () => React.ReactElement> = { wave: Wave, spin: Spin, stars: Stars, infinity: Infinity_ }
/**
* The "agent is working" row that sits above a chat composer: a small
* animated indicator (wave, spin, stars or infinity), a shimmering label and
* an elapsed timer. When the agent finishes, it settles into "Thought for Ns".
*/
export function AgentThinking({
variant = 'wave',
label = 'Thinking',
tone = 'neutral',
color,
shimmer = true,
showTimer = true,
startedAt,
running = true,
doneLabel = (s) => `Thought for ${s < 10 ? s.toFixed(1) : Math.round(s)}s`,
size = 'md',
className,
style,
}: AgentThinkingProps) {
const mounted = useRef(startedAt ?? Date.now())
const start = startedAt ?? mounted.current
const [now, setNow] = useState(() => Date.now())
const [endedAt, setEndedAt] = useState<number | null>(null)
// One interval, only while the timer is visible and the agent is running.
useEffect(() => {
if (!running) {
setEndedAt((e) => e ?? Date.now())
return
}
setEndedAt(null)
if (!showTimer) return
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches
setNow(Date.now())
const t = setInterval(() => setNow(Date.now()), reduce ? 1000 : 100)
return () => clearInterval(t)
}, [running, showTimer])
const elapsed = Math.max(0, (endedAt ?? now) - start)
const Indicator = INDICATORS[variant] ?? Wave
const text = running ? label : doneLabel(elapsed / 1000)
return (
<div
className={['sd-at', className].filter(Boolean).join(' ')}
data-variant={variant}
data-tone={tone}
data-size={size}
data-running={running || undefined}
style={{ ...(color ? { '--at-tone': color } : {}), ...style } as React.CSSProperties}
role="status"
aria-live="polite"
>
<span className="sd-at__ind" key={variant}>
<Indicator />
</span>
<span className="sd-at__label" data-shimmer={(running && shimmer) || undefined}>
{text}
</span>
{showTimer && running && (
<span className="sd-at__time" aria-hidden>
{fmt(elapsed)}
</span>
)}
</div>
)
}.sd-at {
--at-ink: #17171a;
--at-ink-3: #9a9aa1;
--at-tone: #3a3a40;
--at-dim: color-mix(in oklab, var(--at-tone) 22%, transparent);
--at-size: 18px;
--at-t: 1.6s;
display: inline-flex;
align-items: center;
gap: 9px;
color: var(--at-ink);
font-family: inherit;
font-size: 14px;
line-height: 1;
white-space: nowrap;
}
.dark .sd-at,
.sd-at.is-dark { --at-ink: #f1f1f1; --at-ink-3: #75757c; --at-tone: #e9e9ea; }
.sd-at.is-light { --at-ink: #17171a; --at-ink-3: #9a9aa1; --at-tone: #3a3a40; }
.sd-at[data-tone='cherry'] { --at-tone: #ff6a55; }
.sd-at[data-tone='mint'] { --at-tone: #3fcf98; }
.sd-at[data-tone='blueberry'] { --at-tone: #7c96ff; }
.sd-at[data-tone='butterscotch'] { --at-tone: #f4bb58; }
.sd-at[data-size='sm'] { --at-size: 14px; font-size: 13px; gap: 7px; }
.sd-at[data-size='lg'] { --at-size: 24px; font-size: 16px; gap: 11px; }
.sd-at__ind { position: relative; display: grid; place-items: center; width: var(--at-size); height: var(--at-size); flex: none; color: var(--at-tone); animation: sd-at-swap 360ms cubic-bezier(0.23, 1, 0.32, 1); }
@keyframes sd-at-swap { from { opacity: 0; transform: scale(0.6) rotate(-30deg); filter: blur(3px); } }
/* Dot grids */
.sd-at__grid { display: grid; width: 100%; height: 100%; place-items: center; }
.sd-at__grid--4 { grid-template-columns: repeat(4, 1fr); grid-template-rows: repeat(4, 1fr); }
.sd-at__grid--3 { grid-template-columns: repeat(3, 1fr); grid-template-rows: repeat(3, 1fr); }
.sd-at__grid i { display: block; border-radius: 50%; background: currentColor; }
.sd-at__grid--4 i { width: 62%; height: 62%; }
.sd-at__grid--3 i { width: 58%; height: 58%; }
/* Wave: a diagonal crest travels from the top-left corner to the bottom-right. */
.sd-at[data-variant='wave'] .sd-at__grid i { opacity: 0.22; transform: scale(0.55); }
.sd-at[data-variant='wave'][data-running] .sd-at__grid i { animation: sd-at-wave var(--at-t) cubic-bezier(0.45, 0, 0.55, 1) infinite; animation-delay: calc(var(--d) * var(--at-t) / 9); }
@keyframes sd-at-wave {
0%, 100% { opacity: 0.22; transform: scale(0.55); }
22% { opacity: 1; transform: scale(1.12); }
44% { opacity: 0.35; transform: scale(0.7); }
}
/* Spin: a bright head with a fading tail orbits the outer ring. */
.sd-at[data-variant='spin'] .sd-at__grid i { opacity: 0.18; }
.sd-at[data-variant='spin'] .sd-at__grid i[data-center] { opacity: 0.35; transform: scale(0.7); }
.sd-at[data-variant='spin'][data-running] .sd-at__grid i:not([data-center]) { animation: sd-at-orbit 1.05s linear infinite; animation-delay: calc(var(--k) * 1.05s / 8 - 1.05s); }
.sd-at[data-variant='spin'][data-running] .sd-at__grid i[data-center] { animation: sd-at-core 2.1s ease-in-out infinite; }
@keyframes sd-at-orbit {
0% { opacity: 1; transform: scale(1.15); }
12% { opacity: 0.75; transform: scale(1); }
40% { opacity: 0.25; transform: scale(0.8); }
100% { opacity: 0.16; transform: scale(0.75); }
}
@keyframes sd-at-core { 50% { opacity: 0.6; transform: scale(0.85); } }
/* Stars: three sparkles twinkle in turn. */
.sd-at__stars { width: 100%; height: 100%; overflow: visible; fill: currentColor; }
.sd-at__stars path { transform-box: fill-box; transform-origin: center; opacity: 0.25; scale: 0.6; }
.sd-at[data-running] .sd-at__stars path { animation: sd-at-twinkle 1.8s cubic-bezier(0.45, 0, 0.55, 1) infinite; animation-delay: calc(var(--s) * 0.6s); }
@keyframes sd-at-twinkle {
0%, 100% { opacity: 0.15; scale: 0.35; rotate: 0deg; }
35% { opacity: 1; scale: 1; rotate: 45deg; filter: drop-shadow(0 0 2px currentColor); }
65% { opacity: 0.4; scale: 0.6; rotate: 80deg; }
}
/* Infinity: a comet with a two-part tail runs the figure eight. */
.sd-at__inf { width: 120%; height: 120%; overflow: visible; fill: none; stroke-linecap: round; }
.sd-at__inf path { stroke: currentColor; stroke-width: 1.8; }
.sd-at__track { opacity: 0.16; }
.sd-at__comet, .sd-at__tail { stroke-dasharray: 9 91; stroke-dashoffset: 0; }
.sd-at__comet { stroke-width: 2.4; filter: drop-shadow(0 0 1.5px currentColor); }
.sd-at__tail--2 { stroke-dasharray: 18 82; opacity: 0.4; }
.sd-at__tail--3 { stroke-dasharray: 30 70; opacity: 0.15; }
.sd-at[data-running] .sd-at__comet,
.sd-at[data-running] .sd-at__tail { animation: sd-at-comet 1.9s linear infinite; }
.sd-at[data-running] .sd-at__tail--2 { animation-delay: -0.05s; }
.sd-at[data-running] .sd-at__tail--3 { animation-delay: -0.1s; }
@keyframes sd-at-comet { to { stroke-dashoffset: -100; } }
/* Label and timer */
.sd-at__label { color: var(--at-ink-3); letter-spacing: -0.005em; }
.sd-at:not([data-running]) .sd-at__label { color: var(--at-ink-3); }
.sd-at__label[data-shimmer] {
background: linear-gradient(100deg, var(--at-ink-3) 30%, color-mix(in oklab, var(--at-tone) 45%, var(--at-ink)) 50%, var(--at-ink-3) 70%) 0 0 / 280% 100%;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: sd-at-shimmer 2s linear infinite;
}
@keyframes sd-at-shimmer { from { background-position: 140% 0; } to { background-position: -140% 0; } }
.sd-at__time { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.82em; color: var(--at-ink-3); font-variant-numeric: tabular-nums; opacity: 0.85; }
/* Finished: the indicator rests, slightly smaller and dimmer. */
.sd-at:not([data-running]) .sd-at__ind { opacity: 0.55; transform: scale(0.85); transition: opacity 300ms, transform 300ms; }
@media (prefers-reduced-motion: reduce) {
.sd-at *, .sd-at__ind { animation: none !important; }
.sd-at__label[data-shimmer] { background: none; color: var(--at-ink-3); }
.sd-at[data-variant='wave'] .sd-at__grid i:nth-child(6n + 1) { opacity: 0.9; transform: none; }
.sd-at[data-variant='spin'] .sd-at__grid i:first-child { opacity: 1; }
.sd-at__stars path:first-child { opacity: 1; scale: 1; }
.sd-at__comet { stroke-dashoffset: -20; }
}Overview
A small indicator, a label and an elapsed timer for the moment between sending a message and the first token. Four genuinely different animations: Wave, a diagonal crest rolling across a 4 × 4 dot grid; Spin, a bright head with a fading tail orbiting a 3 × 3 grid; Stars, three sparkles twinkling in turn; and Infinity, a comet with a two-part tail running a figure eight.
The label shimmers in the indicator’s tone while the agent runs. When you set running={false} the animation rests and the label becomes “Thought for 4.2s” (or your own doneLabel).
Every variant is CSS and SVG animation, so there’s no JavaScript frame loop; the only timer is one interval for the clock, cleared when it stops. Reduced motion shows a still indicator and a once-a-second clock.
When to use
- Between sending a message to an AI and the first streamed token.
- Background agent tasks shown inline in a thread.
When not to use
- Showing what the agent is actually doing: use Reasoning Trace or Web Search.
- Page or data loading: use a skeleton.
Installation
pnpm dlx shadcn@latest add https://sundaeui.dev/r/agent-thinking.jsonnpx shadcn@latest add https://sundaeui.dev/r/agent-thinking.jsonyarn dlx shadcn@latest add https://sundaeui.dev/r/agent-thinking.jsonbunx --bun shadcn@latest add https://sundaeui.dev/r/agent-thinking.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy agent-thinking.tsx and agent-thinking.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { AgentThinking } from '@/components/sundae/agent-thinking'
{status === 'thinking' && <AgentThinking variant="infinity" label="Reasoning" tone="blueberry" startedAt={sentAt} />}
// Keep it after the answer arrives, as a quiet record:
<AgentThinking variant="wave" running={false} startedAt={sentAt} />Props
| Prop | Type | Default |
|---|---|---|
variantWhich animation. | 'wave' | 'spin' | 'stars' | 'infinity' | 'wave' |
labelText beside the indicator. | string | 'Thinking' |
toneIndicator colour; also tints the shimmer. | 'neutral' | 'cherry' | 'mint' | 'blueberry' | 'butterscotch' | 'neutral' |
colorAny CSS colour, overriding tone. | string | — |
shimmerLight sweeping across the label while running. | boolean | true |
showTimerShow the elapsed time. | boolean | true |
startedAtDate.now() when work began. Defaults to mount time. | number | — |
runningFalse stops the animation and shows doneLabel. | boolean | true |
doneLabelLabel once finished. | (seconds: number) => string | "Thought for Ns" |
size14, 18 or 24 px indicator. | 'sm' | 'md' | 'lg' | 'md' |
className / styleCustom styling. Theme with .is-light / .is-dark or a .dark ancestor. | string / CSSProperties | — |
Accessibility
- The row is a polite status region, so “Thinking” and the finished “Thought for 4.2s” are announced; the ticking timer is hidden from screen readers.
- Indicators are decorative (aria-hidden).
- With reduced motion, all animation and the shimmer stop; the clock updates once a second.
Customization
- Custom properties on .sd-at: --at-tone (indicator), --at-ink-3 (label), --at-size, --at-t (wave period).
- Add your own variant: write a component and add it to INDICATORS.
FAQ
- Is Agent Thinking free?
- Yes. Agent Thinking is free for personal and commercial projects, with full source on this page.
- How do I install Agent Thinking?
- Run npx shadcn@latest add https://sundaeui.dev/r/agent-thinking.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Agent Thinking support keyboard and screen readers?
- The row is a polite status region, so “Thinking” and the finished “Thought for 4.2s” are announced; the ticking timer is hidden from screen readers. Indicators are decorative (aria-hidden).
- Does Agent Thinking respect reduced motion?
- With reduced motion, all animation and the shimmer stop; the clock updates once a second.
More like this
All components →- Pro
Reasoning Trace
Components · Refined - Pro
Composer Attachments
Components · Refined - Pro
Web Search
Components · Refined - Pro
Tool Call
Components · Refined - Pro
Cited Answer
Components · Refined Submit Button
Components · Refined