04휠 델타 — 입력창 위 휠 이동 값만큼 그 방향으로 값이 바뀌는 스텝퍼
입력창 위에서 휠이 움직인 값이 들어오면 그 방향과 크기에 비례해 값이 바뀌고, 값이 상한이나 하한에 닿은 상태가 되면 그 이상의 휠 값은 무시됩니다.
쓰임데스크톱 폼의 수량 입력창, 설정 패널의 슬라이더 대체
THRESH=60STEP=10decayFrame

<main class="stage">
<div class="nspwd is-demo" id="nspwd">
<div class="nspwd__panel">
<span class="nspwd__label">확대 배율</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">휠 델타가 쌓이면 한 칸씩 바뀝니다</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 휠 델타 — 입력창 위 휠 이동 값만큼 그 방향으로 값이 바뀌는 스텝퍼 — 측정 누적면적 0.333% · 움직인 프레임 23/23GODRICH 원작 · zip 에 SCSS·React 포함 · 숫자 스텝퍼 UI 패턴 9종 — 길게 누를수록 빨라지는 것부터 눈금에 붙는 것까지
이 데모가 속한 글
숫자 스텝퍼 UI 패턴 9종 — 길게 누를수록 빨라지는 것부터 눈금에 붙는 것까지숫자 스텝퍼는 버튼이나 드래그로 값을 하나씩 늘리고 줄이는 입력 장치인데, 아홉 개 중 하나는 눌린 상태가 길어질수록 값이 바뀌는 간격이 점점 짧아지고, 하나는 값이 상한에 닿으면 그 지점을 살짝 넘었다가 스프링 곡 이 글의 데모 전부 보기


