Rotating Status New
Cycles through what the agent is doing with an icon per step, a soft slide-and-blur and an elapsed timer.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/ai-rotating-status.json02
Or copy the source below. Dependencies: lucide-react.
Source
components/niceui/ai/thinking/RotatingStatus.tsx
import { useEffect, useRef, useState } from 'react';
import { BookOpen, Check, PenLine, Search, type LucideIcon } from 'lucide-react';
import { cn } from '@/lib/cn';
export type StatusStep = { label: string; icon: LucideIcon; ms: number };
export type RotatingStatusProps = {
steps?: StatusStep[];
/** Label shown after the last step. */
doneLabel?: string;
loop?: boolean;
className?: string;
};
const DEFAULT: StatusStep[] = [
{ label: 'Searching the web…', icon: Search, ms: 1600 },
{ label: 'Reading 4 sources…', icon: BookOpen, ms: 1900 },
{ label: 'Writing the answer…', icon: PenLine, ms: 1700 },
];
/** Cycles through what the agent is doing, sliding each label up with a soft blur, with an elapsed timer. */
export function RotatingStatus({ steps = DEFAULT, doneLabel = 'Done', loop = true, className }: RotatingStatusProps) {
const [i, setI] = useState(0);
const [elapsed, setElapsed] = useState(0);
const t0 = useRef(performance.now());
const done = i >= steps.length;
useEffect(() => {
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) {
setI(steps.length);
return;
}
if (done) {
if (!loop) return;
const id = window.setTimeout(() => {
t0.current = performance.now();
setI(0);
}, 1600);
return () => clearTimeout(id);
}
const id = window.setTimeout(() => setI((n) => n + 1), steps[i].ms);
return () => clearTimeout(id);
}, [i, done, loop, steps]);
useEffect(() => {
if (done) return;
const id = window.setInterval(() => setElapsed((performance.now() - t0.current) / 1000), 100);
return () => clearInterval(id);
}, [done]);
const items = [...steps.map((s) => ({ label: s.label, icon: s.icon })), { label: doneLabel, icon: Check }];
const cur = Math.min(i, steps.length);
return (
<div role="status" aria-live="polite" className={cn('inline-flex items-center gap-2.5 rounded-full bg-ink-900 py-1.5 pl-2 pr-3 shadow-card', className)}>
<span className={cn('grid h-6 w-6 place-items-center rounded-full transition-colors duration-300', done ? 'bg-emerald-500/15 text-emerald-300' : 'bg-brand-500/15 text-brand-300')}>
{(() => {
const Icon = items[cur].icon;
return <Icon key={cur} className="h-3.5 w-3.5 animate-scale-in" />;
})()}
</span>
<span className="relative h-5 w-40 overflow-hidden text-sm sm:w-44">
{items.map((it, k) => (
<span
key={it.label}
aria-hidden={k !== cur}
className={cn(
'absolute inset-0 truncate transition-all duration-500 ease-smooth',
k === cur ? 'translate-y-0 opacity-100 blur-0' : k < cur ? '-translate-y-4 opacity-0 blur-[3px]' : 'translate-y-4 opacity-0 blur-[3px]',
done ? 'text-ink-100' : 'text-ink-200',
)}
>
{it.label}
</span>
))}
</span>
<span className="font-mono text-[11px] tabular-nums text-ink-500">{elapsed.toFixed(1)}s</span>
</div>
);
}
export function RotatingStatusDemo() {
return (
<div className="flex flex-col items-center gap-4">
<RotatingStatus steps={DEFAULT} />
<p className="text-xs text-ink-500">Loops in the demo. Pass your own steps and icons.</p>
</div>
);
}