Spinner
A faint ring with a comet running round it, its tail a true fade from deep to hot.
// Spinner: a faint ring with a comet running round it. The comet's tail is a real fade
// (a conic gradient along the stroke), deep at the back and hot at the head. `done` closes
// the arc into a bone ring and draws a check inside it; at 16px the ring gives way to the check.
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createLoop } from '../shared/spring';
import { useOnScreen, useReducedMotion } from '../shared/chart-kit';
import { ACCENTS, heat, resolveAccent, type Accent, type RGB } from '../shared/accent';
import { Check } from '../status-kit/Check';
import { easeOutCubic, fitCanvas } from '../status-kit/kit';
import '../shared/ember.css';
import './spinner.css';
export interface SpinnerProps {
size?: 16 | 20 | 28;
label?: string;
done?: boolean;
accent?: Accent;
className?: string;
}
const REV_MS = 900;
const CLOSE_MS = 300;
const COMET = (100 / 360) * Math.PI * 2;
const BONE: RGB = [237, 237, 237];
const TAU = Math.PI * 2;
const mixRGB = (a: RGB, b: RGB, t: number): RGB => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
const css = (c: RGB, a: number) => `rgba(${Math.round(c[0])},${Math.round(c[1])},${Math.round(c[2])},${a})`;
function normSize(s: unknown): 16 | 20 | 28 {
const n = Number(s);
return n === 16 || n === 28 ? n : 20;
}
export function Spinner({ size: sizeProp = 20, label = 'Loading', done = false, accent: accentProp, className }: SpinnerProps) {
const accent = resolveAccent(accentProp);
const size = normSize(sizeProp);
const reduced = useReducedMotion();
const rootRef = useRef<HTMLSpanElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const onScreen = useOnScreen(rootRef);
const stroke = size === 28 ? 2 : 1.5;
const small = size === 16;
// When the close started (ms), or null while spinning. The check waits for the ring to close.
const closeAt = useRef<number | null>(done ? -Infinity : null);
const [ringClosed, setRingClosed] = useState(done);
const [instant, setInstant] = useState(done);
const live = useRef({ accent, size, stroke, done, reduced });
live.current = { accent, size, stroke, done, reduced };
const draw = (now: number): boolean => {
const canvas = canvasRef.current;
if (!canvas) return false;
const { accent: a, size: s, stroke: sw, done: isDone, reduced: rm } = live.current;
const { ctx, dpr } = fitCanvas(canvas, s, s);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, s, s);
const c = s / 2;
const r = c - sw / 2 - 0.5;
ctx.lineWidth = sw;
ctx.lineCap = 'butt';
let p = 0;
// A frame's timestamp can predate the click that set closeAt by a few ms: clamp, never negative.
if (isDone && closeAt.current !== null) p = rm ? 1 : easeOutCubic(Math.max(0, (now - closeAt.current) / CLOSE_MS));
if (p >= 1) {
ctx.strokeStyle = css(BONE, 1);
ctx.beginPath();
ctx.arc(c, c, r, 0, TAU);
ctx.stroke();
return false;
}
// Track.
ctx.strokeStyle = 'rgba(255,255,255,0.12)';
ctx.beginPath();
ctx.arc(c, c, r, 0, TAU);
ctx.stroke();
const ramp = ACCENTS[a];
if (rm) {
// Reduced motion: a still quarter in the accent.
ctx.strokeStyle = css(ramp.base, 1);
ctx.beginPath();
ctx.arc(c, c, r, -Math.PI / 2, 0);
ctx.stroke();
return false;
}
const head = ((now % REV_MS) / REV_MS) * TAU - Math.PI / 2;
const span = COMET + (TAU - COMET) * p;
const tail = head - span;
const f = span / TAU;
// Stops along the stroke, tail → head: nothing → deep → accent → hot. Closing pulls all of it to bone.
const stops: [number, RGB, number][] = [
[0, ramp.deep, 0],
[0.35, ramp.deep, 0.55],
[0.72, ramp.base, 1],
[1, heat(a, 0.85), 1],
];
const g = typeof ctx.createConicGradient === 'function' ? ctx.createConicGradient(tail, c, c) : null;
if (g) {
for (const [o, col, al] of stops) g.addColorStop(o * f * 0.999, css(mixRGB(col, BONE, p), al + (1 - al) * p));
g.addColorStop(Math.min(1, f), css(mixRGB(ramp.hot, BONE, p), 1));
if (f < 0.999) g.addColorStop(f + 0.0005, 'rgba(0,0,0,0)');
ctx.strokeStyle = g;
ctx.beginPath();
ctx.arc(c, c, r, tail, head);
ctx.stroke();
} else {
// Fallback: short segments, each a little hotter than the last.
const n = 24;
for (let i = 0; i < n; i++) {
const t = (i + 0.5) / n;
let k = 0;
while (k < stops.length - 2 && t > stops[k + 1][0]) k++;
const [o0, c0, a0] = stops[k];
const [o1, c1, a1] = stops[k + 1];
const u = (t - o0) / (o1 - o0);
ctx.strokeStyle = css(mixRGB(mixRGB(c0, c1, u), BONE, p), a0 + (a1 - a0) * u + (1 - (a0 + (a1 - a0) * u)) * p);
ctx.beginPath();
ctx.arc(c, c, r, tail + (span * i) / n, tail + (span * (i + 1)) / n + 0.01);
ctx.stroke();
}
}
if (isDone && p < 1) return true;
return !isDone;
};
const loopRef = useRef<ReturnType<typeof createLoop> | null>(null);
if (!loopRef.current) {
loopRef.current = createLoop((_dt, now) => {
const more = draw(now);
const { done: isDone } = live.current;
if (isDone && closeAt.current !== null && now - closeAt.current >= CLOSE_MS) setRingClosed(true);
return more;
});
}
// Start, stop and close.
useLayoutEffect(() => {
const loop = loopRef.current!;
if (done) {
if (closeAt.current === null) {
closeAt.current = performance.now();
setInstant(reduced);
if (reduced) setRingClosed(true);
}
} else {
closeAt.current = null;
setRingClosed(false);
setInstant(false);
}
draw(performance.now());
if (!reduced && onScreen && (!done || !ringClosed)) loop.wake();
else loop.stop();
// The loop re-reads everything through `live`; it only needs restarting on these.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [done, reduced, onScreen, size, accent]);
// If the close finished while off screen, settle it now.
useEffect(() => {
if (done && !ringClosed && (!onScreen || reduced)) {
closeAt.current = -Infinity;
setRingClosed(true);
setInstant(true);
draw(performance.now());
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [done, onScreen, reduced]);
useEffect(() => () => loopRef.current?.stop(), []);
const checkSize = small ? 16 : size === 20 ? 10 : 14;
return (
<span
ref={rootRef}
className={className ? `ember esp ${className}` : 'ember esp'}
data-accent={accent}
data-size={size}
data-done={done || undefined}
role="status"
style={{ width: size, height: size }}
>
<canvas ref={canvasRef} className="esp-canvas" aria-hidden="true" data-gone={(small && done) || undefined} />
<Check
className="esp-check"
drawn={done && (small || ringClosed)}
instant={instant}
size={checkSize}
stroke={size === 28 ? 2 : 1.5}
/>
<span className="em-sr">{done ? 'Done' : label}</span>
</span>
);
}
export default Spinner;
Customize
sizes
inline
Done closes the arc into a bone ring and draws a check inside; at 16px the ring gives way to the check.
Install
npx shadcn@latest add https://obsydeui.com/r/spinner.jsonUsage
Import it from where the CLI puts it, or copy the files below by hand.
import { Spinner } from '@/components/obsyde/ember/spinner/Spinner';
export function Example() {
return (
<Spinner label="Loading" />
);
}
Files
Plain React, TypeScript and CSS. No dependencies. The download keeps these paths.
ember/spinner/Spinner.tsxcomponentember/spinner/spinner-demo.cssstylesember/spinner/spinner.cssstylesember/spinner/SpinnerDemo.tsxdemoember/status-kit/check.csssharedember/status-kit/Check.tsxsharedember/status-kit/kit.tssharedember/shared/chart-kit.tssharedember/shared/spring.tssharedember/shared/accent.tssharedember/shared/ember.cssshared
API
| Prop | Type | Default |
|---|---|---|
accentAccent | 'ember' | 'volt' | 'arc' | 'ember' |
layoutLayout | 'sizes' | 'inline' | 'sizes' |
doneDone | boolean | false |
labelLabel | string | 'Loading' |