Spool
import { useEffect, useRef, useState } from 'react';
const W = 240;
const LINE = 40;
const R = 17;
const HUB = 6;
const START = R + 2;
const END = W - R - 2;
// Thread left on the spool shrinks it by area, so it empties slowly at first and quickly at the end.
const wound = (f: number) => Math.sqrt(HUB * HUB + 6 + (1 - f) * (R * R - HUB * HUB - 12));
const css = `
.slider-spool { position: relative; width: ${W}px; height: 64px; }
.slider-spool__svg { display: block; width: ${W}px; height: 48px; overflow: visible; }
.slider-spool__guide { stroke: light-dark(#dbdde1, #2e3035); stroke-width: 1.5; stroke-dasharray: 1 5; stroke-linecap: round; }
.slider-spool__thread { stroke: light-dark(#42454c, #e5e6e9); stroke-width: 2; stroke-linecap: round; fill: none; }
.slider-spool__flange { fill: color-mix(in oklab, light-dark(#f3f4f7, #1a1c1f) 70%, transparent); stroke: light-dark(#cbced3, #393c41); stroke-width: 1; }
.slider-spool__wound { fill: light-dark(#50535b, #c6c8cd); }
.slider-spool__turns { fill: none; stroke: light-dark(#6d7179, #7d8087); stroke-width: 0.8; stroke-dasharray: 9 2.5; }
.slider-spool__hub { fill: light-dark(#f3f4f7, #1a1c1f); stroke: light-dark(#6d7179, #7d8087); stroke-width: 1; }
.slider-spool__hole { fill: light-dark(#cbced3, #393c41); }
.slider-spool__body { transform-box: fill-box; transform-origin: center; transition: transform 0.3s cubic-bezier(0.3, 1.6, 0.5, 1); }
.slider-spool:has(.slider-spool__input:active) .slider-spool__body { transform: translateY(-2px); }
.slider-spool__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-spool:has(.slider-spool__input:focus-visible) .slider-spool__ring { stroke: light-dark(#50535b, #c6c8cd); }
.slider-spool__value { position: absolute; right: 0; top: 48px; font-size: 12px; color: light-dark(#5f626a, #a7abb2); font-variant-numeric: tabular-nums; }
.slider-spool__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; }
.slider-spool__input { position: absolute; left: 0; top: 0; width: 100%; height: 48px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-spool__input::-webkit-slider-thumb { -webkit-appearance: none; width: ${2 * R + 4}px; height: 48px; }
.slider-spool__input::-moz-range-thumb { width: ${2 * R + 4}px; height: 48px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-spool__body { transition: none; }
}
`;
/** Rolls the spool to the value rather than jumping, so a click far along unwinds the thread on the way. */
function useSpring(target: number) {
const [x, setX] = useState(target);
const s = useRef({ x: target, v: 0, raf: 0 });
useEffect(() => {
const st = s.current;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
st.x = target;
setX(target);
return;
}
let last = performance.now();
const step = (now: number) => {
const dt = Math.min(0.032, (now - last) / 1000);
last = now;
st.v += (-200 * (st.x - target) - 26 * st.v) * dt;
st.x += st.v * dt;
if (Math.abs(st.x - target) < 0.02 && Math.abs(st.v) < 0.05) {
st.x = target;
st.v = 0;
setX(target);
return;
}
setX(st.x);
st.raf = requestAnimationFrame(step);
};
cancelAnimationFrame(st.raf);
st.raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(st.raf);
}, [target]);
return x;
}
export function SpoolSlider() {
const [v, setV] = useState(40);
const cx = useSpring(START + (v / 100) * (END - START));
const f = (cx - START) / (END - START);
const r = wound(f);
const cy = LINE - R;
// It rolls on its flanges, so it turns by the distance travelled over the flange radius.
const turn = ((cx - START) / R) * (180 / Math.PI);
// The thread leaves the underside of what is still wound and runs back down to the line.
const tx = cx - r * 0.35;
const ty = cy + r * 0.94;
return (
<>
<style>
{css}
</style>
<div className="slider-spool">
<svg className="slider-spool__svg" aria-hidden="true">
<line className="slider-spool__guide" x1={cx + R + 4} x2={W - 1} y1={LINE} y2={LINE} />
<path className="slider-spool__thread" d={`M1 ${LINE}H${Math.max(1, cx - R * 0.9)}Q${cx - r * 0.6} ${LINE} ${tx} ${ty}`} />
<g className="slider-spool__body">
<circle className="slider-spool__flange" cx={cx} cy={cy} r={R} />
<circle className="slider-spool__wound" cx={cx} cy={cy} r={r} />
<g transform={`rotate(${turn} ${cx} ${cy})`}>
{[0.3, 0.55, 0.8].map((k) => (
<circle key={k} className="slider-spool__turns" cx={cx} cy={cy} r={HUB + (r - HUB) * k} />
))}
<circle className="slider-spool__hub" cx={cx} cy={cy} r={HUB} />
{[0, 120, 240].map((a) => (
<circle key={a} className="slider-spool__hole" cx={cx + Math.cos((a * Math.PI) / 180) * HUB * 0.5} cy={cy + Math.sin((a * Math.PI) / 180) * HUB * 0.5} r={1.2} />
))}
</g>
</g>
<circle className="slider-spool__ring" cx={cx} cy={cy} r={R + 4} />
</svg>
<p className="slider-spool__value" aria-hidden="true">
<b>{v}</b> m
</p>
<input
className="slider-spool__input"
type="range"
min={0}
max={100}
value={v}
onChange={(e) => setV(+e.target.value)}
aria-label="Length"
aria-valuetext={`${v} metres`}
/>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderSpool(el) on the mounted element instead.
<div class="slider-spool">
<svg class="slider-spool__svg" aria-hidden="true">
<line class="slider-spool__guide" x1="120.8" x2="239" y1="40" y2="40"></line>
<path class="slider-spool__thread" d="M1 40H84.5Q91.604 40 95.019 35.841"></path>
<g class="slider-spool__body">
<circle class="slider-spool__flange" cx="99.8" cy="23" r="17"></circle>
<circle class="slider-spool__wound" cx="99.8" cy="23" r="13.66"></circle>
<g transform="rotate(272.323 99.8 23)">
<circle class="slider-spool__turns" cx="99.8" cy="23" r="8.298"></circle>
<circle class="slider-spool__turns" cx="99.8" cy="23" r="10.213"></circle>
<circle class="slider-spool__turns" cx="99.8" cy="23" r="12.128"></circle>
<circle class="slider-spool__hub" cx="99.8" cy="23" r="6"></circle>
<circle class="slider-spool__hole" cx="102.8" cy="23" r="1.2"></circle>
<circle class="slider-spool__hole" cx="98.3" cy="25.598" r="1.2"></circle>
<circle class="slider-spool__hole" cx="98.3" cy="20.402" r="1.2"></circle>
</g>
</g>
<circle class="slider-spool__ring" cx="99.8" cy="23" r="21"></circle>
</svg>
<p class="slider-spool__value" aria-hidden="true">
<b>40</b>
m
</p>
<input class="slider-spool__input" type="range" min="0" max="100" aria-label="Length" aria-valuetext="40 metres" value="40"/>
</div>.slider-spool { position: relative; width: 240px; height: 64px; }
.slider-spool__svg { display: block; width: 240px; height: 48px; overflow: visible; }
.slider-spool__guide { stroke: light-dark(#dbdde1, #2e3035); stroke-width: 1.5; stroke-dasharray: 1 5; stroke-linecap: round; }
.slider-spool__thread { stroke: light-dark(#42454c, #e5e6e9); stroke-width: 2; stroke-linecap: round; fill: none; }
.slider-spool__flange { fill: color-mix(in oklab, light-dark(#f3f4f7, #1a1c1f) 70%, transparent); stroke: light-dark(#cbced3, #393c41); stroke-width: 1; }
.slider-spool__wound { fill: light-dark(#50535b, #c6c8cd); }
.slider-spool__turns { fill: none; stroke: light-dark(#6d7179, #7d8087); stroke-width: 0.8; stroke-dasharray: 9 2.5; }
.slider-spool__hub { fill: light-dark(#f3f4f7, #1a1c1f); stroke: light-dark(#6d7179, #7d8087); stroke-width: 1; }
.slider-spool__hole { fill: light-dark(#cbced3, #393c41); }
.slider-spool__body { transform-box: fill-box; transform-origin: center; transition: transform 0.3s cubic-bezier(0.3, 1.6, 0.5, 1); }
.slider-spool:has(.slider-spool__input:active) .slider-spool__body { transform: translateY(-2px); }
.slider-spool__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-spool:has(.slider-spool__input:focus-visible) .slider-spool__ring { stroke: light-dark(#50535b, #c6c8cd); }
.slider-spool__value { position: absolute; right: 0; top: 48px; font-size: 12px; color: light-dark(#5f626a, #a7abb2); font-variant-numeric: tabular-nums; }
.slider-spool__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; }
.slider-spool__input { position: absolute; left: 0; top: 0; width: 100%; height: 48px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-spool__input::-webkit-slider-thumb { -webkit-appearance: none; width: 38px; height: 48px; }
.slider-spool__input::-moz-range-thumb { width: 38px; height: 48px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-spool__body { transition: none; }
}function initSliderSpool(root) {
var W = 240;
var LINE = 40;
var R = 17;
var HUB = 6;
var START = R + 2;
var END = W - R - 2;
var input = root.querySelector('.slider-spool__input');
var guide = root.querySelector('.slider-spool__guide');
var thread = root.querySelector('.slider-spool__thread');
var flange = root.querySelector('.slider-spool__flange');
var wound = root.querySelector('.slider-spool__wound');
var spin = root.querySelector('.slider-spool__body g');
var turns = root.querySelectorAll('.slider-spool__turns');
var hub = root.querySelector('.slider-spool__hub');
var holes = root.querySelectorAll('.slider-spool__hole');
var ring = root.querySelector('.slider-spool__ring');
var value = root.querySelector('.slider-spool__value b');
var cx = target();
var vel = 0;
var raf = 0;
function target() { return START + (+input.value / 100) * (END - START); }
// Thread left on the spool shrinks it by area, so it empties slowly at first and quickly at the end.
function woundRadius(f) { return Math.sqrt(HUB * HUB + 6 + (1 - f) * (R * R - HUB * HUB - 12)); }
function draw() {
var r = woundRadius((cx - START) / (END - START));
var cy = LINE - R;
// It rolls on its flanges, so it turns by the distance travelled over the flange radius.
var turn = ((cx - START) / R) * (180 / Math.PI);
var tx = cx - r * 0.35;
var ty = cy + r * 0.94;
guide.setAttribute('x1', cx + R + 4);
thread.setAttribute('d', 'M1 ' + LINE + 'H' + Math.max(1, cx - R * 0.9) + 'Q' + (cx - r * 0.6) + ' ' + LINE + ' ' + tx + ' ' + ty);
[flange, wound, hub, ring].forEach(function (c) { c.setAttribute('cx', cx); });
wound.setAttribute('r', r);
spin.setAttribute('transform', 'rotate(' + turn + ' ' + cx + ' ' + cy + ')');
[0.3, 0.55, 0.8].forEach(function (k, i) {
turns[i].setAttribute('cx', cx);
turns[i].setAttribute('r', HUB + (r - HUB) * k);
});
[0, 120, 240].forEach(function (a, i) {
holes[i].setAttribute('cx', cx + Math.cos((a * Math.PI) / 180) * HUB * 0.5);
});
}
// Rolls the spool to the value rather than jumping, so a click far along unwinds the thread on the way.
function run() {
cancelAnimationFrame(raf);
value.textContent = input.value;
input.setAttribute('aria-valuetext', input.value + ' metres');
var goal = target();
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
cx = goal;
return draw();
}
var last = performance.now();
function step(now) {
var dt = Math.min(0.032, (now - last) / 1000);
last = now;
vel += (-200 * (cx - goal) - 26 * vel) * dt;
cx += vel * dt;
if (Math.abs(cx - goal) < 0.02 && Math.abs(vel) < 0.05) {
cx = goal;
vel = 0;
return draw();
}
draw();
raf = requestAnimationFrame(step);
}
raf = requestAnimationFrame(step);
}
input.addEventListener('input', run);
return function () {
cancelAnimationFrame(raf);
input.removeEventListener('input', run);
};
}
document.querySelectorAll('.slider-spool').forEach(initSliderSpool);Goal: Create a "Spool" slider.
Visual behavior: A length slider whose thumb is a spool of thread: it rolls along the track laying its thread down as the fill, shrinking as it runs out and filling out again as it winds back.
Technique:
- A real <input type="range"> lies over the drawing, transparent (opacity 0, appearance none), with its thumb hit area sized to the drawn thumb, so dragging, clicking the track and the arrow, Page Up/Down, Home and End keys all work natively. The drawing is aria-hidden.
- The wound thread's radius comes from the thread left on it by area (the square root of the hub area plus what remains), so it empties slowly at first and quickly at the end. It rolls on its flanges, so it turns by the distance travelled over the flange radius; dashed rings and holes in the hub show the turn.
- The thread is a path along the line that curves up into the underside of the wound thread, and a dotted guide runs ahead. The drawn position follows the input on a spring, so a click far along unwinds the thread on the way.
Accessibility: A native range input with an aria-label and an aria-valuetext in metres; keyboard focus rings the spool. Under prefers-reduced-motion the spool moves without the spring.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Spool" slider.
Visual behavior: A length slider whose thumb is a spool of thread: it rolls along the track laying its thread down as the fill, shrinking as it runs out and filling out again as it winds back.
Technique:
- A real <input type="range"> lies over the drawing, transparent (opacity 0, appearance none), with its thumb hit area sized to the drawn thumb, so dragging, clicking the track and the arrow, Page Up/Down, Home and End keys all work natively. The drawing is aria-hidden.
- The wound thread's radius comes from the thread left on it by area (the square root of the hub area plus what remains), so it empties slowly at first and quickly at the end. It rolls on its flanges, so it turns by the distance travelled over the flange radius; dashed rings and holes in the hub show the turn.
- The thread is a path along the line that curves up into the underside of the wound thread, and a dotted guide runs ahead. The drawn position follows the input on a spring, so a click far along unwinds the thread on the way.
Accessibility: A native range input with an aria-label and an aria-valuetext in metres; keyboard focus rings the spool. Under prefers-reduced-motion the spool moves without the spring.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A length slider whose thumb is a spool of thread: it rolls along the track laying its thread down as the fill, shrinking as it runs out and filling out again as it winds back.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, spring, fill
- Techniques
- svg, spring, raf
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A native range input with an aria-label and an aria-valuetext in metres; keyboard focus rings the spool. Under prefers-reduced-motion the spool moves without the spring.