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

Number Stepper UI: 9 Interaction Patterns

One stepper shortens its repeat interval the longer it stays pressed, one settles past its limit along a spring curve, and one keeps advancing a few steps by

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

None of the nine ever send a real order or price anywhere — 07's coin balance and 08's kg-to-lb conversion both compute locally, and nothing in the set calls fetch or reaches a payment or inventory API. Every one of the nine also answers something a visitor actually does: 01 and 02 answer a held pointerdown through pointerup, pointerleave, and pointercancel, 03, 07, and 08 answer a plain click, 04 answers wheel, 05 and 09 answer a pointerdown-pointermove-pointerup-pointercancel drag, and 06 answers keydown — not one of the nine only plays on autoplay.

Anatomy of nine patterns — when interval, distance, and tick values recompute

Nine patterns split into three ways of turning a held button, a drag, or a keystroke into a value worth animating, and skipping this layer is what makes a number stepper ui pattern read as nine identical +/- buttons instead of nine small calculators built into a single number stepper component.

Why three patterns solve a physics equation directly from elapsed time or force

03, 05, and 09 never touch a bounded progress ratio at all — each one solves a physics-style equation instead. 03's overshoot plugs the time elapsed since a boundary was hit into a decaying sine wave, so the seat count can swing past its edge before settling back, something a plain clamp alone cannot produce. 05's endDrag decays the pointer's release velocity by an exponential friction term every frame and keeps folding the shrinking result into the temperature value until it drops below a threshold. 09's springSettle recomputes a spring force — target distance times STIFF, damped by DAMP — every frame until the handle has actually arrived at the nearest tick, not just approximately close to it.

Why three patterns recompute an interval or threshold from a changing input, not the value itself

01, 02, and 04 never change how far a single step moves — each one instead recomputes when the next step is allowed to fire. 01's ringFrame keeps the repeat interval fixed at INTERVAL but recalculates, every frame, how much of that interval has elapsed so it can redraw the ring around whichever button is held. 02's currentInterval shortens that same kind of interval itself, computing START * DECAY^ticks fresh on every tick so the wait between changes keeps shrinking the longer the button stays held. 04's applyDelta never fires on a timer at all — it accumulates wheel movement into a residual value and only commits a step once that residual crosses THRESH.

Why three patterns compute a transform value keyed to jump size, digit place, or flip angle

06, 07, and 08 each key their motion to something other than a plain elapsed-time ratio. 06's jump takes the log of the jump size itself — Math.log2(1 + Math.abs(delta)) — to size how far and how long a ghost after-image slides, so a Home/End jump leaves a longer, slower-fading trail than a single arrow-key step, while the increment unit itself stays a plain 1-or-10 choice from the Shift key. 07's startColumnRoll multiplies each digit's place by CASCADE to delay its own roll, so the ones column always starts rolling before the thousands column does. 08's flipToggle takes the absolute cosine of the flip angle to compute how thin the card gets, and swaps the unit at the exact frame that thinness reaches zero at the 90-degree point.

Code per pattern — how does each value get computed?

01Press-and-hold repeat — a stepper whose value keeps changing at a fixed interval while held

Once a button enters a held state, its quantity value keeps changing by one unit every INTERVAL, while a separate ring redraw recomputes how close the next tick is on every animation frame.

Press-and-hold repeat — a stepper whose value keeps changing at a fixed interval while held
Open in a new tab

01 · Press-and-hold repeat — a stepper whose value keeps changing at a fixed interval while held — Measured area 0.705% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password

DELAY=380INTERVAL=220ringFrame

What the numbers say

Across 23 tracked frames, 01 moves on 14 of them, covering 0.71% cumulative area at a 136.2 average intensity — modest next to a full scene, since the visible change is a two-digit number and a thin ring stroke, and its DELAY (380ms) and INTERVAL (220ms) constants only ever touch two small SVG rings and a text node.

var MIN = 0, MAX = 30, DELAY = 380, INTERVAL = 220;
var value = 12;
var held = null;
var lastTick = 0;
var repeatTimer = null;
var delayTimer = null;

function render() { valueEl.textContent = String(value); }

function step(dir) {
  value = Math.max(MIN, Math.min(MAX, value + dir));
  render();
  lastTick = performance.now();
}

// calc layer: the repeat interval itself stays fixed at INTERVAL, but the time left until the next
// tick gets recomputed every frame and fed into the ring's stroke-dashoffset — not a static tween.
function ringFrame() {
  var now = performance.now();
  var p = held ? Math.min(1, (now - lastTick) / INTERVAL) : 0;
  var offset = CIRC * (1 - p);
  ringMinus.style.strokeDashoffset = (held === 'minus' ? offset : CIRC).toFixed(2);
  ringPlus.style.strokeDashoffset = (held === 'plus' ? offset : CIRC).toFixed(2);
  requestAnimationFrame(ringFrame);
}

02Long-press acceleration — a stepper whose interval value shortens the longer it stays pressed

Holding a button keeps the interval value shrinking on an exponential curve toward a floor, so the same one-second hold changes far more points near the end of the hold than at the start.

Long-press acceleration — a stepper whose interval value shortens the longer it stays pressed
Open in a new tab

02 · Long-press acceleration — a stepper whose interval value shortens the longer it stays pressed — Measured area 0.872% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

START=480DECAY=0.82currentInterval

What the numbers say

02 covers 0.87% cumulative area at 171.0 average intensity across 8 of 23 frames — the meter bar's scaleX repaint plus the digit swap edges out 01's plainer ring redraw, because paintMeter recomputes the bar's fill every tick rather than every frame.

var timer = null;
var ticks = 0;

function render() { valueEl.textContent = String(value); }

// calc layer: the interval is recomputed every tick as START*DECAY^ticks (exponential decay, floored
// at FLOOR) — not a fixed setInterval period but a recursive setTimeout that updates the interval itself.
function currentInterval() {
  return Math.max(FLOOR, START * Math.pow(DECAY, ticks));
}
function paintMeter() {
  var iv = currentInterval();
  var speed = (START - iv) / (START - FLOOR); // 0 (slowest) to 1 (fastest)
  meterFill.style.transform = 'scaleX(' + speed.toFixed(3) + ')';
}

function scheduleNext(dir) {
  timer = setTimeout(function () {
    value = Math.max(MIN, Math.min(MAX, value + dir * 5));
    render();
    ticks += 1;
    paintMeter();
    scheduleNext(dir);
  }, currentInterval());
}

03Limit bounce — a stepper value that overshoots its min or max slightly before settling along a spring curve

Reaching the seat count's edge doesn't just stop the value — the number's position swings past that edge and back along a decaying sine wave before it holds still.

Limit bounce — a stepper value that overshoots its min or max slightly before settling along a spring curve
Open in a new tab

03 · Limit bounce — a stepper value that overshoots its min or max slightly before settling along a spring curve — Measured area 0.547% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password

AMPLITUDE=10FREQ=16autoplayTick

What the numbers say

03 moves on 15 of 23 frames, the most of any pattern here, covering 0.55% cumulative area at 126.0 average intensity, because overshoot's decaying sine keeps repainting translateX well after a plain clamp would already have stopped.

// calc layer: from the instant the boundary is hit, a decaying sine wave is recomputed every frame from
// elapsed time (t) and fed into translateX — the value overshoots its target slightly before a spring curve pulls it back.
var AMPLITUDE = 10, FREQ = 16, DECAY = 10;
function overshoot(tSec) {
  if (tSec <= 0) { return 0; }
  return AMPLITUDE * Math.sin(tSec * FREQ) * Math.exp(-DECAY * tSec);
}

04Wheel delta — a stepper whose value changes by the amount and direction the wheel moves over it

Every wheel tick over the zoom panel adds to a residual value, and only once that residual crosses THRESH does the displayed percentage actually move.

Wheel delta — a stepper whose value changes by the amount and direction the wheel moves over it
Open in a new tab

04 · Wheel delta — a stepper whose value changes by the amount and direction the wheel moves over it — Measured area 0.333% · moving frames 23/23Original by GODRICH · SCSS and React ship in the zip · See the password

THRESH=60STEP=10decayFrame

What the numbers say

04's 0.33% cumulative area is the lowest of the nine even though it moves on all 23 tracked frames, because decayFrame repaints a thin fill bar by a fraction of a pixel on most frames instead of swapping the whole number.

function render() { valueEl.textContent = value + '%'; }
function paintTrack() {
  var p = Math.min(1, Math.abs(residual) / THRESH);
  fillUp.style.transform = 'scaleX(' + (residual > 0 ? p : 0).toFixed(3) + ')';
  fillDown.style.transform = 'scaleX(' + (residual < 0 ? p : 0).toFixed(3) + ')';
}

// calc layer: wheel delta accumulates into a residual value and only commits one step once that
// residual crosses THRESH. An uncommitted residual decays by 0.9x every frame, settling to 0 on its own.
function applyDelta(dy) {
  residual += dy;
  while (residual >= THRESH) {
    value = Math.min(MAX, value + STEP);
    residual -= THRESH;
    render();
  }
  while (residual <= -THRESH) {
    value = Math.max(MIN, value - STEP);
    residual += THRESH;
    render();
  }
  paintTrack();
}

05Vertical drag inertia — a stepper that changes with drag distance and keeps advancing a few steps after release

Dragging the handle changes the temperature value one step per STEP_PX, and releasing it hands the last recorded velocity to a friction loop that keeps advancing the value for a few more frames.

Vertical drag inertia — a stepper that changes with drag distance and keeps advancing a few steps after release
Open in a new tab

05 · Vertical drag inertia — a stepper that changes with drag distance and keeps advancing a few steps after release — Measured area 3.352% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password

FRICTION=6.2MAX_OFFSET=34autoplayFling

What the numbers say

05 covers the widest area of the nine at 3.35% cumulative area and 154.7 average intensity across 6 of 23 frames, because a full vertical handle travel plus its post-release inertia repaints far more pixels per active frame than a click ever could.

var FRICTION = 6.2;
function endDrag() {
  dragging = false;
  handle.classList.remove('nspdi__handle--active');
  var baseValue = value;
  var virtual = 0;
  var v = vel;
  var last = performance.now();
  function frame(now) {
    var dt = now - last; last = now;
    v *= Math.exp(-FRICTION * dt / 1000);
    virtual += v * dt;
    value = Math.max(MIN, Math.min(MAX, baseValue - Math.round(virtual / STEP_PX)));
    render();
    setHandle(virtual);
    if (Math.abs(v) > 0.02) {
      inertiaRaf = requestAnimationFrame(frame);
    } else {
      setHandle(0);
      inertiaRaf = null;
    }
  }
  inertiaRaf = requestAnimationFrame(frame);
}

06Keyboard jump — arrow keys change the value by one unit, a modifier key by a larger unit

An arrow key changes the point total by one unit; the same arrow key held with Shift changes it by ten, and a fading ghost number behind the live value slides to mark how far that jump just moved it.

Keyboard jump — arrow keys change the value by one unit, a modifier key by a larger unit
Open in a new tab

06 · Keyboard jump — arrow keys change the value by one unit, a modifier key by a larger unit — Measured area 0.785% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password

MAX=200Math.log2stopAutoplay

What the numbers say

06 covers 0.79% cumulative area at the lowest average intensity of the nine, 59.6, across 9 of 23 frames, because the ghost trail spends most of a jump fading in opacity rather than moving far in position.

function jump(oldVal, delta) {
  ghost.textContent = String(oldVal);
  var dist = Math.min(46, 12 + Math.log2(1 + Math.abs(delta)) * 13);
  var dir = delta >= 0 ? -1 : 1;
  var dur = Math.min(520, 220 + Math.abs(delta));
  var start = performance.now();
  if (raf) { cancelAnimationFrame(raf); }
  ghost.style.opacity = '1';
  function frame(now) {
    var t = Math.min(1, (now - start) / dur);
    var eased = 1 - Math.pow(1 - t, 3);
    ghost.style.transform = 'translateY(' + (dir * dist * eased).toFixed(1) + 'px)';
    ghost.style.opacity = (1 - eased).toFixed(3);
    if (t < 1) {
      raf = requestAnimationFrame(frame);
    } else {
      ghost.style.opacity = '0';
      raf = null;
    }
  }
  raf = requestAnimationFrame(frame);
  valueEl.style.transform = 'scale(1.12)';
  setTimeout(function () { valueEl.style.transform = 'scale(1)'; }, 30);
}

07Live currency format — a stepper that recomputes digit-grouping positions every time its value changes

Every step recomputes which of the four digit columns actually changed, and only those columns roll to their new digit while the others hold still.

Live currency format — a stepper that recomputes digit-grouping positions every time its value changes
Open in a new tab

07 · Live currency format — a stepper that recomputes digit-grouping positions every time its value changes — Measured area 0.493% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password

CASCADE=60BASE_DUR=220startColumnRoll

What the numbers say

07 covers 0.49% cumulative area at 125.7 average intensity across 8 of 23 frames, because CASCADE staggers each column's start by 60ms per place so the four columns never all repaint on the same frame.

function startColumnRoll(colEl, oldD, newD, dir, place) {
  var oldEl = colEl.querySelector('.nspcf__digit--old');
  var newEl = colEl.querySelector('.nspcf__digit--new');
  var delay = place * CASCADE;
  var duration = BASE_DUR + place * 40;
  newEl.textContent = String(newD);
  newEl.style.transform = 'translateY(' + (dir * H) + 'px)';
  setTimeout(function () {
    var start = performance.now();
    function frame(now) {
      var t = Math.min(1, (now - start) / duration);
      var eased = 1 - Math.pow(1 - t, 3);
      oldEl.style.transform = 'translateY(' + (dir * H * eased).toFixed(1) + 'px)';
      newEl.style.transform = 'translateY(' + (dir * H * (eased - 1)).toFixed(1) + 'px)';
      if (t < 1) {
        requestAnimationFrame(frame);
      } else {
        oldEl.textContent = String(newD);
        oldEl.style.transform = 'translateY(0)';
        newEl.style.transform = 'translateY(' + (dir * H) + 'px)';
      }
    }
    requestAnimationFrame(frame);
  }, delay);
}

08Unit toggle flip — the card folds over and the display value switches at the 90-degree point once the unit enters a changed state

Toggling the unit doesn't fade one label into another — a flip card recomputes its own vertical scale from the cosine of the flip angle and swaps kg for lb at the exact frame that scale bottoms out, a flip cut rather than an overlapping fade.

Unit toggle flip — the card folds over and the display value switches at the 90-degree point once the unit enters a changed state
Open in a new tab

08 · Unit toggle flip — the card folds over and the display value switches at the 90-degree point once the unit enters a changed state — Measured area 0.93% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

FACTOR=2.20462Math.cosflipToggle

What the numbers say

08 covers 0.93% cumulative area at 79.1 average intensity across 12 of 23 frames, more active frames than any pattern except 04, because every plus, minus, and toggle press each redraws the flip card or bumps the weight value.

function flipToggle() {
  if (flipRaf) { cancelAnimationFrame(flipRaf); }
  var duration = 340;
  var start = performance.now();
  var swapped = false;
  function frame(now) {
    var t = Math.min(1, (now - start) / duration);
    var angle = t * Math.PI;
    var scaleY = Math.abs(Math.cos(angle));
    flipBox.style.transform = 'scaleY(' + Math.max(0.06, scaleY).toFixed(3) + ')';
    if (!swapped && t >= 0.5) {
      unit = unit === 'kg' ? 'lb' : 'kg';
      render();
      swapped = true;
    }
    if (t < 1) {
      flipRaf = requestAnimationFrame(frame);
    } else {
      flipBox.style.transform = 'scaleY(1)';
      flipRaf = null;
    }
  }
  flipRaf = requestAnimationFrame(frame);
}

09Tick-mark snap — a value between set tick marks is pulled to the nearest tick value

Releasing the handle anywhere between two ticks starts a spring force that pulls it toward whichever tick sits closer, not toward a fixed direction.

Tick-mark snap — a value between set tick marks is pulled to the nearest tick value
Open in a new tab

09 · Tick-mark snap — a value between set tick marks is pulled to the nearest tick value — Measured area 3.068% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password

STIFF=0.28DAMP=0.62springSettle

What the numbers say

09 covers 3.07% cumulative area at 110.9 average intensity across 12 of 23 frames — close behind 05's drag for total coverage even though it's the smallest component in the set — because eleven ticks resize and fade in paint on every frame the handle or the spring is moving, not just the handle itself.

var STIFF = 0.28, DAMP = 0.62;
function springSettle(x0, target) {
  if (settleRaf) { cancelAnimationFrame(settleRaf); }
  var x = x0, v = 0;
  function frame() {
    var force = (target - x) * STIFF;
    v = (v + force) * DAMP;
    x += v;
    paint(x);
    if (Math.abs(target - x) > 0.3 || Math.abs(v) > 0.2) {
      settleRaf = requestAnimationFrame(frame);
    } else {
      x = target;
      paint(x);
      currentX = x;
      settleRaf = null;
    }
  }
  settleRaf = requestAnimationFrame(frame);
}

Where values reshape a pattern — acceleration factor, resistance curve, snap interval

Three constants decide how a pattern feels without ever deciding what a pattern does — swap them and the same click, drag, or tick still lands on the same final value, just along a different path.

Why 02's DECAY factor decides how fast the interval shrinks, not whether the button repeats at all

currentInterval() returns START * DECAY^ticks, clamped at FLOOR. A DECAY close to 1 barely shrinks the wait between changes even after dozens of ticks, while a DECAY far from 1 collapses it to FLOOR almost immediately — but the repeat itself, the fact that scheduleNext keeps calling itself at all, comes from the recursive setTimeout, not from this constant.

Why 05's FRICTION constant decides how long inertia coasts, not how far a single drag can travel

endDrag() multiplies the release velocity by Math.exp(-FRICTION * dt / 1000) every frame, so a small FRICTION lets the temperature value keep coasting for many more frames after release. A single drag's own distance, by contrast, is a direct 1:1 mapping through STEP_PX inside moveDrag, unrelated to FRICTION at all.

Why 09's STIFF and DAMP constants decide how it snaps, not which tick it snaps to

nearestTickX() already picked the target tick from raw pixel distance before springSettle ever runs. STIFF and DAMP only shape the force and damping of the path the handle takes to reach that already-chosen target — a low STIFF drifts toward it, a high STIFF snaps hard, but neither can redirect it toward a different tick.

Where it breaks — rapid taps, large digit counts, out-of-range input

What a fast double-click does to 03's overshoot animation before it finishes settling

bounce(dir) cancels any running raf the instant it's called again, so a second click at the boundary while the first overshoot is still mid-flight discards that progress and restarts the sine wave from t = 0 — the seat count visibly snaps back to zero offset before swinging out again, rather than blending the two overshoots together.

What happens once 07's value tries to cross four digits

commit() clamps every step to MAX = 9999, and toDigits() always slices the last four characters of a zero-padded string. That clamp is what keeps the coin balance safe — remove it to track a real five-digit balance and toDigits would silently drop the leading digit instead of rolling a fifth column into view.

What happens once 04's accumulated wheel residual pushes the value past MAX or MIN

applyDelta() clamps the committed value with Math.min/Math.max, but the while loop still drains residual by THRESH on every extra tick regardless of that clamp. Scroll past the zoom level's ceiling and the fill bar keeps flashing up and back down each time it crosses the threshold, even though the displayed percentage has already stopped moving.

Where this set differs from CSS Input Design: 9 Form Widgets You Can Paste In, 9 Color Picker Design Patterns (Copy-Paste), and 9 CSS Number Counter Animations — Copy-Paste Ready is which value each set actually recomputes on every interaction, and all nine source files behind this set, unlocked with the password a3azqrsb, are ready to drop into a real quantity field once you swap the invented labels for a real product name. That input set mostly recomputes a field's own validation or formatting state, not a bounded quantity driven by a hold, a drag, or a wheel. The color picker set recomputes a hue, saturation, or position value from a wheel or pointer move, but nothing in it clamps against a numeric MIN and MAX the way every increment decrement button here does. The counter set recomputes a single displayed number through a one-shot easing tween triggered once by scroll or load, never by the repeated hold-drag-wheel-key input this number stepper html set answers on every interaction.

Three variants and accessibility

The zip ships one value per constant, but each of these three can be swapped for a very different feel without touching the surrounding markup.

Variant What changes What it trades off
07's CASCADE from 60ms to 140ms Digit columns start rolling further apart in time At 60ms the whole number changes as one block; at 140ms the gap between the ones and thousands place becomes visible, reading like a mechanical counter rolling over one column at a time
09's STIFF from 0.28 to 0.5 The spring pulls the handle toward its tick harder At 0.28 the handle drifts toward the nearest tick over several frames after release; at 0.5 the same distance gets pulled in far faster, so the snap feels immediate, like a magnet grabbing the handle
05's FRICTION from 6.2 to 3 The post-release coast decays more slowly At 6.2 the velocity dies almost as soon as the drag ends, settling one or two steps later; at 3 the decay slows down and the value keeps coasting through several more steps before it stops

This zip ships the 220ms, 0.28, and 6.2 values shown throughout the code above — dropping in a faster CASCADE, a stiffer STIFF, or a lower FRICTION is a one-line constant change, not a rewrite.

Why prefers-reduced-motion still leaves every computed value reachable by hand

All nine check window.matchMedia('(prefers-reduced-motion: reduce)').matches before starting their own autoplay preview loop, so a reduced-motion visitor who presses, drags, scrolls, or types by hand still reaches the same recomputed value — only the demo's self-playing loop is what stops. 01's ring SVGs are the only purely decorative repaint in the set marked aria-hidden. 01, 02, 03, 07, and 08 sit behind a button that already carries its own aria-label — "Decrease quantity," "Increase points," "Decrease seats" — and 06's role="spinbutton" box carries its own aria-valuemin and aria-valuemax instead of a label, so each announces one state per control rather than a duplicate for the animation layered under it. 04's wheel panel, 05's drag handle, and 09's drag handle carry no aria-label of their own, so a screen reader visitor has only the surrounding label text to find them — the weakest accessibility link in this set.

FAQ

Should a number stepper ui pattern start with press-and-hold repeat or keyboard support first?

Start with keyboard support — 06's arrow-key and Home/End handling costs nothing extra once commit() already clamps the value, and a visitor who can't hold a pointer down long enough for 01 or 02 to matter can still reach every value 06 reaches. Press-and-hold repeat is worth adding once the range is wide enough that single clicks alone would take dozens of presses.

Do any of these nine patterns validate or submit a real quantity to a server?

No — every value here is clamped by a local MIN/MAX pair and rendered straight to the page; nothing in the set calls fetch, checks real stock, or submits an order, and the coin balance, seat count, and luggage weight are all invented numbers rather than pulled from a real catalog.

Is this collection a reference to read, or code meant to be pasted into a form?

It's meant to be pasted in rather than only read — each of the nine is 25 lines or fewer of the core computation shown above, none of the nine reaches outside itself for a library, and the local value, MIN, and MAX variables are the only part meant to be replaced with a real field's own state.

Sources: MDN — Element: wheel event · MDN — Window.requestAnimationFrame · MDN — Intl.NumberFormat · MDN — ARIA spinbutton role · MDN — prefers-reduced-motion

Enter the archive password

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