Ratchet
import { useEffect, useId, useRef, useState } from 'react';
const W = 240;
const N = 20;
const P = 10.8;
const X0 = 12;
const BASE = 46;
const H = 6;
const at = (k: number) => X0 + k * P + 1.5;
// The tooth profile: a ramp rising to the right, then a sheer drop.
const surf = (x: number) => (x < X0 || x >= X0 + N * P ? BASE : BASE - (H * ((x - X0) % P)) / P);
const TEETH = (() => {
let d = `M 4 ${BASE} L ${X0} ${BASE}`;
for (let k = 0; k < N; k++) d += ` L ${X0 + (k + 1) * P} ${BASE - H} L ${X0 + (k + 1) * P} ${BASE}`;
return `${d} L ${W - 4} ${BASE} L ${W - 4} ${BASE + 7} L 4 ${BASE + 7} Z`;
})();
type Click = { id: number; x: number };
const css = `
.slider-ratchet { position: relative; width: ${W}px; height: 80px; }
.slider-ratchet__svg { position: absolute; left: 0; top: 0; width: ${W}px; height: 56px; overflow: visible; }
.slider-ratchet__teeth--done { fill: light-dark(#50535b, #c6c8cd); }
.slider-ratchet__teeth--left { fill: light-dark(#dbdde1, #2e3035); }
.slider-ratchet__block { fill: light-dark(#373a40, #f1f2f3); }
.slider-ratchet__pawl { stroke: light-dark(#373a40, #f1f2f3); stroke-width: 2.6; stroke-linecap: round; }
.slider-ratchet__pin { fill: light-dark(#fbfcfd, #121417); }
.slider-ratchet__click { stroke: light-dark(#373a40, #f1f2f3); stroke-width: 1.5; stroke-linecap: round; animation: slider-ratchet-click 0.3s ease-out forwards; }
@keyframes slider-ratchet-click {
from { opacity: 0.9; transform: scale(0.6); }
to { opacity: 0; transform: scale(1.4); }
}
.slider-ratchet__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-ratchet:has(.slider-ratchet__input:focus-visible) .slider-ratchet__ring { stroke: light-dark(#50535b, #c6c8cd); }
.slider-ratchet__value { position: absolute; left: 0; right: 0; top: 62px; display: flex; justify-content: space-between; margin: 0; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-ratchet__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-ratchet__input { position: absolute; left: ${at(0) - 10}px; top: 0; width: ${at(N) - at(0) + 20}px; height: 56px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-ratchet__input::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 56px; }
.slider-ratchet__input::-moz-range-thumb { width: 20px; height: 56px; border: 0; }
`;
export function RatchetSlider() {
const [k, setK] = useState(12);
const [pos, setPos] = useState({ x: at(12), tip: surf(at(12)) });
const [clicks, setClicks] = useState<Click[]>([]);
const id = useId().replace(/[^a-zA-Z0-9]/g, '');
const st = useRef({ x: at(12), v: 0, tip: surf(at(12)), vy: 0, raf: 0, n: 0 });
// The block slides on a spring; the pawl tip falls under gravity but can never sink into a tooth,
// so it rides up each ramp and drops off the far edge with a bounce.
useEffect(() => {
const q = st.current;
const goal = at(k);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
q.x = goal;
q.tip = surf(goal);
setPos({ x: q.x, tip: q.tip });
return;
}
let last = performance.now();
const step = (now: number) => {
const dt = Math.min(0.02, (now - last) / 1000);
last = now;
q.v += (-260 * (q.x - goal) - 30 * q.v) * dt;
q.x += q.v * dt;
const floor = surf(q.x);
q.vy += 2600 * dt;
q.tip += q.vy * dt;
if (q.tip >= floor) {
if (q.vy > 140) {
const c = { id: ++q.n, x: q.x };
setClicks((cs) => [...cs.slice(-4), c]);
}
q.tip = floor;
q.vy = q.vy > 140 ? -q.vy * 0.22 : 0;
}
setPos({ x: q.x, tip: q.tip });
const done = Math.abs(q.x - goal) < 0.05 && Math.abs(q.v) < 0.5 && q.tip === floor && q.vy === 0;
if (done) return;
q.raf = requestAnimationFrame(step);
};
cancelAnimationFrame(q.raf);
q.raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(q.raf);
}, [k]);
const { x, tip } = pos;
return (
<>
<style>
{css}
</style>
<div className="slider-ratchet">
<svg className="slider-ratchet__svg" aria-hidden="true">
<defs>
<clipPath id={`${id}l`}>
<rect x={0} y={0} width={x} height={60} />
</clipPath>
<clipPath id={`${id}r`}>
<rect x={x} y={0} width={W} height={60} />
</clipPath>
</defs>
<path className="slider-ratchet__teeth--done" d={TEETH} clipPath={`url(#${id}l)`} />
<path className="slider-ratchet__teeth--left" d={TEETH} clipPath={`url(#${id}r)`} />
{clicks.map((c) => (
<line
key={c.id}
className="slider-ratchet__click"
x1={c.x}
y1={BASE - 9}
x2={c.x}
y2={BASE - 14}
style={{ transformOrigin: `${c.x}px ${BASE}px` }}
onAnimationEnd={() => setClicks((cs) => cs.filter((o) => o.id !== c.id))}
/>
))}
<rect className="slider-ratchet__block" x={x - 16} y={16} width={22} height={13} rx={3} />
<line className="slider-ratchet__pawl" x1={x - 9} y1={27} x2={x} y2={tip - 1.3} />
<circle className="slider-ratchet__pin" cx={x - 9} cy={25} r={1.6} />
<rect className="slider-ratchet__ring" x={x - 20} y={12} width={30} height={BASE - 6} rx={6} />
</svg>
<p className="slider-ratchet__value" aria-hidden="true">
<span>Clicks</span>
<b>
{k} / {N}
</b>
</p>
<input
className="slider-ratchet__input"
type="range"
min={0}
max={N}
value={k}
onChange={(e) => setK(+e.target.value)}
aria-label="Clicks"
aria-valuetext={`${k} of ${N}`}
/>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderRatchet(el) on the mounted element instead.
<div class="slider-ratchet">
<svg class="slider-ratchet__svg" aria-hidden="true">
<defs>
<clipPath id="slider-ratchet-done">
<rect x="0" y="0" width="143.1" height="60"></rect>
</clipPath>
<clipPath id="slider-ratchet-left">
<rect x="143.1" y="0" width="240" height="60"></rect>
</clipPath>
</defs>
<path class="slider-ratchet__teeth--done" d="M 4 46 L 12 46 L 22.8 40 L 22.8 46 L 33.6 40 L 33.6 46 L 44.4 40 L 44.4 46 L 55.2 40 L 55.2 46 L 66 40 L 66 46 L 76.8 40 L 76.8 46 L 87.6 40 L 87.6 46 L 98.4 40 L 98.4 46 L 109.2 40 L 109.2 46 L 120 40 L 120 46 L 130.8 40 L 130.8 46 L 141.6 40 L 141.6 46 L 152.4 40 L 152.4 46 L 163.2 40 L 163.2 46 L 174 40 L 174 46 L 184.8 40 L 184.8 46 L 195.6 40 L 195.6 46 L 206.4 40 L 206.4 46 L 217.2 40 L 217.2 46 L 228 40 L 228 46 L 236 46 L 236 53 L 4 53 Z" clip-path="url(#slider-ratchet-done)"></path>
<path class="slider-ratchet__teeth--left" d="M 4 46 L 12 46 L 22.8 40 L 22.8 46 L 33.6 40 L 33.6 46 L 44.4 40 L 44.4 46 L 55.2 40 L 55.2 46 L 66 40 L 66 46 L 76.8 40 L 76.8 46 L 87.6 40 L 87.6 46 L 98.4 40 L 98.4 46 L 109.2 40 L 109.2 46 L 120 40 L 120 46 L 130.8 40 L 130.8 46 L 141.6 40 L 141.6 46 L 152.4 40 L 152.4 46 L 163.2 40 L 163.2 46 L 174 40 L 174 46 L 184.8 40 L 184.8 46 L 195.6 40 L 195.6 46 L 206.4 40 L 206.4 46 L 217.2 40 L 217.2 46 L 228 40 L 228 46 L 236 46 L 236 53 L 4 53 Z" clip-path="url(#slider-ratchet-left)"></path>
<rect class="slider-ratchet__block" x="127.1" y="16" width="22" height="13" rx="3"></rect>
<line class="slider-ratchet__pawl" x1="134.1" y1="27" x2="143.1" y2="43.867"></line>
<circle class="slider-ratchet__pin" cx="134.1" cy="25" r="1.6"></circle>
<rect class="slider-ratchet__ring" x="123.1" y="12" width="30" height="40" rx="6"></rect>
</svg>
<p class="slider-ratchet__value" aria-hidden="true">
<span>Clicks</span>
<b>12 / 20</b>
</p>
<input class="slider-ratchet__input" type="range" min="0" max="20" aria-label="Clicks" aria-valuetext="12 of 20" value="12"/>
</div>.slider-ratchet { position: relative; width: 240px; height: 80px; }
.slider-ratchet__svg { position: absolute; left: 0; top: 0; width: 240px; height: 56px; overflow: visible; }
.slider-ratchet__teeth--done { fill: light-dark(#50535b, #c6c8cd); }
.slider-ratchet__teeth--left { fill: light-dark(#dbdde1, #2e3035); }
.slider-ratchet__block { fill: light-dark(#373a40, #f1f2f3); }
.slider-ratchet__pawl { stroke: light-dark(#373a40, #f1f2f3); stroke-width: 2.6; stroke-linecap: round; }
.slider-ratchet__pin { fill: light-dark(#fbfcfd, #121417); }
.slider-ratchet__click { stroke: light-dark(#373a40, #f1f2f3); stroke-width: 1.5; stroke-linecap: round; animation: slider-ratchet-click 0.3s ease-out forwards; }
@keyframes slider-ratchet-click {
from { opacity: 0.9; transform: scale(0.6); }
to { opacity: 0; transform: scale(1.4); }
}
.slider-ratchet__ring { fill: none; stroke: transparent; stroke-width: 2; }
.slider-ratchet:has(.slider-ratchet__input:focus-visible) .slider-ratchet__ring { stroke: light-dark(#50535b, #c6c8cd); }
.slider-ratchet__value { position: absolute; left: 0; right: 0; top: 62px; display: flex; justify-content: space-between; margin: 0; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-ratchet__value b { color: light-dark(#373a40, #f1f2f3); font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-ratchet__input { position: absolute; left: 3.5px; top: 0; width: 236px; height: 56px; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-ratchet__input::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 56px; }
.slider-ratchet__input::-moz-range-thumb { width: 20px; height: 56px; border: 0; }function initSliderRatchet(root) {
var N = 20;
var P = 10.8;
var X0 = 12;
var BASE = 46;
var H = 6;
var NS = 'http://www.w3.org/2000/svg';
var input = root.querySelector('.slider-ratchet__input');
var svg = root.querySelector('.slider-ratchet__svg');
var done = root.querySelector('#slider-ratchet-done rect');
var left = root.querySelector('#slider-ratchet-left rect');
var block = root.querySelector('.slider-ratchet__block');
var pawl = root.querySelector('.slider-ratchet__pawl');
var pin = root.querySelector('.slider-ratchet__pin');
var ring = root.querySelector('.slider-ratchet__ring');
var label = root.querySelector('.slider-ratchet__value b');
var x = at(+input.value);
var v = 0;
var tip = surf(x);
var vy = 0;
var raf = 0;
function at(k) { return X0 + k * P + 1.5; }
// The tooth profile: a ramp rising to the right, then a sheer drop.
function surf(px) { return px < X0 || px >= X0 + N * P ? BASE : BASE - (H * ((px - X0) % P)) / P; }
function draw() {
done.setAttribute('width', x);
left.setAttribute('x', x);
block.setAttribute('x', x - 16);
pawl.setAttribute('x1', x - 9);
pawl.setAttribute('x2', x);
pawl.setAttribute('y2', tip - 1.3);
pin.setAttribute('cx', x - 9);
ring.setAttribute('x', x - 20);
}
// A click: a short tick flashes up from the tooth the pawl just dropped onto.
function click() {
var c = document.createElementNS(NS, 'line');
c.setAttribute('class', 'slider-ratchet__click');
c.setAttribute('x1', x);
c.setAttribute('x2', x);
c.setAttribute('y1', BASE - 9);
c.setAttribute('y2', BASE - 14);
c.style.transformOrigin = x + 'px ' + BASE + 'px';
c.addEventListener('animationend', function () { c.remove(); });
svg.insertBefore(c, block);
}
// The block slides on a spring; the pawl tip falls under gravity but can never sink into a tooth,
// so it rides up each ramp and drops off the far edge with a bounce.
function run() {
cancelAnimationFrame(raf);
var goal = at(+input.value);
label.textContent = input.value + ' / ' + N;
input.setAttribute('aria-valuetext', input.value + ' of ' + N);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
x = goal;
tip = surf(goal);
return draw();
}
var last = performance.now();
function step(now) {
var dt = Math.min(0.02, (now - last) / 1000);
last = now;
v += (-260 * (x - goal) - 30 * v) * dt;
x += v * dt;
var floor = surf(x);
vy += 2600 * dt;
tip += vy * dt;
if (tip >= floor) {
if (vy > 140) click();
tip = floor;
vy = vy > 140 ? -vy * 0.22 : 0;
}
draw();
if (Math.abs(x - goal) < 0.05 && Math.abs(v) < 0.5 && tip === floor && vy === 0) return;
raf = requestAnimationFrame(step);
}
raf = requestAnimationFrame(step);
}
input.addEventListener('input', run);
return function () {
cancelAnimationFrame(raf);
input.removeEventListener('input', run);
};
}
document.querySelectorAll('.slider-ratchet').forEach(initSliderRatchet);Goal: Create a "Ratchet" slider.
Visual behavior: The thumb carries a pawl that rides over teeth cut in the track: it lifts up each ramp, drops off the edge into the next tooth and lands with a small click.
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 track is a sawtooth: each tooth a ramp up to the right and a sheer drop. The block slides on a spring; the pawl tip falls under gravity but is never allowed below the tooth surface, so it is pushed up each ramp and falls off the edge, bouncing once.
- A hard landing flashes a short tick that scales out and fades. The teeth are drawn twice and clipped at the thumb, inked behind it.
Accessibility: A native range input with an aria-label and an aria-valuetext like 12 of 20; keyboard focus rings the block. Under prefers-reduced-motion the block moves straight to the tooth with no bounce or click.
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 "Ratchet" slider.
Visual behavior: The thumb carries a pawl that rides over teeth cut in the track: it lifts up each ramp, drops off the edge into the next tooth and lands with a small click.
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 track is a sawtooth: each tooth a ramp up to the right and a sheer drop. The block slides on a spring; the pawl tip falls under gravity but is never allowed below the tooth surface, so it is pushed up each ramp and falls off the edge, bouncing once.
- A hard landing flashes a short tick that scales out and fades. The teeth are drawn twice and clipped at the thumb, inked behind it.
Accessibility: A native range input with an aria-label and an aria-valuetext like 12 of 20; keyboard focus rings the block. Under prefers-reduced-motion the block moves straight to the tooth with no bounce or click.
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
The thumb carries a pawl that rides over teeth cut in the track: it lifts up each ramp, drops off the edge into the next tooth and lands with a small click.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- state change, spring
- Techniques
- svg, spring, raf, keyframes
- 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 like 12 of 20; keyboard focus rings the block. Under prefers-reduced-motion the block moves straight to the tooth with no bounce or click.