sans
uiclaude-spinnersource: ok · hit
Claude Spinner
A reverse-engineered recreation of the Claude Code CLI's terminal spinner — animated glyph, status verb, elapsed time, and token counter.
npx shadcn add "https://sans.sansorigin.com/r/claude-spinner.json"free
Where it goes
stats
Visual style
terminal-mono
Interaction
animated-counter
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Files1 · source cached 10/14/2026, 10:47:26 PM
registry/components/claude-spinner/claude-spinner.tsx
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
// Modeled after the Claude Code CLI binary (@anthropic-ai/claude-code).
// Glyph set for non-Ghostty terminals — Ghostty gets "✳" instead of "*".
const GLYPHS = ["·", "✢", "*", "✶", "✻", "✽"]
const CYCLE_MS = 2000 // one full ease-in/ease-out sweep across the glyph set
const TICK_MS = 100 // real CLI ticks state at 100ms (50ms while "requesting")
function ease(phase: number) {
return (1 - Math.cos(2 * Math.PI * phase)) / 2
}
function formatElapsed(totalSeconds: number) {
const minutes = Math.floor(totalSeconds / 60)
const seconds = totalSeconds % 60
return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`
}
function formatTokenCount(count: number) {
return count >= 1000 ? `${(count / 1000).toFixed(1)}k` : `${count}`
}
function usePrefersReducedMotion() {
const [reduced, setReduced] = React.useState(
() =>
typeof window !== "undefined" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
)
React.useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)")
const handler = () => setReduced(mq.matches)
mq.addEventListener("change", handler)
return () => mq.removeEventListener("change", handler)
}, [])
return reduced
}
export type ClaudeSpinnerProps = Omit<
React.ComponentProps<"div">,
"children" | "color"
> & {
/**
* Status text shown next to the glyph, e.g. "Reading files".
* @defaultValue "Thinking" */
label?: string
/**
* Glyph and label color. Any CSS color, e.g. "#d97757" or "var(--primary)".
* @defaultValue "#d97757" */
color?: string
/**
* Color of the elapsed/tokens/sub-status text.
* @defaultValue "var(--muted-foreground)" */
mutedColor?: string
/**
* Hides the elapsed-time counter.
* @defaultValue false */
hideElapsed?: boolean
/** Shows a "↓ Nk tokens" counter when provided. */
tokens?: number
/** Shows extra status text when provided, e.g. "still thinking". */
subStatus?: string
/** Forces reduced-motion behavior regardless of the OS setting. */
reducedMotion?: boolean
}
export function ClaudeSpinner({
label = "Thinking",
color,
mutedColor,
hideElapsed = false,
tokens,
subStatus,
reducedMotion,
className,
style,
...props
}: ClaudeSpinnerProps) {
const osReducedMotion = usePrefersReducedMotion()
const isReducedMotion = reducedMotion ?? osReducedMotion
const [elapsedMs, setElapsedMs] = React.useState(0)
const elapsedSec = Math.floor(elapsedMs / 1000)
React.useEffect(() => {
const start = Date.now()
const timer = setInterval(() => {
setElapsedMs(Date.now() - start)
}, TICK_MS)
return () => clearInterval(timer)
}, [])
const phase = (elapsedMs % CYCLE_MS) / CYCLE_MS
const glyph = isReducedMotion
? "●"
: GLYPHS[Math.round(ease(phase) * (GLYPHS.length - 1))]
const metaParts: string[] = []
if (!hideElapsed) metaParts.push(formatElapsed(elapsedSec))
if (tokens !== undefined) {
metaParts.push(`↓ ${formatTokenCount(tokens)} tokens`)
}
if (subStatus) metaParts.push(subStatus)
return (
<div
role="status"
aria-live="polite"
className={cn(
"inline-flex max-w-full items-start gap-2 font-mono text-sm tabular-nums",
"[--color:#d97757] [--muted-color:var(--muted-foreground)]",
className
)}
style={
{
...(color && { "--color": color }),
...(mutedColor && { "--muted-color": mutedColor }),
...style,
} as React.CSSProperties
}
{...props}
>
<span
className={cn(
"w-[1em] shrink-0 text-center font-semibold text-(--color)",
isReducedMotion && "animate-pulse"
)}
>
{glyph}
</span>
{/* Label and meta flow as one line of text so narrow screens wrap
naturally, keeping each meta part (e.g. "↓ 1.2k tokens") intact. */}
<span className="min-w-0 wrap-break-word">
<span className="font-medium text-(--color)">{label}…</span>
{metaParts.length > 0 && (
<span className="text-(--muted-color)">
{" "}
{metaParts.map((part, i) => (
<React.Fragment key={i}>
{i > 0 && " · "}
<span className="whitespace-nowrap">
{i === 0 && "("}
{part}
{i === metaParts.length - 1 && ")"}
</span>
</React.Fragment>
))}
</span>
)}
</span>
</div>
)
}
export default ClaudeSpinner