Lunia DS

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.

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-indicator

Usage

The demo above, as code.

demo.tsx
'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).

PropTypeDefaultDescription
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.
labelstring"Thinking"Label while running.
doneLabelstringundefinedLabel 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.
startedAtnumberundefinedStart 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.

IDTriggerElementPropertyFrom → toTimingReduced motion
TH1While running, variant orbitorbit dotrotate around the ring, repeats0deg → 360degduration-loop (1600)ease-linearNo loop
TH2While running, variant dotsdot (3)translate y and opacity wave, repeats0, 0.35 → -3px, 1 (at 40%)duration-6xl (900)ease-loopdelay 150 x dotNo loop
TH3While running, variant pulsepulse dotscale and opacity breathe, repeats0.82, 0.55 → 1, 1 (at 50%)duration-loop (1600)ease-loopNo loop
TH4While running, variant scanscan bar (40% of the track)translate x across the track, repeats-100% → 250%duration-6xl (900)ease-loopNo loop
TH5While runninglabelsweep of light (mask position), repeats150% → -50%duration-loop (1600)ease-linearNo loop
TH6Running to done, or backmark and checkopacity, blur1, 0 → 0, blur-swap (leaving); 0, blur-swap to 1, 0 (arriving)duration-md (260)ease-outSwaps at once
TH7Running to done, or backmark and checkscale1 → scale-swap (leaving); scale-swap to 1 (arriving)duration-xl (380)ease-outSwaps at once
TH8Running to donecheckstroke drawn0% → 100%duration-2xl (420)ease-outdelay duration-2xs (120)Shows at once
TH9Status changelabeltext morphThinking → Done induration-3xl (460)ease-outSwaps at once
TH10Running to donelabelcolortext-primary (with sweep) → text-secondaryduration-lg (320)ease-outSwaps at once
TH11Each whole second while runningtimer secondsnumber roll upn s → n + 1 sduration-4xl (520)ease-outSwaps at once
TH12Running to donetimer secondsnumber roll, tenths join44s → 44.6sduration-4xl (520)ease-outSwaps at once
TH13Timer reaches the first minuteminutes columncolumn width (grid columns)0fr → 1frduration-2xl (420)ease-outResizes at once
TH14Timer reaches the first minuteminutes columnopacity0 → 1duration-lg (320)ease-outShows at once
TH15Running to done with doneLabel settimercolumn width (grid columns)1fr → 0frduration-2xl (420)ease-outResizes at once
TH16Running to done with doneLabel settimeropacity1 → 0duration-lg (320)ease-outHides at once
TH17While running, variant grid, pattern wavegrid cell (all 9)opacity (dim, plateau, dim), repeats0.15 → 1duration-loop (1600)ease-loopdelay 400 x diagonalNo loop
TH18While running, variant grid, pattern orbitgrid cell (8 edge cells)opacity (dim, plateau, dim), repeats0.15 → 1duration-loop (1600)ease-loopdelay 200 x cellNo 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.