A loader that winds thread onto a spool while duration is unknown, then converts the same coil in place into a proportional gauge the instant total size arrives — the indeterminate-to-determinate handoff as one continuous object, never a spinner swapped for a bar.
npx shadcn add https://design.helpmarq.com /r/loader-thread-spool.jsonregistry/core/loader-thread-spool/component.tsx"use client";
import { useEffect, useRef, useState } from "react";
// ---------------------------------------------------------------------------
// WindSpool — an indeterminate/determinate loader that never lies about
// elapsed time. While `total` is unknown, thread visibly winds onto a spool
// at a steady real-time cadence (one layer roughly every 700ms, radius
// growing logarithmically so a long wait stays compact instead of looping a
// spinner that erases how long you've waited). The instant `total` arrives,
// the SAME coil converts in place: a dashed ghost ring pops to the radius
// that represents 100%, and the wound thread re-scopes to loaded/total of
// that ring — one continuous object across the indeterminate -> determinate
// handoff, never a spinner swapped for a bar. Pure SVG + CSS, one rAF loop,
// direct-DOM attribute writes on pre-rendered nodes; React state only
// changes on discrete transitions (phase flips, rounded aria fields),
// never per frame.
// ---------------------------------------------------------------------------
const RING_MS = 700; // real ms per indeterminate thread-layer tick
const CORE_R = 15; // hub radius (inside the two flanges)
const MAX_R = 84; // visual radius standing in for "100% of total"
const PITCH = 3.4; // px between wound layers
const LOG_A = 11.5; // radius(n) = CORE_R + LOG_A * ln(1 + n) while indeterminate
const N_LAYERS = Math.ceil((MAX_R - CORE_R) / PITCH) + 2;
const CX = 108;
const CY = 92;
const ANCHOR_X = 196;
const ANCHOR_Y = 20;
const FEED_ANGLE = (-52 * Math.PI) / 180; // fixed feed direction (rad)
const WOBBLE_RAD = (4.5 * Math.PI) / 180;
const WOBBLE_MS = 2600;
// spring constants (critically-damped-ish, house style: k = stiffness s^-2,
// c = 2*zeta*sqrt(k))
const RADIUS_K = 90;
const RADIUS_C = 2 * 0.62 * Math.sqrt(RADIUS_K); // small overshoot per layer
const GHOST_K = 130;
const GHOST_C = 2 * 0.68 * Math.sqrt(GHOST_K);
const SNIP_K = 150;
const SNIP_C = 2 * 0.55 * Math.sqrt(SNIP_K); // a little recoil past the snip
function springStep(
pos: number,
vel: number,
target: number,
k: number,
c: number,
dt: number
): [number, number] {
const acc = -k * (pos - target) - c * vel;
const nv = vel + acc * dt;
return [pos + nv * dt, nv];
}
function formatBytes(n: number): string {
if (!Number.isFinite(n) || n <= 0) return "0 B";
if (n < 1024) return `${Math.round(n)} B`;
const units = ["KB", "MB", "GB", "TB"];
let v = n / 1024;
let u = 0;
while (v >= 1024 && u < units.length - 1) {
v /= 1024;
u++;
}
return `${v >= 10 ? Math.round(v) : Math.round(v * 10) / 10} ${units[u]}`;
}
function formatElapsed(ms: number): string {
const s = Math.max(0, Math.round(ms / 1000));
if (s < 60) return `${s} second${s === 1 ? "" : "s"}`;
const m = Math.floor(s / 60);
const r = s % 60;
return `${m}m ${r}s`;
}
export type WindSpoolProps = {
/** total size in bytes once known; leave undefined while indeterminate */
total?: number;
/** bytes accumulated so far; only meaningful once `total` is known */
loaded?: number;
className?: string;
"aria-label"?: string;
};
export function WindSpool({
total,
loaded = 0,
className = "",
"aria-label": ariaLabel = "Loading",
}: WindSpoolProps) {
const layersRef = useRef<(SVGCircleElement | null)[]>([]);
const ghostRef = useRef<SVGCircleElement>(null);
const feedRef = useRef<SVGLineElement>(null);
const captionRef = useRef<HTMLSpanElement>(null);
const totalRef = useRef(total);
const loadedRef = useRef(loaded);
const wakeRef = useRef<(() => void) | null>(null);
const initialKnown = total != null && total > 0;
const initialPct = initialKnown
? Math.round(Math.min(100, Math.max(0, (loaded / (total as number)) * 100)))
: 0;
const [phase, setPhase] = useState<"indeterminate" | "determinate">(
initialKnown ? "determinate" : "indeterminate"
);
const [valueNow, setValueNow] = useState<number | undefined>(
initialKnown ? initialPct : undefined
);
const [valueText, setValueText] = useState(
initialKnown
? `Loading, ${initialPct} percent of ${formatBytes(total as number)}`
: "Loading"
);
const [announce, setAnnounce] = useState("");
const [captionText, setCaptionText] = useState(
initialKnown ? `${initialPct}% of ${formatBytes(total as number)}` : "winding…"
);
useEffect(() => {
totalRef.current = total;
loadedRef.current = loaded;
wakeRef.current?.();
}, [total, loaded]);
useEffect(() => {
const ghost = ghostRef.current;
const feed = feedRef.current;
if (!ghost || !feed) return;
const reduced = window.matchMedia(
"(prefers-reduced-motion: reduce)"
).matches;
let raf = 0;
let last = 0;
let indeterminateStart = performance.now();
const knownAtMount = totalRef.current != null && totalRef.current > 0;
const initialFraction = knownAtMount
? Math.min(1, Math.max(0, loadedRef.current / (totalRef.current as number)))
: 0;
let ringCount = 0;
let ringAcc = 0; // ms accumulated toward the next 700ms tick
let radius = knownAtMount ? CORE_R + (MAX_R - CORE_R) * initialFraction : CORE_R;
let radiusVel = 0;
let growTarget = radius;
let known = knownAtMount;
let ghostR = knownAtMount ? MAX_R : 0;
let ghostVel = 0;
let snipT = 0; // 0 = feed fully extended, 1 = fully retracted
let snipVel = 0;
let snipping = false;
let settled = false;
let lastAnnouncedSec = -10;
const paintLayers = () => {
const nVisible = Math.max(0, Math.floor((radius - CORE_R) / PITCH));
for (let i = 0; i < N_LAYERS; i++) {
const el = layersRef.current[i];
if (!el) continue;
if (i < nVisible) {
el.setAttribute("r", String(CORE_R + PITCH * (i + 1)));
el.style.opacity = "1";
} else {
el.style.opacity = "0";
}
}
};
const paintGhost = () => {
ghost.setAttribute("r", String(Math.max(0.001, ghostR)));
ghost.style.opacity = ghostR > 0.5 ? String(Math.min(1, ghostR / MAX_R)) : "0";
};
const paintFeed = (now: number) => {
if (settled) {
feed.style.opacity = "0";
return;
}
const wobble = reduced
? 0
: Math.sin(((now - indeterminateStart) / WOBBLE_MS) * Math.PI * 2) *
WOBBLE_RAD;
const angle = FEED_ANGLE + (snipping ? 0 : wobble);
const t = Math.min(1.15, snipT);
// attach point rides the current coil edge, then eases toward the
// anchor as snipT climbs, so the line visibly shortens into the recoil
const attachR = radius + 3;
const ax = CX + Math.cos(angle) * attachR;
const ay = CY + Math.sin(angle) * attachR;
const x2 = ax + (ANCHOR_X - ax) * t;
const y2 = ay + (ANCHOR_Y - ay) * t;
feed.setAttribute("x1", String(ANCHOR_X));
feed.setAttribute("y1", String(ANCHOR_Y));
feed.setAttribute("x2", String(x2));
feed.setAttribute("y2", String(y2));
// stays visible through the overshoot; the `settled` guard above is
// what finally hides it, once the recoil has actually resettled
feed.style.opacity = "1";
};
const wake = () => {
if (!raf) raf = requestAnimationFrame(loop);
};
wakeRef.current = wake;
const loop = (now: number) => {
const dt = last === 0 ? 1 / 60 : Math.min(0.05, (now - last) / 1000);
last = now;
const t = totalRef.current;
const ld = loadedRef.current;
const knownNow = t != null && t > 0;
if (knownNow !== known) {
known = knownNow;
if (knownNow) {
setPhase("determinate");
const pct = Math.round(Math.min(100, Math.max(0, (ld / (t as number)) * 100)));
setAnnounce(`Loading, ${pct} percent of ${formatBytes(t as number)}`);
} else {
// a fresh indeterminate run started — bank nothing, restart clean
ringCount = 0;
ringAcc = 0;
radius = CORE_R;
radiusVel = 0;
growTarget = CORE_R;
indeterminateStart = now;
lastAnnouncedSec = -10;
setPhase("indeterminate");
setValueNow(undefined);
setValueText("Loading");
}
settled = false;
snipping = false;
snipT = 0;
snipVel = 0;
}
if (!knownNow) {
ringAcc += dt * 1000;
while (ringAcc >= RING_MS) {
ringAcc -= RING_MS;
ringCount += 1;
growTarget = CORE_R + LOG_A * Math.log1p(ringCount);
if (reduced) radius = growTarget;
}
if (!reduced) {
[radius, radiusVel] = springStep(
radius,
radiusVel,
growTarget,
RADIUS_K,
RADIUS_C,
dt
);
}
const elapsedSec = (now - indeterminateStart) / 1000;
if (elapsedSec - lastAnnouncedSec >= 10) {
lastAnnouncedSec = elapsedSec;
setValueText(`Loading, ${formatElapsed(elapsedSec * 1000)} elapsed`);
}
if (reduced) {
ghostR = 0;
ghostVel = 0;
} else {
[ghostR, ghostVel] = springStep(ghostR, ghostVel, 0, GHOST_K, GHOST_C, dt);
}
setCaptionText(`winding — ${formatElapsed(elapsedSec * 1000)}`);
} else {
const total_ = t as number;
const fraction = Math.min(1, Math.max(0, ld / total_));
growTarget = CORE_R + (MAX_R - CORE_R) * fraction;
if (reduced) {
radius = growTarget;
} else {
[radius, radiusVel] = springStep(
radius,
radiusVel,
growTarget,
RADIUS_K,
RADIUS_C,
dt
);
}
if (reduced) {
ghostR = MAX_R;
} else {
[ghostR, ghostVel] = springStep(ghostR, ghostVel, MAX_R, GHOST_K, GHOST_C, dt);
}
const pct = Math.round(fraction * 100);
setValueNow(pct);
setValueText(`Loading, ${pct} percent of ${formatBytes(total_)}`);
setCaptionText(`${pct}% of ${formatBytes(total_)}`);
if (fraction >= 1 && !snipping) {
snipping = true;
}
if (snipping) {
if (reduced) {
snipT = 1;
} else {
[snipT, snipVel] = springStep(snipT, snipVel, 1, SNIP_K, SNIP_C, dt);
}
}
}
paintLayers();
paintGhost();
paintFeed(now);
const radiusDone = Math.abs(radius - growTarget) < 0.05 && Math.abs(radiusVel) < 0.05;
const ghostDone =
(!knownNow && ghostR < 0.05) || (knownNow && Math.abs(ghostR - MAX_R) < 0.05);
const snipDone =
snipping && (reduced || (Math.abs(snipT - 1) < 0.01 && Math.abs(snipVel) < 0.02));
if (knownNow && radiusDone && ghostDone && snipping && snipDone) {
settled = true;
feed.style.opacity = "0";
}
raf = settled && knownNow ? 0 : requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => {
cancelAnimationFrame(raf);
wakeRef.current = null;
};
// mount-once engine; total/loaded are read live via refs each frame
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div
role="progressbar"
aria-label={ariaLabel}
aria-valuemin={phase === "determinate" ? 0 : undefined}
aria-valuemax={phase === "determinate" ? 100 : undefined}
aria-valuenow={phase === "determinate" ? valueNow : undefined}
aria-valuetext={valueText}
className={`inline-flex flex-col items-center gap-2 ${className}`}
>
<svg
viewBox="0 0 216 184"
width={216}
height={184}
aria-hidden="true"
className="max-w-full"
>
{/* feed line: taut thread entering from a fixed anchor */}
<line
ref={feedRef}
x1={ANCHOR_X}
y1={ANCHOR_Y}
x2={ANCHOR_X}
y2={ANCHOR_Y}
stroke="var(--foreground)"
strokeWidth={1}
style={{ transition: "opacity 260ms ease-out" }}
/>
{/* ghost ring: the final wound radius, revealed the moment total is known */}
<circle
ref={ghostRef}
cx={CX}
cy={CY}
r={0.001}
fill="none"
stroke="var(--muted)"
strokeWidth={1}
strokeDasharray="3 3"
/>
{/* wound thread: concentric layers, drawn back-to-front */}
<g>
{Array.from({ length: N_LAYERS }, (_, i) => (
<circle
key={i}
ref={(el) => {
layersRef.current[i] = el;
}}
cx={CX}
cy={CY}
r={CORE_R}
fill="none"
stroke="var(--foreground)"
strokeWidth={1}
style={{ opacity: 0 }}
/>
))}
</g>
{/* spool hub: two flanges */}
<circle cx={CX} cy={CY} r={CORE_R} fill="none" stroke="var(--border)" strokeWidth={1} />
<circle cx={CX} cy={CY} r={CORE_R - 5} fill="none" stroke="var(--border)" strokeWidth={1} />
</svg>
<span
ref={captionRef}
aria-hidden="true"
data-wind-caption
data-phase={phase}
className="font-mono text-[11px] tracking-wide text-muted"
>
{captionText}
</span>
<div aria-live="polite" className="sr-only">
{announce}
</div>
</div>
);
}
A progress indicator built around one idea: a fetch's wait is real elapsed time, and erasing that time every spinner revolution is a small dishonesty this component refuses. Two controlled props drive it — `total` (bytes, undefined while headers are still pending) and `loaded` (bytes so far, meaningful only once `total` is known); the component owns no other state. While `total` is undefined, an internal clock (reset whenever `total` goes back to undefined, i.e. a new job started) advances a ring counter on a steady real 700ms cadence — never a fixed-duration CSS loop — and each tick recomputes a target radius as core-radius plus a logarithmic term in the tick count, so a coil that has wound for two minutes is only modestly bigger than one that wound for twenty seconds: long waits stay visually compact instead of the radius (or a naive linear proxy) blowing past the frame. The target radius is chased by a lightly underdamped spring each frame (never a per-ring CSS keyframe), so every newly exposed thread layer settles with one small wobble rather than snapping. A taut 1px feed line runs from a fixed off-spool anchor to the coil's current edge at a fixed feed angle, its attach point oscillating a few degrees on a slow sine so the line reads as live tension rather than a static prop; a fixed pair of 1px hub circles (the spool's two flanges) anchor the whole thing at center and never move. The moment `total` becomes a positive number, nothing resets: the same spring-chased radius immediately retargets from log(ticks) to core-radius plus (max-radius − core-radius) × loaded/total, so the existing coil mass simply re-scopes to a fraction of a fixed ceiling instead of vanishing and reappearing as a bar — and a dashed 1px ghost ring pops (its own spring, radius 0 to the max-radius ceiling) marking where the coil will finish, so a viewer immediately sees both 'how far' and 'how far there is to go' as concentric facts, not a color or a second widget. From there the mechanism runs as a plain proportional gauge: the wound radius chases loaded/total on every prop update, no more clock, no more log curve, exactly a progress bar wearing a spool's geometry. Reaching loaded >= total snips the feed line — the attach point eases toward the anchor with a small spring recoil (a slight overshoot past fully retracted before it settles) rather than a hard cut, then the line and oscillation both disappear and the component goes idle (its render loop stops scheduling frames once the radius, ghost ring and snip have all actually settled, waking again only if props change). Every layer, the ghost ring and the feed line are drawn with `stroke="var(--foreground|--muted|--border)"` directly in SVG presentation attributes — no canvas, no hex, no rgb()/hsl() — so both themes render correctly with zero extra logic. A visible font-mono caption under the coil mirrors the same numbers a sighted user would want ('winding — 24 seconds', '40% of 12 MB') but is `aria-hidden`, because the real accessibility contract lives on the wrapping `role="progressbar"`: no `aria-valuenow`/`aria-valuemax` while indeterminate, only an `aria-valuetext` that updates roughly every 10 real seconds of elapsed wait ('Loading, 24 seconds elapsed') rather than every frame; the instant `total` is known, real `aria-valuemin=0`/`aria-valuemax=100`/`aria-valuenow` appear alongside an `aria-valuetext` like 'Loading, 40 percent of 12 MB', and a separate `aria-live="polite"` region fires exactly one announcement at that transition so a screen reader hears the handoff happen without being spammed on every subsequent byte. The component renders no button, input or any focusable control — it is pure status, never focusable, with no keyboard surface — so the tab-reachability check correctly skips it as display-only; any interactive control seen in a demo belongs to the demo, not to WindSpool. `prefers-reduced-motion` removes the spring wobble (new radii, the ghost pop and the snip recoil all resolve to their target immediately, in one discrete step, rather than easing) and removes the feed-line oscillation entirely, while every state — indeterminate, determinate, complete — stays fully legible and the underlying cadence-driven radius growth is unaffected, since that growth is the actual information, not decoration. Differs from voice-recorder-meter: voice-recorder-meter visualizes a live external signal (microphone amplitude) that has no notion of accumulated duration and nothing to complete toward; loader-thread-spool has no live signal at all, only elapsed time and, once known, a byte fraction — it measures work done, never a level. Zero dependencies.