Pinion
import { useEffect, useId, useRef, useState } from 'react';
const W = 240;
const R = 12;
const N = 12;
const PITCH = (2 * Math.PI * R) / N;
const C = 22;
const PL = C + R;
const X0 = 16;
const X1 = W - 16;
const at = (v: number) => X0 + (v / 100) * (X1 - X0);
const polar = (r: number, a: number) => `${(r * Math.cos(a)).toFixed(2)} ${(r * Math.sin(a)).toFixed(2)}`;
// Twelve teeth on a 12px pitch circle; at rest one points straight down into a gap in the rack.
const GEAR = (() => {
const a = (2 * Math.PI) / N;
let d = '';
for (let i = 0; i < N; i++) {
const c = i * a;
d += `${i ? 'L' : 'M'} ${polar(R - 2.6, c - 0.3 * a)} L ${polar(R + 2.6, c - 0.14 * a)} L ${polar(R + 2.6, c + 0.14 * a)} L ${polar(R - 2.6, c + 0.3 * a)} `;
}
return `${d}Z`;
})();
// Rack teeth on the same pitch, so the gear meshes as it rolls.
const RACK = (() => {
let d = `M 4 ${PL + 2.6}`;
for (let k = -3; ; k++) {
const cx = X0 + (k + 0.5) * PITCH;
if (cx - 0.3 * PITCH < 4) continue;
if (cx + 0.3 * PITCH > W - 4) break;
d += ` L ${cx - 0.3 * PITCH} ${PL + 2.6} L ${cx - 0.14 * PITCH} ${PL - 2.6} L ${cx + 0.14 * PITCH} ${PL - 2.6} L ${cx + 0.3 * PITCH} ${PL + 2.6}`;
}
return `${d} L ${W - 4} ${PL + 2.6} L ${W - 4} ${PL + 9} L 4 ${PL + 9} Z`;
})();
const css = `
.slider-pinion { position: relative; width: ${W}px; height: 72px; }
.slider-pinion__svg { position: absolute; left: 0; top: 0; width: ${W}px; height: 50px; overflow: visible; }
.slider-pinion__rack--done { fill: light-dark(#50535b, #c6c8cd); }
.slider-pinion__rack--left { fill: light-dark(#cbced3, #393c41); }
.slider-pinion__gear { fill: light-dark(#373a40, #f1f2f3); }
.slider-pinion__hole { fill: light-dark(#fbfcfd, #121417); }
.slider-pinion__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-pinion:has(.slider-pinion__input:focus-visible) .slider-pinion__ring { stroke: light-dark(#50535b, #c6c8cd); }
.slider-pinion__value { position: absolute; left: 0; right: 0; top: 54px; display: flex; justify-content: space-between; margin: 0; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-pinion__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-pinion__input { position: absolute; left: ${X0 - 14}px; top: 0; width: ${X1 - X0 + 28}px; height: 50px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-pinion__input::-webkit-slider-thumb { -webkit-appearance: none; width: 28px; height: 50px; }
.slider-pinion__input::-moz-range-thumb { width: 28px; height: 50px; border: 0; }
`;
export function PinionSlider() {
const [v, setV] = useState(40);
const [x, setX] = useState(at(40));
const id = useId().replace(/[^a-zA-Z0-9]/g, '');
const st = useRef({ x: at(40), v: 0, raf: 0 });
useEffect(() => {
const q = st.current;
const target = at(v);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
q.x = target;
setX(target);
return;
}
let last = performance.now();
const step = (now: number) => {
const dt = Math.min(0.032, (now - last) / 1000);
last = now;
q.v += (-220 * (q.x - target) - 26 * q.v) * dt;
q.x += q.v * dt;
if (Math.abs(q.x - target) < 0.05 && Math.abs(q.v) < 0.5) {
q.x = target;
q.v = 0;
setX(target);
return;
}
setX(q.x);
q.raf = requestAnimationFrame(step);
};
cancelAnimationFrame(q.raf);
q.raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(q.raf);
}, [v]);
// Rolling without slip: the gear turns by the distance travelled over its pitch radius.
const turn = (((x - X0) / R) * 180) / Math.PI;
return (
<>
<style>
{css}
</style>
<div className="slider-pinion">
<svg className="slider-pinion__svg" aria-hidden="true">
<defs>
<clipPath id={`${id}l`}>
<rect x={0} y={0} width={x} height={50} />
</clipPath>
<clipPath id={`${id}r`}>
<rect x={x} y={0} width={W} height={50} />
</clipPath>
</defs>
<path className="slider-pinion__rack--done" d={RACK} clipPath={`url(#${id}l)`} />
<path className="slider-pinion__rack--left" d={RACK} clipPath={`url(#${id}r)`} />
<g className="slider-pinion__wheel" transform={`translate(${x} ${C}) rotate(${turn})`}>
<path className="slider-pinion__gear" d={GEAR} />
<circle className="slider-pinion__hole" r={3.2} />
{[45, 135, 225, 315].map((a) => (
<circle key={a} className="slider-pinion__hole" cx={6.4 * Math.cos((a * Math.PI) / 180)} cy={6.4 * Math.sin((a * Math.PI) / 180)} r={1.8} />
))}
</g>
<circle className="slider-pinion__ring" cx={x} cy={C} r={R + 6} />
</svg>
<p className="slider-pinion__value" aria-hidden="true">
<span>Travel</span>
<b>{v} mm</b>
</p>
<input
className="slider-pinion__input"
type="range"
min={0}
max={100}
value={v}
onChange={(e) => setV(+e.target.value)}
aria-label="Travel"
aria-valuetext={`${v} millimetres`}
/>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderPinion(el) on the mounted element instead.
<div class="slider-pinion">
<svg class="slider-pinion__svg" aria-hidden="true">
<defs>
<clipPath id="slider-pinion-done">
<rect x="0" y="0" width="99.2" height="50"></rect>
</clipPath>
<clipPath id="slider-pinion-left">
<rect x="99.2" y="0" width="240" height="50"></rect>
</clipPath>
</defs>
<path class="slider-pinion__rack--done" d="M 4 36.6 L 4.69 36.6 L 5.696 31.4 L 7.455 31.4 L 8.46 36.6 L 10.973 36.6 L 11.979 31.4 L 13.738 31.4 L 14.743 36.6 L 17.257 36.6 L 18.262 31.4 L 20.021 31.4 L 21.027 36.6 L 23.54 36.6 L 24.545 31.4 L 26.304 31.4 L 27.31 36.6 L 29.823 36.6 L 30.828 31.4 L 32.588 31.4 L 33.593 36.6 L 36.106 36.6 L 37.112 31.4 L 38.871 31.4 L 39.876 36.6 L 42.389 36.6 L 43.395 31.4 L 45.154 31.4 L 46.159 36.6 L 48.673 36.6 L 49.678 31.4 L 51.437 31.4 L 52.442 36.6 L 54.956 36.6 L 55.961 31.4 L 57.72 31.4 L 58.726 36.6 L 61.239 36.6 L 62.244 31.4 L 64.004 31.4 L 65.009 36.6 L 67.522 36.6 L 68.527 31.4 L 70.287 31.4 L 71.292 36.6 L 73.805 36.6 L 74.811 31.4 L 76.57 31.4 L 77.575 36.6 L 80.088 36.6 L 81.094 31.4 L 82.853 31.4 L 83.858 36.6 L 86.372 36.6 L 87.377 31.4 L 89.136 31.4 L 90.142 36.6 L 92.655 36.6 L 93.66 31.4 L 95.419 31.4 L 96.425 36.6 L 98.938 36.6 L 99.943 31.4 L 101.703 31.4 L 102.708 36.6 L 105.221 36.6 L 106.227 31.4 L 107.986 31.4 L 108.991 36.6 L 111.504 36.6 L 112.51 31.4 L 114.269 31.4 L 115.274 36.6 L 117.788 36.6 L 118.793 31.4 L 120.552 31.4 L 121.558 36.6 L 124.071 36.6 L 125.076 31.4 L 126.835 31.4 L 127.841 36.6 L 130.354 36.6 L 131.359 31.4 L 133.119 31.4 L 134.124 36.6 L 136.637 36.6 L 137.642 31.4 L 139.402 31.4 L 140.407 36.6 L 142.92 36.6 L 143.926 31.4 L 145.685 31.4 L 146.69 36.6 L 149.204 36.6 L 150.209 31.4 L 151.968 31.4 L 152.973 36.6 L 155.487 36.6 L 156.492 31.4 L 158.251 31.4 L 159.257 36.6 L 161.77 36.6 L 162.775 31.4 L 164.535 31.4 L 165.54 36.6 L 168.053 36.6 L 169.058 31.4 L 170.818 31.4 L 171.823 36.6 L 174.336 36.6 L 175.342 31.4 L 177.101 31.4 L 178.106 36.6 L 180.619 36.6 L 181.625 31.4 L 183.384 31.4 L 184.389 36.6 L 186.903 36.6 L 187.908 31.4 L 189.667 31.4 L 190.673 36.6 L 193.186 36.6 L 194.191 31.4 L 195.95 31.4 L 196.956 36.6 L 199.469 36.6 L 200.474 31.4 L 202.234 31.4 L 203.239 36.6 L 205.752 36.6 L 206.758 31.4 L 208.517 31.4 L 209.522 36.6 L 212.035 36.6 L 213.041 31.4 L 214.8 31.4 L 215.805 36.6 L 218.319 36.6 L 219.324 31.4 L 221.083 31.4 L 222.088 36.6 L 224.602 36.6 L 225.607 31.4 L 227.366 31.4 L 228.372 36.6 L 230.885 36.6 L 231.89 31.4 L 233.65 31.4 L 234.655 36.6 L 236 36.6 L 236 43 L 4 43 Z" clip-path="url(#slider-pinion-done)"></path>
<path class="slider-pinion__rack--left" d="M 4 36.6 L 4.69 36.6 L 5.696 31.4 L 7.455 31.4 L 8.46 36.6 L 10.973 36.6 L 11.979 31.4 L 13.738 31.4 L 14.743 36.6 L 17.257 36.6 L 18.262 31.4 L 20.021 31.4 L 21.027 36.6 L 23.54 36.6 L 24.545 31.4 L 26.304 31.4 L 27.31 36.6 L 29.823 36.6 L 30.828 31.4 L 32.588 31.4 L 33.593 36.6 L 36.106 36.6 L 37.112 31.4 L 38.871 31.4 L 39.876 36.6 L 42.389 36.6 L 43.395 31.4 L 45.154 31.4 L 46.159 36.6 L 48.673 36.6 L 49.678 31.4 L 51.437 31.4 L 52.442 36.6 L 54.956 36.6 L 55.961 31.4 L 57.72 31.4 L 58.726 36.6 L 61.239 36.6 L 62.244 31.4 L 64.004 31.4 L 65.009 36.6 L 67.522 36.6 L 68.527 31.4 L 70.287 31.4 L 71.292 36.6 L 73.805 36.6 L 74.811 31.4 L 76.57 31.4 L 77.575 36.6 L 80.088 36.6 L 81.094 31.4 L 82.853 31.4 L 83.858 36.6 L 86.372 36.6 L 87.377 31.4 L 89.136 31.4 L 90.142 36.6 L 92.655 36.6 L 93.66 31.4 L 95.419 31.4 L 96.425 36.6 L 98.938 36.6 L 99.943 31.4 L 101.703 31.4 L 102.708 36.6 L 105.221 36.6 L 106.227 31.4 L 107.986 31.4 L 108.991 36.6 L 111.504 36.6 L 112.51 31.4 L 114.269 31.4 L 115.274 36.6 L 117.788 36.6 L 118.793 31.4 L 120.552 31.4 L 121.558 36.6 L 124.071 36.6 L 125.076 31.4 L 126.835 31.4 L 127.841 36.6 L 130.354 36.6 L 131.359 31.4 L 133.119 31.4 L 134.124 36.6 L 136.637 36.6 L 137.642 31.4 L 139.402 31.4 L 140.407 36.6 L 142.92 36.6 L 143.926 31.4 L 145.685 31.4 L 146.69 36.6 L 149.204 36.6 L 150.209 31.4 L 151.968 31.4 L 152.973 36.6 L 155.487 36.6 L 156.492 31.4 L 158.251 31.4 L 159.257 36.6 L 161.77 36.6 L 162.775 31.4 L 164.535 31.4 L 165.54 36.6 L 168.053 36.6 L 169.058 31.4 L 170.818 31.4 L 171.823 36.6 L 174.336 36.6 L 175.342 31.4 L 177.101 31.4 L 178.106 36.6 L 180.619 36.6 L 181.625 31.4 L 183.384 31.4 L 184.389 36.6 L 186.903 36.6 L 187.908 31.4 L 189.667 31.4 L 190.673 36.6 L 193.186 36.6 L 194.191 31.4 L 195.95 31.4 L 196.956 36.6 L 199.469 36.6 L 200.474 31.4 L 202.234 31.4 L 203.239 36.6 L 205.752 36.6 L 206.758 31.4 L 208.517 31.4 L 209.522 36.6 L 212.035 36.6 L 213.041 31.4 L 214.8 31.4 L 215.805 36.6 L 218.319 36.6 L 219.324 31.4 L 221.083 31.4 L 222.088 36.6 L 224.602 36.6 L 225.607 31.4 L 227.366 31.4 L 228.372 36.6 L 230.885 36.6 L 231.89 31.4 L 233.65 31.4 L 234.655 36.6 L 236 36.6 L 236 43 L 4 43 Z" clip-path="url(#slider-pinion-left)"></path>
<g class="slider-pinion__wheel" transform="translate(99.2 22) rotate(397.251)">
<path class="slider-pinion__gear" d="M 9.28 -1.47 L 14.56 -1.07 L 14.56 1.07 L 9.28 1.47 L 8.78 3.37 L 13.14 6.35 L 12.08 8.21 L 7.31 5.92 L 5.92 7.31 L 8.21 12.08 L 6.35 13.14 L 3.37 8.78 L 1.47 9.28 L 1.07 14.56 L -1.07 14.56 L -1.47 9.28 L -3.37 8.78 L -6.35 13.14 L -8.21 12.08 L -5.92 7.31 L -7.31 5.92 L -12.08 8.21 L -13.14 6.35 L -8.78 3.37 L -9.28 1.47 L -14.56 1.07 L -14.56 -1.07 L -9.28 -1.47 L -8.78 -3.37 L -13.14 -6.35 L -12.08 -8.21 L -7.31 -5.92 L -5.92 -7.31 L -8.21 -12.08 L -6.35 -13.14 L -3.37 -8.78 L -1.47 -9.28 L -1.07 -14.56 L 1.07 -14.56 L 1.47 -9.28 L 3.37 -8.78 L 6.35 -13.14 L 8.21 -12.08 L 5.92 -7.31 L 7.31 -5.92 L 12.08 -8.21 L 13.14 -6.35 L 8.78 -3.37 Z"></path>
<circle class="slider-pinion__hole" r="3.2"></circle>
<circle class="slider-pinion__hole" cx="4.525" cy="4.525" r="1.8"></circle>
<circle class="slider-pinion__hole" cx="-4.525" cy="4.525" r="1.8"></circle>
<circle class="slider-pinion__hole" cx="-4.525" cy="-4.525" r="1.8"></circle>
<circle class="slider-pinion__hole" cx="4.525" cy="-4.525" r="1.8"></circle>
</g>
<circle class="slider-pinion__ring" cx="99.2" cy="22" r="18"></circle>
</svg>
<p class="slider-pinion__value" aria-hidden="true">
<span>Travel</span>
<b>40 mm</b>
</p>
<input class="slider-pinion__input" type="range" min="0" max="100" aria-label="Travel" aria-valuetext="40 millimetres" value="40"/>
</div>.slider-pinion { position: relative; width: 240px; height: 72px; }
.slider-pinion__svg { position: absolute; left: 0; top: 0; width: 240px; height: 50px; overflow: visible; }
.slider-pinion__rack--done { fill: light-dark(#50535b, #c6c8cd); }
.slider-pinion__rack--left { fill: light-dark(#cbced3, #393c41); }
.slider-pinion__gear { fill: light-dark(#373a40, #f1f2f3); }
.slider-pinion__hole { fill: light-dark(#fbfcfd, #121417); }
.slider-pinion__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-pinion:has(.slider-pinion__input:focus-visible) .slider-pinion__ring { stroke: light-dark(#50535b, #c6c8cd); }
.slider-pinion__value { position: absolute; left: 0; right: 0; top: 54px; display: flex; justify-content: space-between; margin: 0; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-pinion__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-pinion__input { position: absolute; left: 2px; top: 0; width: 236px; height: 50px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-pinion__input::-webkit-slider-thumb { -webkit-appearance: none; width: 28px; height: 50px; }
.slider-pinion__input::-moz-range-thumb { width: 28px; height: 50px; border: 0; }function initSliderPinion(root) {
var R = 12;
var C = 22;
var X0 = 16;
var X1 = 224;
var input = root.querySelector('.slider-pinion__input');
var wheel = root.querySelector('.slider-pinion__wheel');
var ring = root.querySelector('.slider-pinion__ring');
var done = root.querySelector('#slider-pinion-done rect');
var left = root.querySelector('#slider-pinion-left rect');
var label = root.querySelector('.slider-pinion__value b');
var x = at(+input.value);
var v = 0;
var raf = 0;
function at(n) { return X0 + (n / 100) * (X1 - X0); }
// Rolling without slip: the gear turns by the distance travelled over its pitch radius.
function draw() {
wheel.setAttribute('transform', 'translate(' + x + ' ' + C + ') rotate(' + (((x - X0) / R) * 180) / Math.PI + ')');
ring.setAttribute('cx', x);
done.setAttribute('width', x);
left.setAttribute('x', x);
}
function run() {
cancelAnimationFrame(raf);
var goal = at(+input.value);
label.textContent = input.value + ' mm';
input.setAttribute('aria-valuetext', input.value + ' millimetres');
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
x = goal;
return draw();
}
var last = performance.now();
function step(now) {
var dt = Math.min(0.032, (now - last) / 1000);
last = now;
v += (-220 * (x - goal) - 26 * v) * dt;
x += v * dt;
if (Math.abs(x - goal) < 0.05 && Math.abs(v) < 0.5) {
x = goal;
v = 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-pinion').forEach(initSliderPinion);Goal: Create a "Pinion" slider.
Visual behavior: A rack and pinion: the track is a toothed rack and the thumb a gear that turns as it rolls along, its teeth meshing with the rack, the travelled length inked.
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 gear has 12 teeth on a 12px pitch radius, so its circular pitch is 2 pi r over 12; the rack teeth are spaced at the same pitch with a gap where a gear tooth points down at the start. The gear rotates by the distance travelled over the pitch radius, in degrees, so it rolls without slipping and stays meshed.
- The drawn position follows the input on a damped spring in requestAnimationFrame, so a click far along the track or a Page Up travels there rather than jumping. The rack is drawn twice and clipped at the gear: inked behind it, pale ahead.
Accessibility: A native range input with an aria-label and an aria-valuetext in millimetres; keyboard focus rings the gear. Under prefers-reduced-motion the gear 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 "Pinion" slider.
Visual behavior: A rack and pinion: the track is a toothed rack and the thumb a gear that turns as it rolls along, its teeth meshing with the rack, the travelled length inked.
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 gear has 12 teeth on a 12px pitch radius, so its circular pitch is 2 pi r over 12; the rack teeth are spaced at the same pitch with a gap where a gear tooth points down at the start. The gear rotates by the distance travelled over the pitch radius, in degrees, so it rolls without slipping and stays meshed.
- The drawn position follows the input on a damped spring in requestAnimationFrame, so a click far along the track or a Page Up travels there rather than jumping. The rack is drawn twice and clipped at the gear: inked behind it, pale ahead.
Accessibility: A native range input with an aria-label and an aria-valuetext in millimetres; keyboard focus rings the gear. Under prefers-reduced-motion the gear 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 rack and pinion: the track is a toothed rack and the thumb a gear that turns as it rolls along, its teeth meshing with the rack, the travelled length inked.
- Dependencies
- None
- Difficulty
- Advanced
- 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 millimetres; keyboard focus rings the gear. Under prefers-reduced-motion the gear moves without the spring.