Loaders
ThreadSpool
A small winding bench. Thread leaves a cone, runs through an eyelet riding up and down a rod, and lays real layers on a wooden bobbin. When full it pays back and the next colour starts.
Download
- React
- Tailwind CSS v4
- motion
components/ui/thread-spool.tsx
"use client";
import { useEffect, useId, useState } from "react";
import { useReducedMotion } from "motion/react";
type ThreadSpoolProps = {
/** Thread colours, one per wind. */
colors?: string[];
/** Layers of thread laid before it pays back. */
layers?: number;
/** 1 is the default pace; 2 is twice as fast. */
speed?: number;
/** Read out by screen readers. */
label?: string;
className?: string;
};
const clamp = (v: number) => Math.min(1, Math.max(0, v));
const span = (p: number, a: number, b: number) => clamp((p - a) / (b - a));
const ease = (t: number) => t * t * (3 - 2 * t);
const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
const CX = 92; // Centre of the bobbin.
const TOP = 38; // Barrel top and bottom.
const BOTTOM = 178;
const BARREL = 18;
const STEP = 36; // Total thread depth on one side.
const GUIDE = 186; // The rod the eyelet rides on.
const CONE = 252;
const WIND = 0.78;
const REST = 0.5;
/* ThreadSpool: a small winding bench. The thread leaves a cone, runs through an eyelet that rides up and down a rod, and lays real layers on a wooden bobbin; when full it pays back, and the next colour starts. */
export function ThreadSpool({ colors = ["#d4365f", "#1f9d8b", "#e6a02f"], layers = 7, speed = 1, label = "Loading", className = "" }: ThreadSpoolProps) {
const reduce = useReducedMotion();
const id = useId().replace(/:/g, "");
const [clock, setClock] = useState(REST);
useEffect(() => {
if (reduce) return;
const seconds = 8 / speed;
const start = performance.now() - REST * seconds * 1000;
let raf = 0;
const tick = (now: number) => {
setClock((now - start) / (seconds * 1000));
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [reduce, speed]);
const shown = reduce ? REST : clock;
const p = shown % 1;
const color = colors[Math.floor(shown) % colors.length];
// Wound share: climbs through the layers, then pays back faster.
const wound = lerp(span(p, 0, WIND), 0, ease(span(p, WIND, 1)));
const q = Math.min(wound * layers, layers - 0.0001);
const done = Math.floor(q);
const pass = q - done;
const depth = STEP / layers;
// Odd layers run up, even ones run down, so the eyelet goes to and fro.
const up = done % 2 === 0;
const at = up ? lerp(BOTTOM, TOP, pass) : lerp(TOP, BOTTOM, pass);
const body = BARREL + depth * done;
const lay = body + depth;
// One outline for the wound thread: the finished layers plus the step of the running one.
const l = CX - lay;
const r = CX + lay;
const wrap =
done === 0
? `M${l} ${up ? BOTTOM : TOP} H${r} V${at} H${l} Z`
: up
? `M${CX - body} ${TOP} H${CX + body} V${at} H${r} V${BOTTOM} H${l} V${at} H${CX - body} Z`
: `M${l} ${TOP} H${r} V${at} H${CX + body} V${BOTTOM} H${CX - body} V${at} H${l} Z`;
const spin = wound * 1400;
const left = 1 - wound * 0.72; // Thread left on the cone.
const mark = (spin / 18) % (Math.PI * 2);
return (
<div role="status" aria-label={label} className={`w-80 ${className}`}>
<svg viewBox="0 0 300 220" className="block w-full" aria-hidden>
<defs>
<pattern id={`${id}t`} width="16" height="3" patternUnits="userSpaceOnUse" patternTransform={`translate(${spin} 0) rotate(-4)`}>
<rect width="16" height="1.3" fill="#fff" opacity="0.24" />
<rect y="1.6" width="16" height="1.2" fill="#000" opacity="0.22" />
</pattern>
<linearGradient id={`${id}r`} x1="0" x2="1">
<stop offset="0" stopColor="#000" stopOpacity="0.56" />
<stop offset="0.36" stopColor="#fff" stopOpacity="0.2" />
<stop offset="0.7" stopColor="#000" stopOpacity="0.1" />
<stop offset="1" stopColor="#000" stopOpacity="0.62" />
</linearGradient>
<linearGradient id={`${id}w`} x1="0" y1="0" x2="1" y2="0">
<stop offset="0" stopColor="#a8703c" />
<stop offset="0.4" stopColor="#e8bd84" />
<stop offset="1" stopColor="#8c5a2e" />
</linearGradient>
<linearGradient id={`${id}s`} x1="0" x2="1">
<stop offset="0" stopColor="#7a828a" />
<stop offset="0.4" stopColor="#f0f3f5" />
<stop offset="1" stopColor="#5f666d" />
</linearGradient>
<linearGradient id={`${id}b`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#6d4526" />
<stop offset="1" stopColor="#35200f" />
</linearGradient>
</defs>
<rect x="8" y="192" width="284" height="20" rx="4" fill={`url(#${id}b)`} />
<rect x="8" y="192" width="284" height="1.6" fill="#fff" opacity="0.2" />
{/* Bobbin: barrel, thread layers, then the two flanges over the ends. */}
<rect x={CX - BARREL} y={TOP} width={BARREL * 2} height={BOTTOM - TOP} fill={`url(#${id}w)`} />
<path d={wrap} fill={color} style={{ transition: "fill .4s" }} />
<path d={wrap} fill={`url(#${id}t)`} />
<path d={wrap} fill={`url(#${id}r)`} />
<rect x={CX - 60} y="178" width="120" height="12" rx="2" fill={`url(#${id}w)`} />
<rect x={CX - 60} y="26" width="120" height="12" rx="2" fill={`url(#${id}w)`} />
<ellipse cx={CX} cy="26" rx="60" ry="8" fill="#e8bd84" />
<ellipse cx={CX} cy="26" rx="60" ry="8" fill="none" stroke="#8c5a2e" strokeWidth="1" />
<ellipse cx={CX} cy="26" rx="8" ry="2.6" fill="#3a230f" />
<circle cx={CX + Math.cos(mark) * 40} cy={26 + Math.sin(mark) * 5.2} r="2.6" fill="#7d4f28" />
{/* The rod and the cone behind the thread. */}
<rect x={GUIDE - 1.8} y="20" width="3.6" height="172" fill={`url(#${id}s)`} />
<rect x={GUIDE - 7} y="14" width="14" height="7" rx="1.6" fill={`url(#${id}s)`} />
<rect x={CONE - 1.6} y="38" width="3.2" height="154" fill={`url(#${id}s)`} />
<path
d={`M${CONE - 6} 62 L${CONE + 6} 62 L${CONE + 8 + 26 * left} 188 L${CONE - 8 - 26 * left} 188 Z`}
fill={color}
style={{ transition: "fill .4s" }}
/>
<path
d={`M${CONE - 6} 62 L${CONE + 6} 62 L${CONE + 8 + 26 * left} 188 L${CONE - 8 - 26 * left} 188 Z`}
fill={`url(#${id}t)`}
/>
<path
d={`M${CONE - 6} 62 L${CONE + 6} 62 L${CONE + 8 + 26 * left} 188 L${CONE - 8 - 26 * left} 188 Z`}
fill={`url(#${id}r)`}
/>
<path d={`M${CONE - 30} 188 h60 v4 h-60 Z`} fill="#c9a064" />
{/* The running thread: cone top, overhead eye, down to the eyelet, across to the bobbin. */}
<path
d={`M${CONE} 60 Q${CONE - 4} 34 ${CONE - 12} 30 L${GUIDE + 2} ${at - 3}`}
fill="none"
stroke={color}
strokeWidth="1.4"
style={{ transition: "stroke .4s" }}
/>
<line x1={GUIDE} y1={at} x2={CX + lay} y2={at} stroke={color} strokeWidth="1.4" style={{ transition: "stroke .4s" }} />
<g transform={`translate(${GUIDE} ${at})`}>
<rect x="-5" y="-8" width="10" height="16" rx="2.4" fill="#caa24e" />
<rect x="-5" y="-8" width="10" height="16" rx="2.4" fill={`url(#${id}r)`} />
<circle r="3.2" fill="none" stroke="#f6dfa2" strokeWidth="1.8" />
</g>
</svg>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/thread-spool.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/thread-spool-demo.tsx
import { ThreadSpool } from "@/components/ui/thread-spool"; export default function ThreadSpoolExample() { return ( <div className="@container-size grid h-full place-items-center overflow-hidden bg-[radial-gradient(circle_at_40%_40%,#2f2a24,#100e0b_75%)] p-2"> <ThreadSpool className="w-[min(125cqh,92cqw,420px)]" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| colors | string[] | ["#d4365f", "#1f9d8b", "#e6a02f"] | Thread colours, one per wind. |
| layers | number | 7 | Layers of thread laid before it pays back. |
| speed | number | 1 | 1 is the default pace; 2 is twice as fast. |
| label | string | "Loading" | Read out by screen readers (role=status). |
| className | string | "w-80" | Set the width here. Everything scales with it. |