GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

CSS Range Slider: 9 Physics-Grade Custom Sliders

A css range slider, explained: A velocity lerp decays your drag to rest, and a spring overshoots the thumb on each value change.

Auto-plays · click a tile to jump to its section · all nine in one zip

Nine builds follow, and every one of them keeps the native input[type=range] underneath — restyled with pseudo-elements, never swapped for a div — while a single computed layer sits on top of the plain --pct formula: inertia lerp, spring overshoot, snap restoring force, or color-temperature interpolation. The order below moves from the simplest computed layer (a label that just reads --pct) to the ones that touch keyboard input and prefers-reduced-motion directly, so each item builds on a formula the one before it already introduced.

Demo

All nine sliders repaint from the same one-line formula, pct = (value - min) / (max - min) * 100, written into the CSS custom property --pct and read back with calc(var(--pct) * 1%) for fill width and label position. Nothing below is a @keyframes animation — a requestAnimationFrame loop recomputes position, scale, or color every frame from whichever value the pointer, the keyboard, or the seeded autoplay last set, so dragging min or max never leaves a label stranded at an old pixel offset.

Anatomy of input[type=range] — track, thumb and fill, and where styling stops

The pseudo-elements you actually get

appearance: none strips the browser's own look without touching the input's behavior, and from there only four pseudo-elements are yours to draw: ::-webkit-slider-thumb and ::-moz-range-thumb for the handle, plus ::-webkit-slider-runnable-track and ::-moz-range-track for the groove it sits in — all nine css range slider examples in this post restyle exactly those four and nothing else.

Where the fill actually lives

The track pseudo-element can't be clipped to the value on its own, so every item that shows a filled span (01, 04, 05, 08) layers a separate element or a linear-gradient cut at var(--pct) behind the thumb instead of trying to make the track itself half-colored — the thumb and the fill both read the same --pct, so they can never drift apart even while a spring or a decay is still chasing the target.

Code per slider in nine steps

Paste any one item's prompt from the zip into an AI coding tool and it rebuilds the same screen — the block under each item below is the exact function that carries its computed layer, trimmed out of the full index.html that ships in the password-protected file.

01Value bubble — converting track position to a percentage the label follows

A label glued to left: 50% would drift the instant the thumb moves, so this one recomputes its position from the value on every single frame instead.

Percent stored in one CSS variable

paint() turns the current value into pct and writes it straight onto --pct, so the bubble's left: calc(var(--pct) * 1%) in the stylesheet never needs a second source of truth for where the thumb is.

Value bubble — converting track position to a percentage the label follows
Open in a new tab

01 · Value bubble — converting track position to a percentage the label follows — Measured area 6.988% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

pointermoveCSS custom propertiescalc

What the numbers say

Painted area comes out widest of the nine at 6.99% (peak frame 2.02%, average intensity 183.8), moving on 22 of 23 captured frames, because the bubble repaints on every step of the exponential-decay autoplay.

var MIN = 0, MAX = 100;
var TAU = 0.11; // exponential decay time constant (seconds) — how fast the label approaches its target

function paint(v) {
  var pct = (v - MIN) / (MAX - MIN) * 100;
  wrap.style.setProperty('--pct', pct.toFixed(2));
  valEl.textContent = String(Math.round(v));
  calcEl.textContent = pct.toFixed(1) + '%';
  range.setAttribute('aria-valuenow', String(Math.round(v)));
}

var t = elapsed / 1000;
var k = 1 - Math.exp(-t / TAU);
current = segFrom + (target - segFrom) * k;
range.value = String(Math.round(current));
paint(current);

02Spring overshoot — a damped oscillation that pops the thumb in on change

Every value change adds a kick to the thumb's scale velocity instead of jumping the size directly, which is what lets it pop past 1.0 and settle back.

Two coefficients, one damped oscillation

accel = -K * (scale - 1) - C * velocity runs inside the frame loop, so K alone sets how hard it overshoots and C alone sets how fast it stops ringing — a damping ratio near 0.54 here, good for roughly 12-14% overshoot.

Spring overshoot — a damped oscillation that pops the thumb in on change
Open in a new tab

02 · Spring overshoot — a damped oscillation that pops the thumb in on change — Measured area 6.238% · moving frames 22/23Original by GODRICH · SCSS and React ship in the zip · See the password

requestAnimationFrameCSS custom propertiestransform: scale

What the numbers say

Painted area sits at 6.24% (peak frame 1.78%, average intensity 134.6) across 22 of 23 moved frames, the second-widest of the set because the scale bounce redraws the thumb's shadow on every tick.

var K = 170, C = 14; // spring constant · damping coefficient (damping ratio ~0.54 — 12-14% overshoot)
var KICK = 9; // velocity impulse added to the thumb's scale on every value change

var pos = 50, scale = 1, vel = 0;

range.addEventListener('input', function () {
  pos = Number(range.value);
  vel += KICK;
  paint();
});

var acc = -K * (scale - 1) - C * vel;
vel += acc * dt;
scale += vel * dt;
paint();

03Drag inertia — tracking pointer velocity, then decaying it with an exponential glide

Releasing the thumb doesn't stop it — the velocity recorded during the drag keeps carrying it forward until friction eats it away.

A decay rate converted to a per-second value

Multiplying velocity by DECAY every frame would drift with frame rate, so the code raises DECAY to a per-second power first (DECAY_PER_SEC = Math.pow(DECAY, BASE_FPS)) and multiplies by that converted rate against real elapsed time instead.

Drag inertia — tracking pointer velocity, then decaying it with an exponential glide
Open in a new tab

03 · Drag inertia — tracking pointer velocity, then decaying it with an exponential glide — Measured area 1.847% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

requestAnimationFramepointermoveCSS custom properties

What the numbers say

Painted area is the smallest of the nine at 1.85% (peak frame 0.93%, average intensity 133.6), moving on only 19 of 23 frames because the glide sits still once velocity drops under the stop threshold.

var DECAY = 0.90; // "per-frame" decay factor (variant: 0.75) — baseline frame rate 12fps
var BASE_FPS = 12;
var DECAY_PER_SEC = Math.pow(DECAY, BASE_FPS); // convert to a per-second rate, independent of the real rAF rate
var STOP_THRESHOLD = 0.03;

function mulberry32(seed) {
  return function () {
    seed |= 0; seed = (seed + 0x6D2B79F5) | 0;
    var t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
    t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

pos += velocity * (dt / 1000);
velocity *= Math.pow(DECAY_PER_SEC, dt / 1000);

04Fill gloss — clipping the filled span to the value ratio and sweeping a highlight

A gloss band that always sat in the same spot would look painted on, so this one's position is tied to a second timer running independently of the value.

Two variables driving one gradient

--pct sets how much of the track the fill covers while --sweep slides the highlight inside it, and background-clip keeps the sweep confined to the fill so nothing leaks past the rounded ends when the value sits near 0 or 100.

Fill gloss — clipping the filled span to the value ratio and sweeping a highlight
Open in a new tab

04 · Fill gloss — clipping the filled span to the value ratio and sweeping a highlight — Measured area 4.78% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

linear-gradientbackground-clipCSS custom properties

What the numbers say

Painted area lands at 4.78% (peak frame 1.35%, average intensity 127.7) across 21 of 23 frames, since the sweep keeps moving even in the handful of frames where the value itself has already settled.

var TAU = 0.14;
var SWEEP_PERIOD = 1.1; // seconds — how long the gloss takes to cross the fill once

function paint(v, sweepPhase) {
  var pct = (v - MIN) / (MAX - MIN) * 100;
  track.style.setProperty('--pct', pct.toFixed(2));
  track.style.setProperty('--sweep', (sweepPhase * 100).toFixed(2));
  pctEl.textContent = Math.round(pct) + '%';
  range.setAttribute('aria-valuenow', String(Math.round(v)));
}

var sweepT = ((now - start) / 1000) % SWEEP_PERIOD / SWEEP_PERIOD;
paint(current, sweepT);

05Tick snap — finding the nearest tick and sticking with a restoring bounce

Rounding to the nearest tick only picks a destination — this item still needs a second calculation to make the thumb feel like it's caught there.

Round picks the target, a spring does the catching

Math.round(rawTarget / STEP) * STEP finds the nearest of ten ticks, then pos keeps chasing that rounded value through acc = -K * (pos - tickTarget) - C * vel every frame, so the last frame or two before landing overshoots slightly and bounces back.

Tick snap — finding the nearest tick and sticking with a restoring bounce
Open in a new tab

05 · Tick snap — finding the nearest tick and sticking with a restoring bounce — Measured area 3.133% · moving frames 19/23Original by GODRICH · SCSS and React ship in the zip · See the password

pointeruprequestAnimationFrameCSS custom properties

What the numbers say

Painted area reads 3.13% (peak frame 0.88%) with the highest average intensity of the nine at 193.8, moving on 19 of 23 frames because the spring goes still once it lands exactly on a tick.

var STEP = (MAX - MIN) / 10; // variant: max/20 gives STEP = (MAX-MIN)/20
var K = 260, C = 22; // snap spring constant · damping (~5% overshoot)
var TAU = 0.16;

var tickTarget = Math.round(rawTarget / STEP) * STEP;
var acc = -K * (pos - tickTarget) - C * vel;
vel += acc * dt;
pos += vel * dt;
range.value = String(Math.round(pos));

06Vertical volume bar — writing-mode rotation and flipping the fill direction

Standing a horizontal slider up on end sounds like it should just need a sign flip on the fill — measuring it in the browser said otherwise.

The rotation that keeps the fill honest

Rotating the track -90deg puts the native thumb's local 0% at the screen's bottom, and the fill reads the same var(--pct) the horizontal items use rather than an inverted (100% - var(--pct)), because flipping it detaches the fill from the thumb and strands it as a slice pinned to the top instead — this css range slider vertical build keeps the two aligned by leaving the formula unflipped.

Vertical volume bar — writing-mode rotation and flipping the fill direction
Open in a new tab

06 · Vertical volume bar — writing-mode rotation and flipping the fill direction — Measured area 2.646% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

writing-modetransform: rotateCSS custom properties

What the numbers say

Painted area sits at 2.65% (peak frame 1.24%, average intensity 119.7) across 20 of 23 frames as the icon swaps between mute, low, and high states along the way.

var MIN = 0, MAX = 100;
var TAU = 0.13;

function paint(v) {
  var pct = (v - MIN) / (MAX - MIN) * 100;
  wrap.style.setProperty('--pct', pct.toFixed(2));
  pctEl.textContent = Math.round(pct) + '%';
  range.setAttribute('aria-valuenow', String(Math.round(v)));
  var key = iconFor(v);
  if (key !== lastIcon) { iconEl.innerHTML = ICONS[key]; lastIcon = key; }
}

07Keyboard precision — arrow and Shift step sizes synced with aria-valuetext

A slider that only feels good under a mouse fails half its keyboard users the moment the step size doesn't match what the arrow keys announce.

One target, two input paths

Arrow keys move the value by a fine step of 1 (0.1s) and Shift-arrow moves it by a coarse step of 10 (1s), both feeding the same spring target that pointer input already uses, and aria-valuetext is written in the same frame as (v / 10).toFixed(1) + 's' so a screen reader announces the value in seconds, not raw units.

Keyboard precision — arrow and Shift step sizes synced with aria-valuetext
Open in a new tab

07 · Keyboard precision — arrow and Shift step sizes synced with aria-valuetext — Measured area 4.829% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · See the password

keydownCSS custom propertiesaria-valuetext

What the numbers say

Painted area comes to 4.83% (peak frame 1.35%, average intensity 135.5) over 20 of 23 frames, from a light spring (K 120, C 16) tuned for almost no visible overshoot.

var K = 120, C = 16; // light spring — minimal overshoot
var TAU = 0.12;

function fmt(v) { return (v / 10).toFixed(1) + 's'; }

range.addEventListener('keydown', function (e) {
  var fine = 1, coarse = 10;
  var delta = 0;
  if (e.key === 'ArrowRight' || e.key === 'ArrowUp') delta = e.shiftKey ? coarse : fine;
  else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') delta = e.shiftKey ? -coarse : -fine;
  else return;
  e.preventDefault();
  target = Math.max(MIN, Math.min(MAX, target + delta));
  range.value = String(target);
  paint();
});

08Color-temperature track — interpolating hue and saturation from the value ratio

A track that only changes fill width tells you the value moved but not by how much it's heating up — color does that second job here.

One ratio, two interpolated channels

The value ratio feeds hue = HUE_COLD + (HUE_WARM - HUE_COLD) * ratio and a matching saturation lerp, both written to --hue and --sat every frame so the track background and thumb repaint from cool blue toward warm orange as one continuous function of the value, never a fixed set of color stops.

Color-temperature track — interpolating hue and saturation from the value ratio
Open in a new tab

08 · Color-temperature track — interpolating hue and saturation from the value ratio — Measured area 5.576% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

linear-gradientCSS custom propertiespointermove

What the numbers say

Average intensity is the calmest of the nine at 94.7 — a low-saturation pastel range — while painted area reaches 5.58% with the highest peak frame of the set at 2.60%, moving on 21 of 23 frames.

var HUE_COLD = 210, HUE_WARM = 28;
var SAT_MIN = 55, SAT_MAX = 78;
var TAU = 0.14;

function paint(v) {
  var ratio = (v - MIN) / (MAX - MIN);
  var hue = HUE_COLD + (HUE_WARM - HUE_COLD) * ratio;
  var sat = SAT_MIN + (SAT_MAX - SAT_MIN) * ratio;
  wrap.style.setProperty('--pct', (ratio * 100).toFixed(2));
  wrap.style.setProperty('--hue', hue.toFixed(1));
  wrap.style.setProperty('--sat', sat.toFixed(1));
  range.setAttribute('aria-valuenow', String(Math.round(v)));
}

09Reduced motion — turning inertia and springs off for instant, static values

Two panels run side by side here specifically so the difference between an animated slider and its reduced-motion twin is something you can see rather than take on faith.

Damping to zero instead of deleting the loop

The left panel's spring keeps running exactly as item 02 and 07's do, while the right panel jumps straight to each new target the instant it changes, and when matchMedia('(prefers-reduced-motion: reduce)') matches, the left panel is set to behave exactly like the right one rather than the loop being removed outright.

Reduced motion — turning inertia and springs off for instant, static values
Open in a new tab

09 · Reduced motion — turning inertia and springs off for instant, static values — Measured area 2.876% · moving frames 21/23Original by GODRICH · SCSS and React ship in the zip · See the password

prefers-reduced-motiontransitionaria-valuenow

What the numbers say

Painted area is 2.88% (peak frame 1.17%, average intensity 185.3) across 21 of 23 frames, all of it coming from the left panel's spring since the right one only ever jumps.

var mq = window.matchMedia('(prefers-reduced-motion: reduce)');
var reduce = mq.matches;

if (reduce) {
  target = targets[0]; posA = target; posB = target;
  rangeA.value = String(target); rangeB.value = String(target);
  paint(wrapA, rangeA, posA); paint(wrapB, rangeB, posB);
} else {
  var acc = -K * (posA - target) - C * velA;
  velA += acc * dt;
  posA += velA * dt;
  posB = targets[seg]; // the right panel jumps straight to the target, no easing at all
}

Motion is math — inertia lerp, snap restoring force, overshoot formulas

The same three shapes, nine times over

Every item above reduces to one of three curves recomputed each frame — exponential decay (1 - Math.exp(-t / TAU)) for items that ease toward a target, spring integration (accel = -K * error - C * velocity) for items that overshoot and settle, or a straight linear multiply-by-time for the drag-inertia glide — and a slider is small enough on screen that none of these formulas ever needs a second pass to look convincing.

Why this isn't just item 077's canvas trick shrunk down

A slider is one track and one thumb, so it was never going to match a canvas scene's painted area — the same discipline that makes a kaleidoscope's mirrored wedge redraw itself from one symmetry formula or a diorama's cube faces shade from a single light-angle calculation is what's being asked of a spring or a decay curve here instead: the number on screen has to come from a formula every frame, not from a canned curve that happens to look similar.

Where it breaks — browser pitfalls

Faking item 02's overshoot with a CSS transition's easing curve instead of integrating accel = -K*(scale-1) - C*velocity throws away the one thing that makes a spring feel like a spring — that changing K or C alone genuinely changes how hard it overshoots, not just how a canned curve happens to look at one setting. Item 03's decay is written against a per-second rate for exactly this reason: multiplying velocity by a raw DECAY once per requestAnimationFrame tick ties the glide's length to whatever frame rate a given device happens to render at, so a phone at 30fps would stop noticeably sooner than a monitor at 120fps unless the coefficient is converted first. Item 05's snap looks like it should just be Math.round() on pointerup, but jumping straight to the tick instead of letting a spring chase it removes the one frame or two of overshoot that reads as "sticking" rather than teleporting. Item 06's vertical bar is the one case measured wrong on the first pass — flipping the fill to (100% - var(--pct)) reads correct on paper for a bottom-up volume bar, but rendering it showed the fill jumping to a slice pinned at the screen's top, detached from the thumb, which is why the source leaves the formula as plain var(--pct) once the rendered layout was actually checked rather than assumed. Item 07's arrow-key handler has to feed the exact same target variable pointer input already writes to, because a separate calculation path for keyboard input is the quiet way a slider ends up behaving differently for the two kinds of users without anyone noticing in a quick test. Every autoplay segment across all nine pulls from a fixed target array or a seeded mulberry32(42) generator rather than Math.random(), since gkbc756y unlocks the same nine files this post's numbers were measured from, and a slider whose demo drifts to a different curve on every reload would make those numbers impossible to reproduce.

Where these nine actually sit

A brightness or volume control in a settings screen, a price-range filter on a product listing, a color-correction panel in a photo editor — those three screens are where a custom slider gets reached for most often. Item 02's spring overshoot fits a settings screen where every change should feel like it landed with some weight, and item 05's tick snap fits a filter that has to lock onto fixed steps rather than any value in between.

In a color-editing tool, item 08's color-temperature track shows the value itself instead of a number you'd have to read to guess direction. All three screens share the same underlying fact — one position on a track resolves to one real value — so the percentage-to-value conversion this post builds nine times over drops straight in.

Ease off the damping and inertia, and accessibility comes into view too

Variant What changes What it trades off
Inertia decay coefficient Item 03's DECAY from 0.90 down to 0.75 0.90 glides far like sliding on ice; 0.75 gets caught right in your hand for a heavier, more metallic feel
Snap tick spacing Item 05's STEP from (MAX-MIN)/10 to (MAX-MIN)/20 Denser ticks weaken the magnet feeling and read closer to fine adjustment — the bounce is liveliest when the spacing is wide
Track thickness Item 01's track from 8px to 16px A thin track reads like a precision instrument; a thick one gives a generous touch target that's easier to hit with a finger on mobile

Reduced motion, item by item

prefers-reduced-motion: reduce turns off every spring, decay, and autoplay loop across all nine and snaps straight to a settled value instead — item 01's bubble lands on its first target with no exponential approach, item 06's icon still swaps between mute, low, and high, and item 09 makes both of its panels behave identically instead of only the right one, so no visitor loses the value itself, only the motion around it. aria-label, aria-valuenow, and — on item 07 — aria-valuetext stay on the native input in every item; tooltips, tick marks, and icons are decoration layered on top, never the value's only source.

Questions that keep coming up once a slider ships

Do these range sliders need a library like noUiSlider to build?

No — every one of the nine restyles a single native input[type=range] with vanilla CSS and JavaScript, and the full HTML, CSS, and JS for all nine ships in the download zip, along with matching React versions.

Why does item 06's vertical bar fill from the bottom without an inverted formula?

Because the -90deg rotation already moves the native thumb's local 0% to the screen's bottom, so the fill only has to read the same var(--pct) the horizontal sliders use — inverting it would pin the fill to the top slice instead, disconnected from the thumb, which rendering confirmed rather than assumed.

Are these css range slider design patterns operable from a keyboard alone?

Yes — item 07 documents the pattern directly (arrow keys and Shift feed the same spring target pointer input uses, with aria-valuetext updated in the same frame), and every other item keeps its native input focusable and its value announced, since a redrawn track is decoration on top of the real control, never a replacement for it.

Sources: MDN — <input type="range"> · MDN — requestAnimationFrame

Enter the archive password

The password is inside this article. You will find it as you read.