Thumbwheel
import { useRef, useState } from 'react';
const W = 240;
const WHEEL = 224;
const RIDGES = 40;
const STEP = 360 / RIDGES;
const PX = 14;
const MIN = -9;
const MAX = 9;
const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
// Thirds of a stop, the way a camera counts them.
const ev = (n: number) => (n === 0 ? '0' : `${n < 0 ? '−' : '+'}${(Math.abs(n) / 3).toFixed(1)}`);
const css = `
.slider-thumbwheel { position: relative; width: ${W}px; height: 84px; touch-action: none; cursor: ew-resize; outline: none; user-select: none; -webkit-user-select: none; }
.slider-thumbwheel__head { display: flex; justify-content: space-between; align-items: baseline; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-thumbwheel__value { color: light-dark(#373a40, #f1f2f3); font-size: 13px; font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-thumbwheel__slot {
position: absolute;
left: 0;
top: 26px;
width: ${W}px;
height: 36px;
border-radius: 10px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 1px 3px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.5)), inset 0 0 0 1px light-dark(#ebedf0, #232529);
}
.slider-thumbwheel__wheel {
position: absolute;
left: ${(W - WHEEL) / 2}px;
top: 5px;
width: ${WHEEL}px;
height: 26px;
border-radius: 6px;
overflow: hidden;
background: light-dark(#ebedf0, #232529);
/* The cylinder darkens as it turns away at both ends. */
box-shadow: inset 34px 0 18px -14px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.55)), inset -34px 0 18px -14px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.55));
}
.slider-thumbwheel__wheel::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(180deg, transparent 15%, light-dark(color-mix(in oklab, light-dark(#fbfcfd, #121417) 55%, transparent), color-mix(in oklab, light-dark(#373a40, #f1f2f3) 9%, transparent)) 35%, transparent 60%);
pointer-events: none;
}
.slider-thumbwheel__svg { display: block; }
.slider-thumbwheel__notch { position: absolute; left: ${W / 2 - 4}px; top: 22px; border: 4px solid transparent; border-top-color: light-dark(#373a40, #f1f2f3); border-bottom-width: 0; }
.slider-thumbwheel:focus-visible .slider-thumbwheel__slot { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.slider-thumbwheel__range { position: absolute; left: ${(W - WHEEL) / 2}px; top: 74px; width: ${WHEEL}px; height: 1px; background: light-dark(#dbdde1, #2e3035); }
.slider-thumbwheel__zero { position: absolute; left: 50%; top: -2px; width: 1px; height: 5px; background: light-dark(#6d7179, #7d8087); }
.slider-thumbwheel__at {
position: absolute;
top: -2px;
left: var(--slider-thumbwheel-at);
width: 5px;
height: 5px;
margin-left: -2.5px;
border-radius: 50%;
background: light-dark(#373a40, #f1f2f3);
}
`;
export function ThumbwheelSlider() {
const [pos, setPos] = useState(2);
const drag = useRef<{ x: number; from: number } | null>(null);
const raf = useRef(0);
// The stop it is resting on or settling into; while dragging, the stop under the notch.
const goal = useRef(2);
const value = drag.current ? clamp(Math.round(pos), MIN, MAX) : goal.current;
// Lets go into the nearest click stop, the way a detented wheel does.
const settle = (to: number) => {
cancelAnimationFrame(raf.current);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return setPos(to);
let x = pos;
let v = 0;
let last = performance.now();
const step = (now: number) => {
const dt = Math.min(0.032, (now - last) / 1000);
last = now;
v += (-500 * (x - to) - 32 * v) * dt;
x += v * dt;
if (Math.abs(x - to) < 0.002 && Math.abs(v) < 0.01) return setPos(to);
setPos(x);
raf.current = requestAnimationFrame(step);
};
raf.current = requestAnimationFrame(step);
};
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.button !== 0) return;
cancelAnimationFrame(raf.current);
e.currentTarget.setPointerCapture(e.pointerId);
drag.current = { x: e.clientX, from: pos };
};
const onPointerMove = (e: React.PointerEvent) => {
const d = drag.current;
if (!d) return;
// The surface moves with your finger: drag right and the wheel turns up through the stops.
setPos(clamp(d.from + (e.clientX - d.x) / PX, MIN - 0.4, MAX + 0.4));
};
const onPointerUp = () => {
if (!drag.current) return;
drag.current = null;
goal.current = value;
settle(value);
};
const onKeyDown = (e: React.KeyboardEvent) => {
const next = { ArrowRight: value + 1, ArrowUp: value + 1, ArrowLeft: value - 1, ArrowDown: value - 1, PageUp: value + 3, PageDown: value - 3, Home: MIN, End: MAX }[e.key];
if (next === undefined) return;
e.preventDefault();
goal.current = clamp(next, MIN, MAX);
settle(goal.current);
};
const phi = pos * STEP;
const ridges = Array.from({ length: RIDGES }, (_, i) => {
const a = ((((i * STEP + phi) % 360) + 540) % 360) - 180;
if (Math.abs(a) > 86) return null;
const c = Math.cos((a * Math.PI) / 180);
const w = 0.4 + 2.6 * c;
const x = WHEEL / 2 + (WHEEL / 2) * Math.sin((a * Math.PI) / 180);
return <rect key={i} x={x - w / 2} y={2} width={w} height={22} rx={w / 2} style={{ fill: `color-mix(in oklab, light-dark(#50535b, #c6c8cd) ${Math.round(c * 85)}%, light-dark(#dbdde1, #2e3035))` }} />;
});
return (
<>
<style>
{css}
</style>
<div
className="slider-thumbwheel"
role="slider"
tabIndex={0}
aria-label="Exposure"
aria-valuemin={MIN}
aria-valuemax={MAX}
aria-valuenow={value}
aria-valuetext={`${ev(value)} EV`}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={onPointerUp}
onPointerCancel={onPointerUp}
onKeyDown={onKeyDown}
style={{ '--slider-thumbwheel-at': `${((value - MIN) / (MAX - MIN)) * 100}%` } as React.CSSProperties}
>
<div className="slider-thumbwheel__head" aria-hidden="true">
<span>Exposure</span>
<span className="slider-thumbwheel__value">{ev(value)} EV</span>
</div>
<div className="slider-thumbwheel__slot" aria-hidden="true">
<div className="slider-thumbwheel__wheel">
<svg className="slider-thumbwheel__svg" width={WHEEL} height={26}>
{ridges}
</svg>
</div>
</div>
<span className="slider-thumbwheel__notch" aria-hidden="true" />
<span className="slider-thumbwheel__range" aria-hidden="true">
<span className="slider-thumbwheel__zero" />
<span className="slider-thumbwheel__at" />
</span>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderThumbwheel(el) on the mounted element instead.
<div class="slider-thumbwheel" role="slider" tabindex="0" aria-label="Exposure" aria-valuemin="-9" aria-valuemax="9" aria-valuenow="2" aria-valuetext="+0.7 EV" style="--slider-thumbwheel-at:61.111%">
<div class="slider-thumbwheel__head" aria-hidden="true">
<span>Exposure</span>
<span class="slider-thumbwheel__value">+0.7 EV</span>
</div>
<div class="slider-thumbwheel__slot" aria-hidden="true">
<div class="slider-thumbwheel__wheel">
<svg class="slider-thumbwheel__svg" width="224" height="26">
<rect x="145.174" y="2" width="2.873" height="22" rx="1.436" style="fill:color-mix(in oklab, light-dark(#50535b, #c6c8cd) 81%, light-dark(#dbdde1, #2e3035))"></rect>
<rect x="161.489" y="2" width="2.717" height="22" rx="1.358" style="fill:color-mix(in oklab, light-dark(#50535b, #c6c8cd) 76%, light-dark(#dbdde1, #2e3035))"></rect>
<rect x="176.58" y="2" width="2.503" height="22" rx="1.252" style="fill:color-mix(in oklab, light-dark(#50535b, #c6c8cd) 69%, light-dark(#dbdde1, #2e3035))"></rect>
<rect x="190.077" y="2" width="2.238" height="22" rx="1.119" style="fill:color-mix(in oklab, light-dark(#50535b, #c6c8cd) 60%, light-dark(#dbdde1, #2e3035))"></rect>
<rect x="201.646" y="2" width="1.928" height="22" rx="0.964" style="fill:color-mix(in oklab, light-dark(#50535b, #c6c8cd) 50%, light-dark(#dbdde1, #2e3035))"></rect>
<rect x="211.003" y="2" width="1.58" height="22" rx="0.79" style="fill:color-mix(in oklab, light-dark(#50535b, #c6c8cd) 39%, light-dark(#dbdde1, #2e3035))"></rect>
<rect x="217.917" y="2" width="1.203" height="22" rx="0.602" style="fill:color-mix(in oklab, light-dark(#50535b, #c6c8cd) 26%, light-dark(#dbdde1, #2e3035))"></rect>
<rect x="222.218" y="2" width="0.807" height="22" rx="0.403" style="fill:color-mix(in oklab, light-dark(#50535b, #c6c8cd) 13%, light-dark(#dbdde1, #2e3035))"></rect>
<rect x="0.976" y="2" width="0.807" height="22" rx="0.403" style="fill:color-mix(in oklab, light-dark(#50535b, #c6c8cd) 13%, light-dark(#dbdde1, #2e3035))"></rect>
<rect x="4.88" y="2" width="1.203" height="22" rx="0.602" style="fill:color-mix(in oklab, light-dark(#50535b, #c6c8cd) 26%, light-dark(#dbdde1, #2e3035))"></rect>
<rect x="11.417" y="2" width="1.58" height="22" rx="0.79" style="fill:color-mix(in oklab, light-dark(#50535b, #c6c8cd) 39%, light-dark(#dbdde1, #2e3035))"></rect>
<rect x="20.426" y="2" width="1.928" height="22" rx="0.964" style="fill:color-mix(in oklab, light-dark(#50535b, #c6c8cd) 50%, light-dark(#dbdde1, #2e3035))"></rect>
<rect x="31.685" y="2" width="2.238" height="22" rx="1.119" style="fill:color-mix(in oklab, light-dark(#50535b, #c6c8cd) 60%, light-dark(#dbdde1, #2e3035))"></rect>
<rect x="44.916" y="2" width="2.503" height="22" rx="1.252" style="fill:color-mix(in oklab, light-dark(#50535b, #c6c8cd) 69%, light-dark(#dbdde1, #2e3035))"></rect>
<rect x="59.795" y="2" width="2.717" height="22" rx="1.358" style="fill:color-mix(in oklab, light-dark(#50535b, #c6c8cd) 76%, light-dark(#dbdde1, #2e3035))"></rect>
<rect x="75.954" y="2" width="2.873" height="22" rx="1.436" style="fill:color-mix(in oklab, light-dark(#50535b, #c6c8cd) 81%, light-dark(#dbdde1, #2e3035))"></rect>
<rect x="92.995" y="2" width="2.968" height="22" rx="1.484" style="fill:color-mix(in oklab, light-dark(#50535b, #c6c8cd) 84%, light-dark(#dbdde1, #2e3035))"></rect>
<rect x="110.5" y="2" width="3" height="22" rx="1.5" style="fill:color-mix(in oklab, light-dark(#50535b, #c6c8cd) 85%, light-dark(#dbdde1, #2e3035))"></rect>
<rect x="128.037" y="2" width="2.968" height="22" rx="1.484" style="fill:color-mix(in oklab, light-dark(#50535b, #c6c8cd) 84%, light-dark(#dbdde1, #2e3035))"></rect>
</svg>
</div>
</div>
<span class="slider-thumbwheel__notch" aria-hidden="true"></span>
<span class="slider-thumbwheel__range" aria-hidden="true">
<span class="slider-thumbwheel__zero"></span>
<span class="slider-thumbwheel__at"></span>
</span>
</div>.slider-thumbwheel { position: relative; width: 240px; height: 84px; touch-action: none; cursor: ew-resize; outline: none; user-select: none; -webkit-user-select: none; }
.slider-thumbwheel__head { display: flex; justify-content: space-between; align-items: baseline; font-size: 12px; color: light-dark(#5f626a, #a7abb2); }
.slider-thumbwheel__value { color: light-dark(#373a40, #f1f2f3); font-size: 13px; font-weight: 500; font-variant-numeric: tabular-nums; }
.slider-thumbwheel__slot {
position: absolute;
left: 0;
top: 26px;
width: 240px;
height: 36px;
border-radius: 10px;
background: light-dark(#f3f4f7, #1a1c1f);
box-shadow: inset 0 1px 3px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.5)), inset 0 0 0 1px light-dark(#ebedf0, #232529);
}
.slider-thumbwheel__wheel {
position: absolute;
left: 8px;
top: 5px;
width: 224px;
height: 26px;
border-radius: 6px;
overflow: hidden;
background: light-dark(#ebedf0, #232529);
/* The cylinder darkens as it turns away at both ends. */
box-shadow: inset 34px 0 18px -14px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.55)), inset -34px 0 18px -14px light-dark(rgba(0, 0, 0, 0.18), rgba(0, 0, 0, 0.55));
}
.slider-thumbwheel__wheel::after {
content: '';
position: absolute;
inset: 0;
background: linear-gradient(180deg, transparent 15%, light-dark(color-mix(in oklab, light-dark(#fbfcfd, #121417) 55%, transparent), color-mix(in oklab, light-dark(#373a40, #f1f2f3) 9%, transparent)) 35%, transparent 60%);
pointer-events: none;
}
.slider-thumbwheel__svg { display: block; }
.slider-thumbwheel__notch { position: absolute; left: 116px; top: 22px; border: 4px solid transparent; border-top-color: light-dark(#373a40, #f1f2f3); border-bottom-width: 0; }
.slider-thumbwheel:focus-visible .slider-thumbwheel__slot { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.slider-thumbwheel__range { position: absolute; left: 8px; top: 74px; width: 224px; height: 1px; background: light-dark(#dbdde1, #2e3035); }
.slider-thumbwheel__zero { position: absolute; left: 50%; top: -2px; width: 1px; height: 5px; background: light-dark(#6d7179, #7d8087); }
.slider-thumbwheel__at {
position: absolute;
top: -2px;
left: var(--slider-thumbwheel-at);
width: 5px;
height: 5px;
margin-left: -2.5px;
border-radius: 50%;
background: light-dark(#373a40, #f1f2f3);
}function initSliderThumbwheel(root) {
var WHEEL = 224;
var RIDGES = 40;
var STEP = 360 / RIDGES;
var PX = 14;
var MIN = -9;
var MAX = 9;
var svg = root.querySelector('.slider-thumbwheel__svg');
var label = root.querySelector('.slider-thumbwheel__value');
var pos = +root.getAttribute('aria-valuenow');
// The stop it is resting on or settling into; while dragging, the stop under the notch.
var goal = pos;
var drag = null;
var raf = 0;
function clamp(n, lo, hi) { return Math.min(hi, Math.max(lo, n)); }
function value() { return drag ? clamp(Math.round(pos), MIN, MAX) : goal; }
// Thirds of a stop, the way a camera counts them.
function ev(n) { return n === 0 ? '0' : (n < 0 ? '−' : '+') + (Math.abs(n) / 3).toFixed(1); }
function draw() {
var phi = pos * STEP;
var out = '';
for (var i = 0; i < RIDGES; i++) {
var a = ((((i * STEP + phi) % 360) + 540) % 360) - 180;
if (Math.abs(a) > 86) continue;
// Ridges crowd together and darken as the cylinder turns away at either end.
var c = Math.cos((a * Math.PI) / 180);
var w = 0.4 + 2.6 * c;
var x = WHEEL / 2 + (WHEEL / 2) * Math.sin((a * Math.PI) / 180);
out += '<rect x="' + (x - w / 2) + '" y="2" width="' + w + '" height="22" rx="' + w / 2 +
'" style="fill: color-mix(in oklab, light-dark(#50535b, #c6c8cd) ' + Math.round(c * 85) + '%, light-dark(#dbdde1, #2e3035))"></rect>';
}
svg.innerHTML = out;
var n = value();
label.textContent = ev(n) + ' EV';
root.setAttribute('aria-valuenow', n);
root.setAttribute('aria-valuetext', ev(n) + ' EV');
root.style.setProperty('--slider-thumbwheel-at', ((n - MIN) / (MAX - MIN)) * 100 + '%');
}
// Lets go into the nearest click stop, the way a detented wheel does.
function settle(to) {
cancelAnimationFrame(raf);
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
pos = to;
return draw();
}
var v = 0;
var last = performance.now();
function step(now) {
var dt = Math.min(0.032, (now - last) / 1000);
last = now;
v += (-500 * (pos - to) - 32 * v) * dt;
pos += v * dt;
if (Math.abs(pos - to) < 0.002 && Math.abs(v) < 0.01) {
pos = to;
return draw();
}
draw();
raf = requestAnimationFrame(step);
}
raf = requestAnimationFrame(step);
}
function down(e) {
if (e.button !== 0) return;
cancelAnimationFrame(raf);
root.setPointerCapture(e.pointerId);
drag = { x: e.clientX, from: pos };
}
function move(e) {
if (!drag) return;
// The surface moves with your finger: drag right and the wheel turns up through the stops.
pos = clamp(drag.from + (e.clientX - drag.x) / PX, MIN - 0.4, MAX + 0.4);
draw();
}
function up() {
if (!drag) return;
goal = value();
drag = null;
settle(goal);
}
function onKeyDown(e) {
var n = value();
var next = { ArrowRight: n + 1, ArrowUp: n + 1, ArrowLeft: n - 1, ArrowDown: n - 1, PageUp: n + 3, PageDown: n - 3, Home: MIN, End: MAX }[e.key];
if (next === undefined) return;
e.preventDefault();
goal = clamp(next, MIN, MAX);
settle(goal);
}
root.addEventListener('pointerdown', down);
root.addEventListener('pointermove', move);
root.addEventListener('pointerup', up);
root.addEventListener('pointercancel', up);
root.addEventListener('keydown', onKeyDown);
draw();
return function () {
cancelAnimationFrame(raf);
root.removeEventListener('pointerdown', down);
root.removeEventListener('pointermove', move);
root.removeEventListener('pointerup', up);
root.removeEventListener('pointercancel', up);
root.removeEventListener('keydown', onKeyDown);
};
}
document.querySelectorAll('.slider-thumbwheel').forEach(initSliderThumbwheel);Goal: Create a "Thumbwheel" slider.
Visual behavior: An exposure control set as a knurled roller in a slot: drag across it and the ridges roll past with real curvature, clicking into each third of a stop, with a fine line below showing where you are in the range.
Technique:
- The control is a focusable element with role="slider", aria-valuemin, aria-valuemax and aria-valuenow, driven by pointer events with pointer capture and by the arrow keys, Page Up/Down, Home and End. The drawing is aria-hidden.
- The wheel is forty ridges around a cylinder. Each frame, a ridge at angle a is drawn at the sine of a across the wheel, as wide as the cosine and brighter the more it faces you; inset shadows darken both ends and a soft band suggests the light.
- Dragging turns the wheel with your finger (14px per step) and releasing springs it into the nearest stop, the way a detented wheel does. Values are thirds of a stop from -3 to +3.
Accessibility: role="slider" with an aria-label, aria-valuenow in thirds and an aria-valuetext in EV; the arrow keys move one click, Page Up/Down a full stop, Home and End to either end. Keyboard focus rings the slot. Under prefers-reduced-motion the wheel steps 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 "Thumbwheel" slider.
Visual behavior: An exposure control set as a knurled roller in a slot: drag across it and the ridges roll past with real curvature, clicking into each third of a stop, with a fine line below showing where you are in the range.
Technique:
- The control is a focusable element with role="slider", aria-valuemin, aria-valuemax and aria-valuenow, driven by pointer events with pointer capture and by the arrow keys, Page Up/Down, Home and End. The drawing is aria-hidden.
- The wheel is forty ridges around a cylinder. Each frame, a ridge at angle a is drawn at the sine of a across the wheel, as wide as the cosine and brighter the more it faces you; inset shadows darken both ends and a soft band suggests the light.
- Dragging turns the wheel with your finger (14px per step) and releasing springs it into the nearest stop, the way a detented wheel does. Values are thirds of a stop from -3 to +3.
Accessibility: role="slider" with an aria-label, aria-valuenow in thirds and an aria-valuetext in EV; the arrow keys move one click, Page Up/Down a full stop, Home and End to either end. Keyboard focus rings the slot. Under prefers-reduced-motion the wheel steps 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
An exposure control set as a knurled roller in a slot: drag across it and the ridges roll past with real curvature, clicking into each third of a stop, with a fine line below showing where you are in the range.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- state change, 3d, spring
- Techniques
- svg, raf, spring
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
role="slider" with an aria-label, aria-valuenow in thirds and an aria-valuetext in EV; the arrow keys move one click, Page Up/Down a full stop, Home and End to either end. Keyboard focus rings the slot. Under prefers-reduced-motion the wheel steps without the spring.