Needles
import { useEffect, useRef, useState } from 'react';
const W = 240;
const NY = 16;
const TY = 50;
const T = 16;
const XS = Array.from({ length: 13 }, (_, i) => 12 + i * 18);
const at = (v: number) => T / 2 + (v / 100) * (W - T);
const aim = (x: number, tx: number) => (Math.atan2(TY - NY, tx - x) * 180) / Math.PI;
const css = `
.slider-needles { position: relative; width: ${W}px; height: 84px; }
.slider-needles__svg { position: absolute; left: 0; top: 0; width: ${W}px; height: 62px; overflow: visible; }
.slider-needles__north { fill: light-dark(#6d7179, #7d8087); transition: fill 0.2s; }
.slider-needles__north--on { fill: light-dark(#373a40, #f1f2f3); }
.slider-needles__south { fill: light-dark(#dbdde1, #2e3035); }
.slider-needles__pivot { fill: light-dark(#5f626a, #a7abb2); }
.slider-needles__rail { stroke: light-dark(#ebedf0, #232529); stroke-width: 4; stroke-linecap: round; }
.slider-needles__fill { stroke: light-dark(#50535b, #c6c8cd); stroke-width: 4; stroke-linecap: round; }
.slider-needles__thumb { fill: light-dark(#373a40, #f1f2f3); }
.slider-needles__core { fill: light-dark(#fbfcfd, #121417); }
.slider-needles__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-needles:has(.slider-needles__input:focus-visible) .slider-needles__ring { stroke: light-dark(#50535b, #c6c8cd); }
.slider-needles__value { position: absolute; left: 0; right: 0; top: 66px; display: flex; justify-content: space-between; margin: 0; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-needles__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-needles__input { position: absolute; left: 0; top: 0; width: 100%; height: 62px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-needles__input::-webkit-slider-thumb { -webkit-appearance: none; width: ${T}px; height: 62px; }
.slider-needles__input::-moz-range-thumb { width: ${T}px; height: 62px; border: 0; }
`;
export function NeedlesSlider() {
const [v, setV] = useState(58);
const tx = at(v);
const [angles, setAngles] = useState(() => XS.map((x) => aim(x, at(58))));
const st = useRef({ a: XS.map((x) => aim(x, at(58))), w: XS.map(() => 0), raf: 0 });
// Each needle swings round on its own loose spring, so they overshoot and wobble a little as they settle.
useEffect(() => {
const q = st.current;
const goal = XS.map((x) => aim(x, tx));
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
q.a = goal;
setAngles(goal);
return;
}
let last = performance.now();
const step = (now: number) => {
const dt = Math.min(0.032, (now - last) / 1000);
last = now;
let busy = false;
q.a.forEach((a, i) => {
q.w[i] += (-(110 + (i % 3) * 18) * (a - goal[i]) - 7 * q.w[i]) * dt;
q.a[i] += q.w[i] * dt;
if (Math.abs(q.a[i] - goal[i]) > 0.05 || Math.abs(q.w[i]) > 0.3) busy = true;
});
setAngles(q.a.slice());
if (busy) q.raf = requestAnimationFrame(step);
};
cancelAnimationFrame(q.raf);
q.raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(q.raf);
}, [tx]);
return (
<>
<style>
{css}
</style>
<div className="slider-needles">
<svg className="slider-needles__svg" aria-hidden="true">
{XS.map((x, i) => (
<g key={x} className="slider-needles__needle" transform={`translate(${x} ${NY}) rotate(${angles[i]})`}>
<polygon className="slider-needles__south" points="0,-3 -7,0 0,3" />
<polygon className={`slider-needles__north${x <= tx ? ' slider-needles__north--on' : ''}`} points="0,-3 9.5,0 0,3" />
<circle className="slider-needles__pivot" r={1.5} />
</g>
))}
<line className="slider-needles__rail" x1={T / 2} y1={TY} x2={W - T / 2} y2={TY} />
<line className="slider-needles__fill" x1={T / 2} y1={TY} x2={tx} y2={TY} />
<circle className="slider-needles__thumb" cx={tx} cy={TY} r={T / 2} />
<circle className="slider-needles__core" cx={tx} cy={TY} r={2.5} />
<circle className="slider-needles__ring" cx={tx} cy={TY} r={T / 2 + 4} />
</svg>
<p className="slider-needles__value" aria-hidden="true">
<span>Field</span>
<b>{v}%</b>
</p>
<input
className="slider-needles__input"
type="range"
min={0}
max={100}
value={v}
onChange={(e) => setV(+e.target.value)}
aria-label="Field strength"
aria-valuetext={`${v}%`}
/>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderNeedles(el) on the mounted element instead.
<div class="slider-needles">
<svg class="slider-needles__svg" aria-hidden="true">
<g class="slider-needles__needle" transform="translate(12 16) rotate(15.11)">
<polygon class="slider-needles__south" points="0,-3 -7,0 0,3"></polygon>
<polygon class="slider-needles__north slider-needles__north--on" points="0,-3 9.5,0 0,3"></polygon>
<circle class="slider-needles__pivot" r="1.5"></circle>
</g>
<g class="slider-needles__needle" transform="translate(30 16) rotate(17.487)">
<polygon class="slider-needles__south" points="0,-3 -7,0 0,3"></polygon>
<polygon class="slider-needles__north slider-needles__north--on" points="0,-3 9.5,0 0,3"></polygon>
<circle class="slider-needles__pivot" r="1.5"></circle>
</g>
<g class="slider-needles__needle" transform="translate(48 16) rotate(20.712)">
<polygon class="slider-needles__south" points="0,-3 -7,0 0,3"></polygon>
<polygon class="slider-needles__north slider-needles__north--on" points="0,-3 9.5,0 0,3"></polygon>
<circle class="slider-needles__pivot" r="1.5"></circle>
</g>
<g class="slider-needles__needle" transform="translate(66 16) rotate(25.302)">
<polygon class="slider-needles__south" points="0,-3 -7,0 0,3"></polygon>
<polygon class="slider-needles__north slider-needles__north--on" points="0,-3 9.5,0 0,3"></polygon>
<circle class="slider-needles__pivot" r="1.5"></circle>
</g>
<g class="slider-needles__needle" transform="translate(84 16) rotate(32.234)">
<polygon class="slider-needles__south" points="0,-3 -7,0 0,3"></polygon>
<polygon class="slider-needles__north slider-needles__north--on" points="0,-3 9.5,0 0,3"></polygon>
<circle class="slider-needles__pivot" r="1.5"></circle>
</g>
<g class="slider-needles__needle" transform="translate(102 16) rotate(43.427)">
<polygon class="slider-needles__south" points="0,-3 -7,0 0,3"></polygon>
<polygon class="slider-needles__north slider-needles__north--on" points="0,-3 9.5,0 0,3"></polygon>
<circle class="slider-needles__pivot" r="1.5"></circle>
</g>
<g class="slider-needles__needle" transform="translate(120 16) rotate(62.208)">
<polygon class="slider-needles__south" points="0,-3 -7,0 0,3"></polygon>
<polygon class="slider-needles__north slider-needles__north--on" points="0,-3 9.5,0 0,3"></polygon>
<circle class="slider-needles__pivot" r="1.5"></circle>
</g>
<g class="slider-needles__needle" transform="translate(138 16) rotate(90.135)">
<polygon class="slider-needles__south" points="0,-3 -7,0 0,3"></polygon>
<polygon class="slider-needles__north" points="0,-3 9.5,0 0,3"></polygon>
<circle class="slider-needles__pivot" r="1.5"></circle>
</g>
<g class="slider-needles__needle" transform="translate(156 16) rotate(118.002)">
<polygon class="slider-needles__south" points="0,-3 -7,0 0,3"></polygon>
<polygon class="slider-needles__north" points="0,-3 9.5,0 0,3"></polygon>
<circle class="slider-needles__pivot" r="1.5"></circle>
</g>
<g class="slider-needles__needle" transform="translate(174 16) rotate(136.7)">
<polygon class="slider-needles__south" points="0,-3 -7,0 0,3"></polygon>
<polygon class="slider-needles__north" points="0,-3 9.5,0 0,3"></polygon>
<circle class="slider-needles__pivot" r="1.5"></circle>
</g>
<g class="slider-needles__needle" transform="translate(192 16) rotate(147.842)">
<polygon class="slider-needles__south" points="0,-3 -7,0 0,3"></polygon>
<polygon class="slider-needles__north" points="0,-3 9.5,0 0,3"></polygon>
<circle class="slider-needles__pivot" r="1.5"></circle>
</g>
<g class="slider-needles__needle" transform="translate(210 16) rotate(154.747)">
<polygon class="slider-needles__south" points="0,-3 -7,0 0,3"></polygon>
<polygon class="slider-needles__north" points="0,-3 9.5,0 0,3"></polygon>
<circle class="slider-needles__pivot" r="1.5"></circle>
</g>
<g class="slider-needles__needle" transform="translate(228 16) rotate(159.321)">
<polygon class="slider-needles__south" points="0,-3 -7,0 0,3"></polygon>
<polygon class="slider-needles__north" points="0,-3 9.5,0 0,3"></polygon>
<circle class="slider-needles__pivot" r="1.5"></circle>
</g>
<line class="slider-needles__rail" x1="8" y1="50" x2="232" y2="50"></line>
<line class="slider-needles__fill" x1="8" y1="50" x2="137.92" y2="50"></line>
<circle class="slider-needles__thumb" cx="137.92" cy="50" r="8"></circle>
<circle class="slider-needles__core" cx="137.92" cy="50" r="2.5"></circle>
<circle class="slider-needles__ring" cx="137.92" cy="50" r="12"></circle>
</svg>
<p class="slider-needles__value" aria-hidden="true">
<span>Field</span>
<b>58%</b>
</p>
<input class="slider-needles__input" type="range" min="0" max="100" aria-label="Field strength" aria-valuetext="58%" value="58"/>
</div>.slider-needles { position: relative; width: 240px; height: 84px; }
.slider-needles__svg { position: absolute; left: 0; top: 0; width: 240px; height: 62px; overflow: visible; }
.slider-needles__north { fill: light-dark(#6d7179, #7d8087); transition: fill 0.2s; }
.slider-needles__north--on { fill: light-dark(#373a40, #f1f2f3); }
.slider-needles__south { fill: light-dark(#dbdde1, #2e3035); }
.slider-needles__pivot { fill: light-dark(#5f626a, #a7abb2); }
.slider-needles__rail { stroke: light-dark(#ebedf0, #232529); stroke-width: 4; stroke-linecap: round; }
.slider-needles__fill { stroke: light-dark(#50535b, #c6c8cd); stroke-width: 4; stroke-linecap: round; }
.slider-needles__thumb { fill: light-dark(#373a40, #f1f2f3); }
.slider-needles__core { fill: light-dark(#fbfcfd, #121417); }
.slider-needles__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-needles:has(.slider-needles__input:focus-visible) .slider-needles__ring { stroke: light-dark(#50535b, #c6c8cd); }
.slider-needles__value { position: absolute; left: 0; right: 0; top: 66px; display: flex; justify-content: space-between; margin: 0; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-needles__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-needles__input { position: absolute; left: 0; top: 0; width: 100%; height: 62px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-needles__input::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 62px; }
.slider-needles__input::-moz-range-thumb { width: 16px; height: 62px; border: 0; }function initSliderNeedles(root) {
var W = 240;
var NY = 16;
var TY = 50;
var T = 16;
var input = root.querySelector('.slider-needles__input');
var needles = root.querySelectorAll('.slider-needles__needle');
var norths = root.querySelectorAll('.slider-needles__north');
var fill = root.querySelector('.slider-needles__fill');
var thumb = root.querySelector('.slider-needles__thumb');
var core = root.querySelector('.slider-needles__core');
var ring = root.querySelector('.slider-needles__ring');
var label = root.querySelector('.slider-needles__value b');
var xs = [];
for (var i = 0; i < needles.length; i++) xs.push(12 + i * 18);
var a = xs.map(function (x) { return aim(x, at(+input.value)); });
var w = xs.map(function () { return 0; });
var raf = 0;
function at(v) { return T / 2 + (v / 100) * (W - T); }
function aim(x, tx) { return (Math.atan2(TY - NY, tx - x) * 180) / Math.PI; }
function draw() {
needles.forEach(function (g, i) { g.setAttribute('transform', 'translate(' + xs[i] + ' ' + NY + ') rotate(' + a[i] + ')'); });
}
// Each needle swings round on its own loose spring, so they overshoot and wobble a little as they settle.
function update() {
var tx = at(+input.value);
norths.forEach(function (n, i) { n.classList.toggle('slider-needles__north--on', xs[i] <= tx); });
fill.setAttribute('x2', tx);
[thumb, core, ring].forEach(function (c) { c.setAttribute('cx', tx); });
label.textContent = input.value + '%';
input.setAttribute('aria-valuetext', input.value + '%');
var goal = xs.map(function (x) { return aim(x, tx); });
cancelAnimationFrame(raf);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
a = goal;
return draw();
}
var last = performance.now();
function step(now) {
var dt = Math.min(0.032, (now - last) / 1000);
last = now;
var busy = false;
for (var i = 0; i < a.length; i++) {
w[i] += (-(110 + (i % 3) * 18) * (a[i] - goal[i]) - 7 * w[i]) * dt;
a[i] += w[i] * dt;
if (Math.abs(a[i] - goal[i]) > 0.05 || Math.abs(w[i]) > 0.3) busy = true;
}
draw();
if (busy) raf = requestAnimationFrame(step);
}
raf = requestAnimationFrame(step);
}
input.addEventListener('input', update);
return function () {
cancelAnimationFrame(raf);
input.removeEventListener('input', update);
};
}
document.querySelectorAll('.slider-needles').forEach(initSliderNeedles);Goal: Create a "Needles" slider.
Visual behavior: A row of compass needles above the track all swing to point at the thumb; the ones it has passed are inked, the ones ahead stay faint, and they wobble a little as they settle.
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.
- Each needle aims at the thumb with atan2 of the offset between them, so those near the thumb point straight down and those far off lie almost flat. Every needle chases its angle on its own underdamped spring, with slightly different stiffness from one to the next, so they overshoot and settle out of step.
- A needle is two triangles round a pivot: the half pointing at the thumb darkens once the thumb has passed it.
Accessibility: A native range input with an aria-label and an aria-valuetext in percent; keyboard focus rings the thumb. Under prefers-reduced-motion the needles turn at once.
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 "Needles" slider.
Visual behavior: A row of compass needles above the track all swing to point at the thumb; the ones it has passed are inked, the ones ahead stay faint, and they wobble a little as they settle.
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.
- Each needle aims at the thumb with atan2 of the offset between them, so those near the thumb point straight down and those far off lie almost flat. Every needle chases its angle on its own underdamped spring, with slightly different stiffness from one to the next, so they overshoot and settle out of step.
- A needle is two triangles round a pivot: the half pointing at the thumb darkens once the thumb has passed it.
Accessibility: A native range input with an aria-label and an aria-valuetext in percent; keyboard focus rings the thumb. Under prefers-reduced-motion the needles turn at once.
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 row of compass needles above the track all swing to point at the thumb; the ones it has passed are inked, the ones ahead stay faint, and they wobble a little as they settle.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, spring, continuous
- 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 percent; keyboard focus rings the thumb. Under prefers-reduced-motion the needles turn at once.