Comb
import { useRef, useState } from 'react';
const W = 240;
const TICKS = 41;
const PITCH = W / (TICKS - 1);
const BASE = 60;
const css = `
.slider-comb { position: relative; width: ${W}px; height: 68px; }
.slider-comb__tick {
position: absolute;
bottom: ${68 - BASE}px;
width: 2px;
border-radius: 1px;
transform-origin: 50% 100%;
transition: height 0.32s cubic-bezier(0.3, 1.35, 0.5, 1), transform 0.5s cubic-bezier(0.3, 1.6, 0.5, 1), background-color 0.2s;
}
.slider-comb__value {
position: absolute;
left: 0;
padding: 1px 5px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: light-dark(#42454c, #e5e6e9);
transition: bottom 0.32s cubic-bezier(0.3, 1.35, 0.5, 1);
}
.slider-comb:has(.slider-comb__input:focus-visible) .slider-comb__value { outline: 2px solid light-dark(#50535b, #c6c8cd); }
.slider-comb__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-comb__input::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 68px; }
.slider-comb__input::-moz-range-thumb { width: 12px; height: 68px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-comb__tick, .slider-comb__value { transition: none; }
}
`;
export function CombSlider() {
const [v, setV] = useState(50);
const [held, setHeld] = useState(false);
const [lean, setLean] = useState(0);
const settle = useRef<ReturnType<typeof setTimeout>>(undefined);
const t = 6 + (v / 100) * (W - 12);
const peak = held ? 30 : 22;
const spread = held ? 18 : 13;
return (
<>
<style>
{css}
</style>
<div className="slider-comb">
{Array.from({ length: TICKS }, (_, i) => {
const x = i * PITCH;
// A bell of ticks around the thumb, like a magnified dock.
const g = Math.exp(-(((x - t) / spread) ** 2));
return (
<span
key={i}
className="slider-comb__tick"
aria-hidden="true"
style={{
left: x - 1,
height: 8 + peak * g,
background: x <= t ? 'light-dark(#373a40, #f1f2f3)' : 'light-dark(#cbced3, #393c41)',
transform: `rotate(${(lean * g).toFixed(2)}deg)`,
}}
/>
);
})}
<span
className="slider-comb__value"
aria-hidden="true"
style={{ bottom: 68 - BASE + 8 + peak + 6, transform: `translateX(calc(${t}px - 50%))` }}
>
{v}
</span>
<input
className="slider-comb__input"
type="range"
min={0}
max={100}
value={v}
onChange={(e) => {
const next = +e.target.value;
// Ticks near the thumb lean the way you drag, harder the faster you go, then stand back up.
if (!matchMedia('(prefers-reduced-motion: reduce)').matches) setLean(Math.max(-22, Math.min(22, (next - v) * 3)));
setV(next);
clearTimeout(settle.current);
settle.current = setTimeout(() => setLean(0), 110);
}}
onPointerDown={() => {
setHeld(true);
const up = () => {
setHeld(false);
removeEventListener('pointerup', up);
removeEventListener('pointercancel', up);
};
addEventListener('pointerup', up);
addEventListener('pointercancel', up);
}}
aria-label="Level"
/>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderComb(el) on the mounted element instead.
<div class="slider-comb">
<span class="slider-comb__tick" aria-hidden="true" style="left:-1px;height:8px;background:light-dark(#373a40, #f1f2f3);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:5px;height:8px;background:light-dark(#373a40, #f1f2f3);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:11px;height:8px;background:light-dark(#373a40, #f1f2f3);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:17px;height:8px;background:light-dark(#373a40, #f1f2f3);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:23px;height:8px;background:light-dark(#373a40, #f1f2f3);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:29px;height:8px;background:light-dark(#373a40, #f1f2f3);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:35px;height:8px;background:light-dark(#373a40, #f1f2f3);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:41px;height:8px;background:light-dark(#373a40, #f1f2f3);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:47px;height:8px;background:light-dark(#373a40, #f1f2f3);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:53px;height:8px;background:light-dark(#373a40, #f1f2f3);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:59px;height:8px;background:light-dark(#373a40, #f1f2f3);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:65px;height:8px;background:light-dark(#373a40, #f1f2f3);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:71px;height:8px;background:light-dark(#373a40, #f1f2f3);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:77px;height:8.001px;background:light-dark(#373a40, #f1f2f3);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:83px;height:8.01px;background:light-dark(#373a40, #f1f2f3);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:89px;height:8.107px;background:light-dark(#373a40, #f1f2f3);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:95px;height:8.728px;background:light-dark(#373a40, #f1f2f3);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:101px;height:11.235px;background:light-dark(#373a40, #f1f2f3);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:107px;height:17.384px;background:light-dark(#373a40, #f1f2f3);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:113px;height:25.779px;background:light-dark(#373a40, #f1f2f3);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:119px;height:30px;background:light-dark(#373a40, #f1f2f3);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:125px;height:25.779px;background:light-dark(#cbced3, #393c41);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:131px;height:17.384px;background:light-dark(#cbced3, #393c41);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:137px;height:11.235px;background:light-dark(#cbced3, #393c41);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:143px;height:8.728px;background:light-dark(#cbced3, #393c41);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:149px;height:8.107px;background:light-dark(#cbced3, #393c41);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:155px;height:8.01px;background:light-dark(#cbced3, #393c41);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:161px;height:8.001px;background:light-dark(#cbced3, #393c41);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:167px;height:8px;background:light-dark(#cbced3, #393c41);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:173px;height:8px;background:light-dark(#cbced3, #393c41);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:179px;height:8px;background:light-dark(#cbced3, #393c41);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:185px;height:8px;background:light-dark(#cbced3, #393c41);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:191px;height:8px;background:light-dark(#cbced3, #393c41);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:197px;height:8px;background:light-dark(#cbced3, #393c41);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:203px;height:8px;background:light-dark(#cbced3, #393c41);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:209px;height:8px;background:light-dark(#cbced3, #393c41);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:215px;height:8px;background:light-dark(#cbced3, #393c41);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:221px;height:8px;background:light-dark(#cbced3, #393c41);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:227px;height:8px;background:light-dark(#cbced3, #393c41);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:233px;height:8px;background:light-dark(#cbced3, #393c41);transform:rotate(0.00deg)"></span>
<span class="slider-comb__tick" aria-hidden="true" style="left:239px;height:8px;background:light-dark(#cbced3, #393c41);transform:rotate(0.00deg)"></span>
<span class="slider-comb__value" aria-hidden="true" style="bottom:44px;transform:translateX(calc(120px - 50%))">50</span>
<input class="slider-comb__input" type="range" min="0" max="100" aria-label="Level" value="50"/>
</div>.slider-comb { position: relative; width: 240px; height: 68px; }
.slider-comb__tick {
position: absolute;
bottom: 8px;
width: 2px;
border-radius: 1px;
transform-origin: 50% 100%;
transition: height 0.32s cubic-bezier(0.3, 1.35, 0.5, 1), transform 0.5s cubic-bezier(0.3, 1.6, 0.5, 1), background-color 0.2s;
}
.slider-comb__value {
position: absolute;
left: 0;
padding: 1px 5px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
font-variant-numeric: tabular-nums;
color: light-dark(#42454c, #e5e6e9);
transition: bottom 0.32s cubic-bezier(0.3, 1.35, 0.5, 1);
}
.slider-comb:has(.slider-comb__input:focus-visible) .slider-comb__value { outline: 2px solid light-dark(#50535b, #c6c8cd); }
.slider-comb__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-comb__input::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 68px; }
.slider-comb__input::-moz-range-thumb { width: 12px; height: 68px; border: 0; }
@media (prefers-reduced-motion: reduce) {
.slider-comb__tick, .slider-comb__value { transition: none; }
}function initSliderComb(root) {
var W = 240, BASE = 60;
var input = root.querySelector('.slider-comb__input');
var ticks = root.querySelectorAll('.slider-comb__tick');
var label = root.querySelector('.slider-comb__value');
var pitch = W / (ticks.length - 1);
var still = matchMedia('(prefers-reduced-motion: reduce)').matches;
var v = +input.value, held = false, lean = 0, settle;
function draw() {
var t = 6 + (v / 100) * (W - 12);
var peak = held ? 30 : 22;
var spread = held ? 18 : 13;
ticks.forEach(function (tick, i) {
var x = i * pitch;
// A bell of ticks around the thumb, like a magnified dock.
var g = Math.exp(-Math.pow((x - t) / spread, 2));
tick.style.height = 8 + peak * g + 'px';
tick.style.background = x <= t ? 'light-dark(#373a40, #f1f2f3)' : 'light-dark(#cbced3, #393c41)';
tick.style.transform = 'rotate(' + (lean * g).toFixed(2) + 'deg)';
});
label.textContent = v;
label.style.bottom = 68 - BASE + 8 + peak + 6 + 'px';
label.style.transform = 'translateX(calc(' + t + 'px - 50%))';
}
function update() {
var next = +input.value;
// Ticks near the thumb lean the way you drag, harder the faster you go, then stand back up.
if (!still) lean = Math.max(-22, Math.min(22, (next - v) * 3));
v = next;
draw();
clearTimeout(settle);
settle = setTimeout(function () {
lean = 0;
draw();
}, 110);
}
function up() {
held = false;
draw();
removeEventListener('pointerup', up);
removeEventListener('pointercancel', up);
}
function down() {
held = true;
draw();
addEventListener('pointerup', up);
addEventListener('pointercancel', up);
}
input.addEventListener('input', update);
input.addEventListener('pointerdown', down);
return function () {
clearTimeout(settle);
up();
input.removeEventListener('input', update);
input.removeEventListener('pointerdown', down);
};
}
document.querySelectorAll('.slider-comb').forEach(initSliderComb);Goal: Create a "Comb" slider.
Visual behavior: A slider made of fine ticks that rise around the thumb like a magnified dock, lean the way you drag, harder the faster you go, and stand back up when you stop.
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 tick's height is a Gaussian of its distance to the thumb, taller and wider while pressed, and ticks behind the thumb are bright. A springy transition on height makes the bell follow with a little give.
- Each input event sets a lean from how far the value jumped, scaled per tick by the same Gaussian; a timer clears it after 110ms and the ticks spring upright.
Accessibility: A native range input with an aria-label; keyboard focus rings the value above the peak. Under prefers-reduced-motion the ticks never lean.
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 "Comb" slider.
Visual behavior: A slider made of fine ticks that rise around the thumb like a magnified dock, lean the way you drag, harder the faster you go, and stand back up when you stop.
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 tick's height is a Gaussian of its distance to the thumb, taller and wider while pressed, and ticks behind the thumb are bright. A springy transition on height makes the bell follow with a little give.
- Each input event sets a lean from how far the value jumped, scaled per tick by the same Gaussian; a timer clears it after 110ms and the ticks spring upright.
Accessibility: A native range input with an aria-label; keyboard focus rings the value above the peak. Under prefers-reduced-motion the ticks never lean.
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 slider made of fine ticks that rise around the thumb like a magnified dock, lean the way you drag, harder the faster you go, and stand back up when you stop.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- state change, continuous, spring
- Techniques
- css transition, spring, transform only
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A native range input with an aria-label; keyboard focus rings the value above the peak. Under prefers-reduced-motion the ticks never lean.