Thinking Indicator
A transcript-line thinking status — a highlight sweeping the label, a phase walk that never lies about progress, a clock that only appears once the wait is real, and a fade-out that hands the slot to the answer.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/thinking-indicator.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ThinkingIndicator" component: the single
line that stands in for an assistant reply between "request sent" and "first
token arrived". lucide-react for the glyphs, no animation library — the motion
is CSS keyframes shipped with the component.
Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement> minus
children (children are redeclared as the details slot); remaining props spread
on the root.
- phases?: ReadonlyArray<string | ThinkingPhase> (default ["Thinking"]), where
ThinkingPhase = { id?: string; label: string; duration?: number;
icon?: React.ReactNode }. Strings are shorthand for { label }.
- phase?: number | string — supplying it makes the cursor controlled and stops
the internal walk. onPhaseChange?(index, phase) fires only for the walk.
- status?: "thinking" | "done" (default "thinking").
- loop?: boolean (default false), phaseDuration?: number (default 2600ms).
- startedAt?: number (epoch ms), showElapsed?: boolean (default true),
elapsedAfter?: number (default 3000ms).
- shimmer?: boolean (default true), shimmerDuration?: number seconds
(default 2), shimmerSpread?: number px per character (default 2).
- ellipsis?: boolean (default true), icon?: React.ReactNode (false removes it).
- exitDuration?: number ms (default 420), onExited?: () => void.
- children + expanded / defaultExpanded / onExpandedChange / expandLabel /
collapseLabel: an optional disclosure. No children, no chevron.
- announce?: boolean (default true).
Behavior — the phase walk
- Normalise phases every render (strings to objects, blank labels dropped) and
never return an empty list: an empty `phases` prop still renders "Thinking".
Doing the work per render is what lets an inline array literal be passed
without restarting timers — every timer dependency below is a primitive
(hold, index, count, loop), never the array identity.
- Uncontrolled: hold each phase for `phase.duration ?? phaseDuration`, then
advance. Stop on the last phase unless `loop` — a run's progress is monotonic
and walking back to "Thinking" after "Writing the answer" would be a lie.
- The index is CLAMPED to the list, never reset: a `phases` array that shrinks
mid-run must not send a run that is already writing back to thinking.
- Controlled: `phase` may be an index or a phase id. An out-of-range number or
an unknown id pins a real phase (clamped / first) — a status line must never
render blank.
- Each phase swap replays a 0.22s enter (fade + 2px rise) and restarts the
sweep, keyed on the index.
Behavior — the clock
- Base the clock on `startedAt`, else on an epoch captured at mount. Hold the
reading at null until the first CLIENT tick: the server frame and the
hydrating frame must agree and Date.now() does not.
- The clock is invisible until elapsed >= elapsedAfter. One loop covers both
regimes: below the threshold sleep exactly until it (no wasted renders while
nothing is shown), above it schedule 1000 - ((now - base) % 1000) so ticks
re-derive from the epoch. setInterval drifts, and a throttled tab makes it
skip a whole second on screen.
- Format coarsely: 8s, 1m 4s, 2h 5m.
- status="done" stops the loop, so the last reading stays frozen through the
fade instead of blanking.
Behavior — done, and the handoff
- Status is compared during render (not in an effect): an effect would paint one
frame of the previous status and the exit would stutter on its first frame.
- thinking -> done puts the root into "exiting", plays the exit animation for
exitDuration, then renders null and calls onExited EXACTLY ONCE (a ref latch;
re-armed only when the line becomes visible again). exitDuration 0 unmounts on
the next frame. A component mounted already done renders null and never fires
onExited — nothing exited.
- done -> thinking is a NEW RUN: the walk restarts at phase 0 and the clock
re-bases, unless `startedAt` pins it.
- The parent mounts the answer in onExited, so the two never overlap in the
same slot.
Behavior — disclosure and screen readers
- The chevron exists only when children were passed. Native <button
aria-expanded aria-controls>, rotate the chevron 180deg, accessible name
toggles between expandLabel / collapseLabel. Mount the panel on first open and
keep it mounted (hidden attribute) so scroll offset and inner state survive a
collapse.
- announce: a PERMANENTLY mounted sr-only <span role="status" aria-live="polite"
aria-atomic="true"> holding the current label. A region inserted together with
its text is skipped by several screen readers; an always-present one is an
update and gets spoken. It empties while exiting — a line on its way out has
nothing left to say.
- The visible label carries aria-hidden while `announce` is on, so browse mode
does not read the status twice. The elapsed number is aria-hidden ALWAYS: a
ticking digit inside any live region interrupts the reader every second.
- Everything decorative (glyph, animated dots) is aria-hidden.
Rendering & styling
- Semantic tokens only: text-muted-foreground, text-foreground, border-border,
ring, bg-card in the host. The sweep paints with var(--ti-base,
var(--muted-foreground)) and var(--ti-glow, var(--foreground)). No hex, no
rgb(), no oklch().
- The shimmer is a gradient CLIPPED TO THE GLYPHS (background-clip:text +
color:transparent + background-size 250% 100%, animating background-position
100% -> 0%), not an overlay bar: an overlay would need the text's box and
would smear across the icon and the timer beside it. Highlight half-width =
label length * shimmerSpread px, clamped 28–160, so short labels are not
swallowed by the band and long ones still get a moving edge.
- The animated ellipsis lives OUTSIDE the clipped span. Inside it the glyphs are
painted by the parent's background, so an opacity animation on a child would
fade nothing at all. A trailing "…" or "..." already in the label is stripped,
so "Thinking…" never renders as "Thinking……".
- The enter animation and the sweep sit on two NESTED spans: one element cannot
run both, because the second `animation` shorthand replaces the first.
- Ship every keyframe and every reduced-motion override in ONE hoisted
stylesheet (React 19 <style href precedence>): same sheet, same specificity,
later rule wins, so the reduced-motion branch cannot lose a cascade race with
a utility class. Under prefers-reduced-motion the sweep becomes flat
muted text, dots sit solid, the glyph stops breathing, and the exit keeps its
opacity fade but drops the translate — SAME duration, so the JS unmount timer
stays in sync in both regimes.
- Root: flex column, `data-state="thinking" | "exiting"`, `data-phase` = id or
index for styling and tests. The label truncates (min-w-0) instead of widening
its container; the clock is tabular-nums.
- The chevron gets focus-visible:ring-2 focus-visible:ring-ring and is reachable
by keyboard like any button.
Cleanup
- Three timers only — walk, clock, exit — each cleared in its effect's cleanup.
No rAF, no listeners, no observers, nothing to leak on unmount.
Customization levers
- Rhythm: phaseDuration (2600ms) and per-phase duration; shimmerDuration
(2s — under ~1.2s it reads as a strobe, over ~3s it reads as broken).
- Threshold: elapsedAfter (3000ms) — raise it if your p95 is fast and a timer
would look alarming, drop it to 0 to time everything, or showElapsed={false}
to say nothing about time at all.
- Colour: set --ti-base / --ti-glow to any token (var(--chart-1),
var(--primary)) through className or style; both branches, animated and
reduced-motion, follow them.
- Anatomy: icon={false} for a bare line, ellipsis={false} when your labels
already end in punctuation, no children for no chevron, per-phase `icon` to
give each step its own glyph.
- Exit: exitDuration 0 for an instant swap, 600–800ms for a slower cross-fade
against an answer that fades in.
- Ownership: pass `phase` to drive the cursor from your stream events (tool
call started, retrieval finished) instead of letting the walk guess; pass
`startedAt` so a remount keeps the reading.
- Density: the root's text-sm / gap-2 and the details panel's border-l + pl-3
are the only spacing knobs; override through className.Concepts
- Phase walk, not a loop — phases advance in order and the last one holds; looping back to "Thinking" after "Writing the answer" would claim progress that was undone, so cycling is opt-in and the index is clamped rather than reset when the list changes under it.
- Deferred clock — waiting is only worth naming once it is felt: the timer stays hidden until the run crosses
elapsedAfter, and the single scheduling loop sleeps straight through to that moment before switching to second-boundary ticks derived from the epoch, so a throttled tab can never lose a second. - Sweep clipped to the glyphs — the highlight is a gradient painted through
background-clip: text, so it follows the words themselves instead of a rectangle dragged over them; the animated dots sit outside that clip because a child of a clipped span has no paint of its own to fade. - Graceful handoff —
status="done"never yanks the line: it freezes the clock, fades the row out overexitDuration, then unmounts itself and firesonExitedonce, which is the parent's cue to mount the answer into the slot that was just vacated. - One-shot exit lock — a ref latch guarantees exactly one
onExitedper run no matter how the effect is re-entered, and re-arms only when the line goes live again, so adone → thinking → doneflip-flop produces two runs, not two callbacks for one. - Quiet live region — the announcement region is mounted for the whole life of the line (a region inserted with its text is skipped by several screen readers), carries the phase words and never the digits, and empties on exit so the reader hears one sentence per phase and nothing on the way out.
PII Detector
A pre-send privacy scan — detected emails, phones, cards, IDs and names underlined by category, a hover or focus popover per entity, a mask-or-keep decision on each, and a masked preview of exactly what will be sent.
Queue Position
A generation-queue waiting room — a position dial that rolls down through every place it passes, a drain bar measured from where you joined, a self-correcting ETA, an optimistic notify-me switch and an arm-before-you-lose-it leave action.