04Wheel delta — a stepper whose value changes by the amount and direction the wheel moves over it
When a wheel-movement value comes in over the input, the value changes in proportion to that direction and size, and once the value reaches its limit, further wheel values are ignored.
Best forA quantity input in a desktop form, a slider alternative in a settings panel
THRESH=60STEP=10decayFrame

<main class="stage">
<div class="nspwd is-demo" id="nspwd">
<div class="nspwd__panel">
<span class="nspwd__label">Zoom level</span>
<span class="nspwd__value" id="nspwdValue">100%</span>
<div class="nspwd__track">
<span class="nspwd__fillDown" id="nspwdFillDown"></span>
<span class="nspwd__mid"></span>
<span class="nspwd__fillUp" id="nspwdFillUp"></span>
</div>
<span class="nspwd__hint">Once the wheel delta accumulates, it steps by one</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 휠 delta 를 잔여값에 누적하다 문턱을 넘을 때만 한 칸 커밋하고, 남은 잔여값은
// 매 프레임 마찰(0.9배)로 감쇠시킨다 — 트랙 채움은 scaleX 로 그 잔여값을 실시간 표시한다.
$duration: $dur-instant;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-paper);
.stage { position: relative; }
.nspwd {
width: min(220px, 68vw);
}
.nspwd__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
padding: $sp-5;
border-radius: $r-card;
background: rgba($subject-ink, .05);
}
.nspwd__label {
color: rgba($subject-ink, .72);
font-weight: 700;
font-size: 12px;
}
.nspwd__value {
color: $subject-ink;
font-weight: 700;
font-size: 28px;
font-variant-numeric: tabular-nums;
}
.nspwd__track {
position: relative;
width: 100%;
height: $sp-2;
border-radius: $r-xs;
background: rgba($subject-ink, .12);
display: flex;
overflow: hidden;
}
.nspwd__fillDown,
.nspwd__fillUp {
display: block;
width: 50%;
height: 100%;
background: $color;
transform: scaleX(0);
}
.nspwd__fillDown { transform-origin: right center; }
.nspwd__fillUp { transform-origin: left center; }
.nspwd__mid {
position: absolute;
left: 50%;
top: -3px;
bottom: -3px;
width: 2px;
background: $subject-ink;
opacity: .3;
}
.nspwd__hint {
color: rgba($subject-ink, .72);
font-size: 11px;
text-align: center;
}
@media (max-width: 340px) {
.nspwd__panel { padding: $sp-4; gap: $sp-2; }
.nspwd__value { font-size: 24px; }
}
@media (prefers-reduced-motion: reduce) {
.nspwd__fillUp, .nspwd__fillDown { transition: none !important; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.nspwd {
width: min(220px, 68vw);
}
.nspwd__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px;
border-radius: 16px;
background: rgba(23, 20, 26, 0.05);
}
.nspwd__label {
color: rgba(23, 20, 26, 0.72);
font-weight: 700;
font-size: 12px;
}
.nspwd__value {
color: #17141a;
font-weight: 700;
font-size: 28px;
font-variant-numeric: tabular-nums;
}
.nspwd__track {
position: relative;
width: 100%;
height: 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.12);
display: flex;
overflow: hidden;
}
.nspwd__fillDown,
.nspwd__fillUp {
display: block;
width: 50%;
height: 100%;
background: #0a4fe8;
transform: scaleX(0);
}
.nspwd__fillDown {
transform-origin: right center;
}
.nspwd__fillUp {
transform-origin: left center;
}
.nspwd__mid {
position: absolute;
left: 50%;
top: -3px;
bottom: -3px;
width: 2px;
background: #17141a;
opacity: 0.3;
}
.nspwd__hint {
color: rgba(23, 20, 26, 0.72);
font-size: 11px;
text-align: center;
}
@media (max-width: 340px) {
.nspwd__panel {
padding: 16px;
gap: 8px;
}
.nspwd__value {
font-size: 24px;
}
}
@media (prefers-reduced-motion: reduce) {
.nspwd__fillUp, .nspwd__fillDown {
transition: none !important;
}
}(function () {
var root = document.getElementById('nspwd');
var panel = root.querySelector('.nspwd__panel');
var valueEl = document.getElementById('nspwdValue');
var fillUp = document.getElementById('nspwdFillUp');
var fillDown = document.getElementById('nspwdFillDown');
var MIN = 50, MAX = 200, STEP = 10, THRESH = 60;
var value = 100;
var residual = 0;
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) + ')';
}
// 계산 겹: 휠 delta 를 잔여값(residual)에 누적하다가 문턱(THRESH)을 넘을 때만 한 칸 커밋한다.
// 커밋되지 않은 잔여값은 매 프레임 마찰(0.9배)로 감쇠시켜 손을 놓아도 서서히 0으로 가라앉는다.
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();
}
function decayFrame() {
if (Math.abs(residual) > 0.5) {
residual *= 0.90;
paintTrack();
} else if (residual !== 0) {
residual = 0;
paintTrack();
}
requestAnimationFrame(decayFrame);
}
requestAnimationFrame(decayFrame);
panel.addEventListener('wheel', function (e) {
root.classList.remove('is-demo'); stopAutoplay();
e.preventDefault();
applyDelta(e.deltaY);
}, { passive: false });
var seq = [16, 16, 16, 16, -16, -16, -16, -16];
var idx = 0;
var autoplayTimer = null;
function stopAutoplay() { if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) {
autoplayTimer = setInterval(function () {
applyDelta(seq[idx % seq.length]);
idx += 1;
}, 180);
}
render();
})();import { useEffect, useRef } from "react";
import styles from "./NumberStepperScrollWheelDelta.module.scss";
export type NumberStepperScrollWheelDeltaProps = {
color?: string;
};
export default function NumberStepperScrollWheelDelta({
color = "#0a4fe8",
}: NumberStepperScrollWheelDeltaProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const panel = root.querySelector<HTMLDivElement>(`.${styles.panel}`);
const valueEl = root.querySelector<HTMLSpanElement>(`.${styles.value}`);
const fillUp = root.querySelector<HTMLSpanElement>(`.${styles.fillUp}`);
const fillDown = root.querySelector<HTMLSpanElement>(`.${styles.fillDown}`);
if (!panel || !valueEl || !fillUp || !fillDown) return;
const MIN = 50, MAX = 200, STEP = 10, THRESH = 60;
let value = 100;
let residual = 0;
let rafId: number | null = null;
function render() { valueEl!.textContent = value + "%"; }
function paintTrack() {
const 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) + ")";
}
// 계산 겹: 휠 delta 를 잔여값(residual)에 누적하다가 문턱(THRESH)을 넘을 때만 한 칸 커밋한다.
// 커밋되지 않은 잔여값은 매 프레임 마찰(0.9배)로 감쇠시켜 손을 놓아도 서서히 0으로 가라앉는다.
function applyDelta(dy: number) {
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();
}
function decayFrame() {
if (Math.abs(residual) > 0.5) {
residual *= 0.90;
paintTrack();
} else if (residual !== 0) {
residual = 0;
paintTrack();
}
rafId = requestAnimationFrame(decayFrame);
}
rafId = requestAnimationFrame(decayFrame);
const onWheel = (e: WheelEvent) => {
root!.classList.remove(styles.isDemo);
stopAutoplay();
e.preventDefault();
applyDelta(e.deltaY);
};
panel.addEventListener("wheel", onWheel, { passive: false });
const seq = [16, 16, 16, 16, -16, -16, -16, -16];
let idx = 0;
let autoplayTimer: ReturnType<typeof setInterval> | null = null;
function stopAutoplay() {
if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; }
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) {
autoplayTimer = setInterval(() => {
applyDelta(seq[idx % seq.length]);
idx += 1;
}, 180);
}
render();
return () => {
stopAutoplay();
if (rafId) cancelAnimationFrame(rafId);
panel!.removeEventListener("wheel", onWheel);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.isDemo}`} style={{ ["--color" as string]: color }}>
<div className={styles.panel}>
<span className={styles.label}>확대 배율</span>
<span className={styles.value}>100%</span>
<div className={styles.track}>
<span className={styles.fillDown} />
<span className={styles.mid} />
<span className={styles.fillUp} />
</div>
<span className={styles.hint}>휠 델타가 쌓이면 한 칸씩 바뀝니다</span>
</div>
</div>
);
}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 · Number Stepper UI: 9 Interaction Patterns
The post this demo lives in
Number Stepper UI: 9 Interaction PatternsOne stepper shortens its repeat interval the longer it stays pressed, one settles past its limit along a spring curve, a See all demos in this post
If you like this effect - similar demos
Inputs05 Live validity value toggle — each time a digit value fills in, the whole number's validity value is recalculated and the display value changesCredit Card Input UI: 9 Patterns
Inputs07 Arrival chip whose date slides over when you change the shipping speedCheckout Page Design — 9 Cart Patterns, No Library
Inputs05 Grouped sticky headers — splitting the list into groups whose headers pin on scrollAutocomplete Input: 9 Search Dropdowns