AI output
Thinking indicator
A small mark, a label and a timer that fill the wait before the first word of an answer, where a moving mark and a light sweeping the label say the model is working and a drawn check with the final time says it has finished.
Live preview
Loading preview
The five marks one under another (grid in its wave pattern, then the orbit pattern), each with "Thinking" and a timer that starts at 0s; on Replay each one finishes a little after the one above and shows "Done in" with the time in tenths.
Installation
Run this in your app after the one-time setup on the Getting started page.
$ npx @ganisigit/lunia-ds add thinking-indicatorUsage
The demo above, as code.
'use client';
import * as React from 'react';
import {
type GridPattern,
ThinkingIndicator,
type ThinkingStatus,
type ThinkingVariant,
} from './thinking-indicator';
const ROWS: { variant: ThinkingVariant; pattern?: GridPattern; doneAfter: number }[] = [
{ variant: 'orbit', doneAfter: 2600 },
{ variant: 'dots', doneAfter: 3400 },
{ variant: 'pulse', doneAfter: 4100 },
{ variant: 'scan', doneAfter: 4800 },
{ variant: 'grid', pattern: 'wave', doneAfter: 5300 },
{ variant: 'grid', pattern: 'orbit', doneAfter: 5900 },
];
export default function ThinkingIndicatorDemo({ replay = 0 }: { replay?: number }) {
const [startedAt, setStartedAt] = React.useState<number>();
const [done, setDone] = React.useState<boolean[]>(() => ROWS.map(() => false));
// The clock is read on the client only, so the prerendered page and the first client render agree.
React.useEffect(() => setStartedAt(Date.now()), []);
React.useEffect(() => {
if (replay === 0) return;
setStartedAt(Date.now());
setDone(ROWS.map(() => false));
const timers = ROWS.map((row, i) =>
window.setTimeout(() => setDone((list) => list.map((d, j) => d || j === i)), row.doneAfter),
);
return () => {
for (const id of timers) window.clearTimeout(id);
};
}, [replay]);
return (
<div className="flex flex-col gap-lg">
{ROWS.map((row, i) => {
const status: ThinkingStatus = done[i] ? 'done' : 'running';
return (
<ThinkingIndicator
key={`${row.variant}-${row.pattern ?? ''}`}
variant={row.variant}
pattern={row.pattern}
status={status}
startedAt={startedAt}
/>
);
})}
</div>
);
}
API
Parts: root (inline row), mark slot (16px), mark (orbit, dots, pulse, scan or grid), check (drawn path, same slot), label (text morph, sweep while running), timer (minutes column, seconds number roll), live region (visually hidden).
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | "orbit" | "dots" | "pulse" | "scan" | "grid" | "orbit" | Which mark moves while the model works. |
| status | "running" | "done" | "running" | Running shows the mark and the sweep; done swaps the mark for a check and stops the timer. |
| pattern | "wave" | "orbit" | "wave" | How the grid mark lights its cells: a wave that moves to the right, or one light that goes around the edge. Used only when variant is grid. |
| label | string | "Thinking" | Label while running. |
| doneLabel | string | undefined | Label when done. When set, the timer hides; when not set, the label is "Done in" with the final time, or "Done" without a start time. |
| startedAt | number | undefined | Start time in ms (Date.now()). When set, a timer shows the time since then. |
Motion
Every row names a token from the Motion page. The last column says what happens when the user asks for reduced motion.
| ID | Trigger | Element | Property | From → to | Timing | Reduced motion |
|---|---|---|---|---|---|---|
| TH1 | While running, variant orbit | orbit dot | rotate around the ring, repeats | 0deg → 360deg | duration-loop (1600)ease-linear | No loop |
| TH2 | While running, variant dots | dot (3) | translate y and opacity wave, repeats | 0, 0.35 → -3px, 1 (at 40%) | duration-6xl (900)ease-loopdelay 150 x dot | No loop |
| TH3 | While running, variant pulse | pulse dot | scale and opacity breathe, repeats | 0.82, 0.55 → 1, 1 (at 50%) | duration-loop (1600)ease-loop | No loop |
| TH4 | While running, variant scan | scan bar (40% of the track) | translate x across the track, repeats | -100% → 250% | duration-6xl (900)ease-loop | No loop |
| TH5 | While running | label | sweep of light (mask position), repeats | 150% → -50% | duration-loop (1600)ease-linear | No loop |
| TH6 | Running to done, or back | mark and check | opacity, blur | 1, 0 → 0, blur-swap (leaving); 0, blur-swap to 1, 0 (arriving) | duration-md (260)ease-out | Swaps at once |
| TH7 | Running to done, or back | mark and check | scale | 1 → scale-swap (leaving); scale-swap to 1 (arriving) | duration-xl (380)ease-out | Swaps at once |
| TH8 | Running to done | check | stroke drawn | 0% → 100% | duration-2xl (420)ease-outdelay duration-2xs (120) | Shows at once |
| TH9 | Status change | label | text morph | Thinking → Done in | duration-3xl (460)ease-out | Swaps at once |
| TH10 | Running to done | label | color | text-primary (with sweep) → text-secondary | duration-lg (320)ease-out | Swaps at once |
| TH11 | Each whole second while running | timer seconds | number roll up | n s → n + 1 s | duration-4xl (520)ease-out | Swaps at once |
| TH12 | Running to done | timer seconds | number roll, tenths join | 44s → 44.6s | duration-4xl (520)ease-out | Swaps at once |
| TH13 | Timer reaches the first minute | minutes column | column width (grid columns) | 0fr → 1fr | duration-2xl (420)ease-out | Resizes at once |
| TH14 | Timer reaches the first minute | minutes column | opacity | 0 → 1 | duration-lg (320)ease-out | Shows at once |
| TH15 | Running to done with doneLabel set | timer | column width (grid columns) | 1fr → 0fr | duration-2xl (420)ease-out | Resizes at once |
| TH16 | Running to done with doneLabel set | timer | opacity | 1 → 0 | duration-lg (320)ease-out | Hides at once |
| TH17 | While running, variant grid, pattern wave | grid cell (all 9) | opacity (dim, plateau, dim), repeats | 0.15 → 1 | duration-loop (1600)ease-loopdelay 400 x diagonal | No loop |
| TH18 | While running, variant grid, pattern orbit | grid cell (8 edge cells) | opacity (dim, plateau, dim), repeats | 0.15 → 1 | duration-loop (1600)ease-loopdelay 200 x cell | No loop |
Accessibility
- The mark, the check, the label and the timer are aria-hidden; a visually hidden role="status" aria-live="polite" region says the label while running and the result once done (for example "Done in 44.6s").
- The live region never reads the ticking timer, so screen readers hear one line at the start and one at the end.
- Under reduced motion the mark holds still (grid cells hold at 0.5 opacity), the label has no sweep and shows in text-secondary, and the check, label and time swap at once; the words still say running or done.
- The mark stops its loop (animation paused) once it has faded out, so a done indicator costs nothing.
- Done is shown by a check plus words, never by color alone.