AI

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { ChevronDown, Sparkles } from "lucide-react"
import { cn } from "@/lib/utils"

/* -------------------------------------------------------------------------- *
 * Motion
 *
 * Every keyframe and every reduced-motion override lives in ONE hoisted
 * stylesheet (React 19 <style href precedence>): same sheet, same specificity,
 * later rule wins — so the reduced-motion branch can never lose a cascade race
 * with a utility class, and duplicate instances dedupe by href.
 *

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/thinking-indicator.json

Prompt

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 handoffstatus="done" never yanks the line: it freezes the clock, fades the row out over exitDuration, then unmounts itself and fires onExited once, 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 onExited per run no matter how the effect is re-entered, and re-arms only when the line goes live again, so a done → thinking → done flip-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.

On This Page