Thinking Rings New
Ring spinners for tool calls and searches: gradient arc, orbiting dots, pulsing halo and a radar sweep, with a done check.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/ai-thinking-rings.json02
Or copy the source below (2 files). Dependencies: lucide-react.
Source
components/niceui/ai/thinking/ThinkingRings.tsx
import { useState, type CSSProperties } from 'react';
import { Check } from 'lucide-react';
import { Pills, Showcase, Size, ThinkingLabel, TONES, type ThinkingState, type Tone } from './shared';
export type RingVariant = 'arc' | 'orbit' | 'halo' | 'sweep';
export type ThinkingRingProps = {
size?: number;
variant?: RingVariant;
tone?: Tone;
state?: ThinkingState;
className?: string;
};
const KEYFRAMES = `
@keyframes nui-ring-spin { to { transform: rotate(360deg) } }
@keyframes nui-ring-halo { 0% { transform: scale(.55); opacity: .9 } 100% { transform: scale(1.15); opacity: 0 } }
@media (prefers-reduced-motion: reduce) { .nui-ring * { animation: none !important } }
`;
export function ThinkingRing({ size = 20, variant = 'arc', tone = 'sky', state = 'thinking', className }: ThinkingRingProps) {
const c = TONES[tone];
const stroke = Math.max(1.5, size / 10);
const box: CSSProperties = { position: 'relative', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', width: size, height: size, flexShrink: 0 };
const label = state === 'thinking' ? 'Thinking' : state === 'done' ? 'Done' : 'Idle';
if (state === 'done') {
return (
<span role="img" aria-label={label} className={className} style={{ ...box, borderRadius: '50%', background: `${c.main}22`, boxShadow: `inset 0 0 0 ${stroke}px ${c.main}` }}>
<Check style={{ width: size * 0.55, height: size * 0.55, color: c.soft }} strokeWidth={3} />
</span>
);
}
const play = state === 'idle' ? 'paused' : 'running';
const track = <span style={{ position: 'absolute', inset: 0, borderRadius: '50%', boxShadow: `inset 0 0 0 ${stroke}px rgba(255,255,255,.08)` }} />;
let inner;
if (variant === 'arc') {
const mask = `radial-gradient(farthest-side, transparent calc(100% - ${stroke}px), #000 calc(100% - ${stroke}px + .5px))`;
inner = (
<>
{track}
<span style={{ position: 'absolute', inset: 0, borderRadius: '50%', background: `conic-gradient(from 0deg, transparent 0 25%, ${c.deep} 55%, ${c.soft})`, WebkitMaskImage: mask, maskImage: mask, animation: 'nui-ring-spin .9s linear infinite', animationPlayState: play }} />
</>
);
} else if (variant === 'orbit') {
const dot = Math.max(2.5, size / 6);
inner = (
<>
{track}
{[0, 1, 2].map((i) => (
<span key={i} style={{ position: 'absolute', inset: 0, animation: `nui-ring-spin ${1.2 + i * 0.35}s cubic-bezier(.5,.1,.5,.9) infinite`, animationDelay: `${-i * 0.18}s`, animationPlayState: play }}>
<span style={{ position: 'absolute', left: '50%', top: 0, width: dot, height: dot, marginLeft: -dot / 2, borderRadius: '50%', background: i ? c.main : c.soft, opacity: 1 - i * 0.28, boxShadow: `0 0 ${dot * 1.5}px ${c.main}` }} />
</span>
))}
</>
);
} else if (variant === 'halo') {
inner = (
<>
{[0, 1].map((i) => (
<span key={i} style={{ position: 'absolute', inset: 0, borderRadius: '50%', boxShadow: `inset 0 0 0 ${stroke}px ${c.main}`, animation: 'nui-ring-halo 1.6s ease-out infinite', animationDelay: `${-i * 0.8}s`, animationPlayState: play }} />
))}
<span style={{ width: size * 0.36, height: size * 0.36, borderRadius: '50%', background: c.soft, boxShadow: `0 0 ${size * 0.4}px ${c.main}` }} />
</>
);
} else {
inner = (
<>
<span style={{ position: 'absolute', inset: 0, borderRadius: '50%', background: `${c.main}14`, boxShadow: `inset 0 0 0 1px ${c.main}44` }} />
<span style={{ position: 'absolute', inset: 0, borderRadius: '50%', background: `conic-gradient(from 0deg, transparent 0 70%, ${c.main}cc 96%, ${c.soft})`, animation: 'nui-ring-spin 1.4s linear infinite', animationPlayState: play }} />
<span style={{ width: Math.max(2, size / 8), height: Math.max(2, size / 8), borderRadius: '50%', background: c.soft, position: 'relative' }} />
</>
);
}
return (
<span role="img" aria-label={label} className={['nui-ring', className].filter(Boolean).join(' ')} style={{ ...box, opacity: state === 'idle' ? 0.5 : 1 }}>
<style>{KEYFRAMES}</style>
{inner}
</span>
);
}
const VARIANTS = ['arc', 'orbit', 'halo', 'sweep'] as const;
const STATES = ['idle', 'thinking', 'done'] as const;
export function ThinkingRingsDemo() {
const [variant, setVariant] = useState<RingVariant>('arc');
const [state, setState] = useState<ThinkingState>('thinking');
return (
<Showcase
controls={
<>
<Pills label="Variant" value={variant} options={VARIANTS} onChange={setVariant} />
<Pills label="State" value={state} options={STATES} onChange={setState} />
</>
}
sizes={
<>
<Size px={14}><ThinkingRing size={14} variant={variant} state={state} /></Size>
<Size px={24}><ThinkingRing size={24} variant={variant} state={state} tone="violet" /></Size>
<Size px={40}><ThinkingRing size={40} variant={variant} state={state} /></Size>
<Size px={60}><ThinkingRing size={60} variant={variant} state={state} tone="amber" /></Size>
</>
}
chat={
<div className="flex items-center gap-2.5">
<ThinkingRing size={16} variant={variant} state={state} />
{state === 'done' ? <span className="text-sm text-ink-300">Searched 12 sources</span> : <ThinkingLabel>Searching the web…</ThinkingLabel>}
</div>
}
/>
);
}components/niceui/ai/thinking/shared.tsx
import { useEffect, useState, type ReactNode } from 'react';
import { Sparkles } from 'lucide-react';
import { cn } from '@/lib/cn';
export type ThinkingState = 'idle' | 'thinking' | 'done';
export const TONES = {
sky: { main: '#38bdf8', soft: '#7dd3fc', deep: '#0284c7' },
violet: { main: '#a78bfa', soft: '#c4b5fd', deep: '#7c3aed' },
emerald: { main: '#34d399', soft: '#6ee7b7', deep: '#059669' },
amber: { main: '#fbbf24', soft: '#fcd34d', deep: '#d97706' },
white: { main: '#e5e7eb', soft: '#f9fafb', deep: '#9ca3af' },
} as const;
export type Tone = keyof typeof TONES;
export function usePrefersReducedMotion() {
const [reduce, setReduce] = useState(false);
useEffect(() => {
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
setReduce(mq.matches);
const on = () => setReduce(mq.matches);
mq.addEventListener('change', on);
return () => mq.removeEventListener('change', on);
}, []);
return reduce;
}
/** Ticks a frame counter at `fps` while active. */
export function useTick(active: boolean, fps = 12) {
const [n, setN] = useState(0);
useEffect(() => {
if (!active) return;
const id = window.setInterval(() => setN((v) => v + 1), 1000 / fps);
return () => window.clearInterval(id);
}, [active, fps]);
return n;
}
export function Pills<T extends string>({ value, options, onChange, label }: { value: T; options: readonly T[]; onChange: (v: T) => void; label: string }) {
return (
<div role="radiogroup" aria-label={label} className="flex flex-wrap gap-1 rounded-xl bg-white/[0.03] p-1 ring-1 ring-white/5">
{options.map((o) => (
<button
key={o}
role="radio"
aria-checked={value === o}
onClick={() => onChange(o)}
className={cn(
'focus-ring rounded-lg px-2.5 py-1 text-xs capitalize transition-colors',
value === o ? 'bg-white/10 text-ink-50 shadow-hairline' : 'text-ink-400 hover:text-ink-200',
)}
>
{o}
</button>
))}
</div>
);
}
/** Standard showcase: variant pills, a size ladder and an inline chat row. */
export function Showcase({ controls, sizes, chat, footer }: { controls: ReactNode; sizes: ReactNode; chat: ReactNode; footer?: ReactNode }) {
return (
<div className="mx-auto flex w-full max-w-xl flex-col gap-5">
<div className="flex flex-wrap items-center justify-center gap-2">{controls}</div>
<div className="flex min-h-[112px] flex-wrap items-center justify-center gap-x-10 gap-y-6 rounded-2xl bg-ink-900/80 px-6 py-7 shadow-card">{sizes}</div>
<div className="flex items-start gap-3 rounded-2xl bg-ink-900 p-4 shadow-card">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-brand-500/15 text-brand-300">
<Sparkles className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1 pt-1.5">{chat}</div>
</div>
{footer}
</div>
);
}
export function Size({ px, children }: { px: number; children: ReactNode }) {
return (
<div className="flex flex-col items-center gap-2.5">
<div className="flex h-16 items-center justify-center">{children}</div>
<span className="font-mono text-[10px] tabular-nums text-ink-500">{px}px</span>
</div>
);
}
export function ThinkingLabel({ children = 'Thinking…' }: { children?: ReactNode }) {
return <span className="shimmer-text text-sm font-medium">{children}</span>;
}