Terminal Spinners New
Unicode spinners in the style of command-line coding agents, with labels, elapsed time and done ticks.
Installation
01
Install with the shadcn CLI. The NiceUI theme comes along the first time.
$ npx shadcn@latest add https://niceui.dev/r/ai-braille-spinner.json02
Or copy the source below (2 files). Dependencies: lucide-react.
Source
components/niceui/ai/thinking/BrailleSpinner.tsx
import { useState } from 'react';
import { Pills, TONES, usePrefersReducedMotion, useTick, type ThinkingState, type Tone } from './shared';
export const SPINNERS = {
braille: { frames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'], fps: 12 },
dots: { frames: ['⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷'], fps: 10 },
line: { frames: ['-', '\\', '|', '/'], fps: 8 },
arc: { frames: ['◜', '◠', '◝', '◞', '◡', '◟'], fps: 9 },
star: { frames: ['✶', '✸', '✹', '✺', '✹', '✷'], fps: 8 },
} as const;
export type SpinnerKind = keyof typeof SPINNERS;
export type BrailleSpinnerProps = {
kind?: SpinnerKind;
/** Font size in px. */
size?: number;
tone?: Tone;
state?: ThinkingState;
label?: string;
/** Show elapsed seconds after the label. */
elapsed?: boolean;
className?: string;
};
/** Unicode spinners in the style of command-line coding agents. */
export function BrailleSpinner({ kind = 'braille', size = 14, tone = 'sky', state = 'thinking', label, elapsed = false, className }: BrailleSpinnerProps) {
const reduce = usePrefersReducedMotion();
const spec = SPINNERS[kind];
const running = state === 'thinking' && !reduce;
const n = useTick(state === 'thinking', spec.fps);
const c = TONES[tone];
const glyph = state === 'done' ? '✓' : state === 'idle' ? '·' : running ? spec.frames[n % spec.frames.length] : spec.frames[0];
return (
<span role="status" aria-label={label ?? (state === 'thinking' ? 'Working' : state)} className={className} style={{ display: 'inline-flex', alignItems: 'baseline', gap: '0.6em', fontFamily: 'var(--font-mono, "Geist Mono", ui-monospace, monospace)', fontSize: size, lineHeight: 1.2 }}>
<span aria-hidden style={{ color: state === 'idle' ? '#5a5f67' : c.soft, width: '1ch', display: 'inline-block', textAlign: 'center', textShadow: state === 'thinking' ? `0 0 8px ${c.main}88` : 'none' }}>
{glyph}
</span>
{label && (
<span style={{ color: state === 'done' ? '#a3a8b0' : '#e5e7eb' }}>
{label}
{elapsed && state === 'thinking' && <span style={{ color: '#6b7079' }}> ({(n / spec.fps).toFixed(0)}s · esc to interrupt)</span>}
</span>
)}
</span>
);
}
const KINDS = Object.keys(SPINNERS) as SpinnerKind[];
export function BrailleSpinnerDemo() {
const [kind, setKind] = useState<SpinnerKind>('braille');
return (
<div className="mx-auto flex w-full max-w-xl flex-col gap-5">
<div className="flex justify-center">
<Pills label="Spinner" value={kind} options={KINDS} onChange={setKind} />
</div>
<div className="flex flex-wrap items-center justify-center gap-x-10 gap-y-5 rounded-2xl bg-ink-900/80 px-6 py-7 shadow-card">
{KINDS.map((k, i) => (
<div key={k} className="flex flex-col items-center gap-2">
<BrailleSpinner kind={k} size={k === kind ? 28 : 20} tone={(['sky', 'violet', 'emerald', 'amber', 'white'] as Tone[])[i]} />
<span className="font-mono text-[10px] text-ink-500">{k}</span>
</div>
))}
</div>
<div className="overflow-hidden rounded-2xl bg-[#07080a] shadow-card ring-1 ring-white/5">
<div className="flex items-center gap-1.5 border-b border-white/5 px-3 py-2">
{[0, 1, 2].map((i) => <span key={i} className="h-2.5 w-2.5 rounded-full bg-white/10" />)}
<span className="ml-2 font-mono text-[10px] text-ink-500">agent — zsh</span>
</div>
<div className="space-y-2 p-4 font-mono text-[12.5px]">
<p className="text-ink-500">› add rate limiting to /api/upload</p>
<div><BrailleSpinner kind={kind} size={12.5} state="done" label="Read src/api/upload.ts" /></div>
<div><BrailleSpinner kind={kind} size={12.5} state="done" label="Searched for “limiter”" /></div>
<div><BrailleSpinner kind={kind} size={12.5} label="Editing middleware…" elapsed /></div>
<div><BrailleSpinner kind={kind} size={12.5} state="idle" label="Run tests" /></div>
</div>
</div>
</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>;
}