数値ステッパー UI 9選 — 長押しから目盛りスナップまで
9つのうち1つは押された状態が長引くほど値が変わる間隔が短くなり、1つは値が上限に触れるとその地点を少し超えてからばね曲線に沿って戻り、もう1つは引く距離の値に比例して数字が変わり、指を離した後も慣性の値に沿って数段階分さらに進みます。ステッパー uiの9パターンを以下にまとめました。
01 number-stepper-press-hold-repeat
02 number-stepper-longpress-acceleration
03 number-stepper-min-max-clamp-bounce
04 number-stepper-scroll-wheel-delta
05 number-stepper-drag-vertical-inertia
06 number-stepper-keyboard-shift-jump
07 number-stepper-currency-live-format
08 number-stepper-unit-toggle-crossfade
09 number-stepper-tick-mark-snap
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 長押しリピート — 押された状態が続く間、一定間隔で値が変わり続けるステッパー
- 02 長押し加速 — 押されている時間が長くなるほど間隔の値が短くなるステッパー
- 03 限界バウンス — 値が上限や下限を少し超えてからばね曲線に沿って戻るステッパー
- 04 ホイールデルタ — 入力欄上のホイール移動の値の分だけ同じ向きに値が変わるステッパー
- 05 縦ドラッグ慣性 — 引く距離の値に比例して変わり、離した後も数段階分続くステッパー
- 06 キーボードジャンプ — 矢印キーは1単位、修飾キーを加えるとより大きな単位で値が変わるステッパー
- 07 通貨のライブ書式 — 値が変わるたびに桁区切りの位置を求め直すステッパー
- 08 単位フリップ切替 — 単位が変わった状態になるとカードが裏返り、90度の地点で表示値が切り替わるステッパー
- 09 目盛りスナップ — 値が決まった目盛りの間にあると最も近い目盛りの値に引き寄せられるステッパー
この数値 ステッパーの9つはどれも実在のサーバーに触れません — 07のコイン残高も08のkg・lb換算もその場で計算されるだけで、fetchや決済・在庫のAPIを呼び出す処理はどこにもありません。そのうえで9つとも実際に手を動かした結果に答えます。01と02はpointerdownからpointerup・pointerleave・pointercancelまで続く押し続け操作に、03・07・08はクリックに、04はホイールに、05はpointerdownからpointermove・pointerup・pointercancelまで続くドラッグに、06はkeydownに、それぞれ応答します — 自動再生だけで完結する項目はひとつもありません。
9つのパターンの解剖 — 間隔・距離・目盛りの値がいつ変わるか
9つのパターンは、押し続け・ドラッグ・キー入力という3つの入力を、アニメーションする価値のある値に変える3つの方法に分かれます。この計算層を飛ばすと、ステッパー ボタンが9個とも同じ+/-アイコンの繰り返しにしか見えなくなります。
03・05・09が経過時間や力から直接、物理の式を解く理由
03・05・09は一定区間を0〜1に正規化した進行率をそのまま使いません — それぞれが物理的な式を毎フレーム解きます。03のovershootは境界に触れてから経過した時間を減衰する正弦波に代入し、座席数の値が境界を少し超えてからばね曲線に沿って戻る動きを作ります。05のendDragは指を離した瞬間の速度に指数的な摩擦の項を毎フレーム掛けて減衰させ、体感温度の値にその結果を折り込み続けます。09のspringSettleはハンドルの位置と目標の目盛りとの距離にSTIFFを掛けDAMPで減衰させたばねの力を毎フレーム計算し、ハンドルが実際にその目盛りへ到達するまで止まりません。
01・02・04が値そのものではなく、間隔や閾値を求め直す理由
01・02・04は1回のステップが動く量そのものは変えません — 代わりに次のステップがいつ発火できるかを求め直します。01のringFrameは繰り返し間隔自体をINTERVALのまま固定しつつ、その間隔のうちどれだけ経過したかを毎フレーム計算し直してリングを描き直します。02のcurrentIntervalは同じ種類の間隔自体を縮め、START * DECAY^ticksをティックごとに求め直すことで、押され続ける時間が長いほど変化までの待ち時間が短くなっていきます。04のapplyDeltaはタイマーでは一切発火せず、ホイールの動きを残余値に積み上げ、その残余値がTHRESHを超えたときだけ1段コミットします。
06・07・08が経過時間の比率ではなく、跳躍幅・桁位置・反転角度に紐づく値を計算する理由
06・07・08はそれぞれ単純な経過時間の比率とは別のものに動きを紐づけます。06のjumpは跳躍の大きさそのものの対数 — Math.log2(1 + Math.abs(delta)) — を使って残像がどれだけ長く、どれだけ長い時間かけてスライドするかを決めるので、Home/Endのような大きな跳躍は矢印キー1回分より長く尾を引いて消え、増減する単位そのものはShiftキーによる1か10かの単純な選択のままです。07のstartColumnRollは桁の位置にCASCADEを掛けて自分のロール開始を遅らせるので、1の位の列は必ず1000の位の列より先にロールを始めます。08のflipToggleは反転角度のコサインの絶対値からカードがどれだけ薄くなるかを求め、その薄さが90度の地点でゼロに達した瞬間に単位を切り替えます。
パターン別コード
01長押しリピート — 押された状態が続く間、一定間隔で値が変わり続けるステッパー
ボタンが押された状態になると、在庫数量の値はINTERVALごとに1ずつ変わり続け、それとは別にリングの再描画が次のティックまでの近さを毎フレーム計算し直します。

<main class="stage">
<div class="nsphr is-demo" id="nsphr">
<div class="nsphr__panel">
<span class="nsphr__label">在庫数量</span>
<div class="nsphr__row">
<button class="nsphr__btn nsphr__btn--minus" id="nsphrMinus" type="button" aria-label="数量を減らす">
<svg class="nsphr__ring" viewBox="0 0 32 32" aria-hidden="true">
<circle class="nsphr__ringTrack" cx="16" cy="16" r="14"></circle>
<circle class="nsphr__ringFill" id="nsphrRingMinus" cx="16" cy="16" r="14"></circle>
</svg>
<span>-</span>
</button>
<span class="nsphr__value" id="nsphrValue">12</span>
<button class="nsphr__btn nsphr__btn--plus" id="nsphrPlus" type="button" aria-label="数量を増やす">
<svg class="nsphr__ring" viewBox="0 0 32 32" aria-hidden="true">
<circle class="nsphr__ringTrack" cx="16" cy="16" r="14"></circle>
<circle class="nsphr__ringFill" id="nsphrRingPlus" cx="16" cy="16" r="14"></circle>
</svg>
<span>+</span>
</button>
</div>
<span class="nsphr__hint" id="nsphrHint">押し続けると220msごとに1ずつ変わります</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 반복 간격(220ms)은 고정이지만 다음 반복까지 남은 시간을 매 프레임 계산해
// 버튼 둘레 링(stroke-dashoffset)에 대입한다 — 정적 트윈이 아니라 실시간 카운트다운이다.
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-ink);
.stage { position: relative; }
.nsphr {
width: min(240px, 70vw);
}
.nsphr__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
padding: $sp-5;
border-radius: $r-card;
background: $stage-paper;
}
.nsphr__label {
color: rgba($subject-ink, .72);
font-weight: 700;
font-size: 12px;
}
.nsphr__row {
display: flex;
align-items: center;
gap: $sp-4;
}
.nsphr__btn {
position: relative;
width: 40px;
height: 40px;
border: 0;
border-radius: $r-pill;
background: $color;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 18px;
line-height: 1;
display: grid;
place-items: center;
cursor: pointer;
touch-action: none;
}
.nsphr__ring {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
transform: rotate(-90deg);
pointer-events: none;
}
.nsphr__ringTrack {
fill: none;
stroke: rgba($subject-cream, .28);
stroke-width: 2;
}
.nsphr__ringFill {
fill: none;
stroke: $subject-cream;
stroke-width: 2;
stroke-linecap: round;
}
.nsphr__value {
min-width: 48px;
text-align: center;
color: $subject-ink;
font-weight: 700;
font-size: 28px;
font-variant-numeric: tabular-nums;
}
.nsphr__hint {
color: rgba($subject-ink, .72);
font-size: 11px;
text-align: center;
}
@media (max-width: 340px) {
.nsphr__panel { padding: $sp-4; gap: $sp-2; }
.nsphr__btn { width: 36px; height: 36px; font-size: 16px; }
.nsphr__value { font-size: 24px; }
}
@media (prefers-reduced-motion: reduce) {
.nsphr__ringFill { 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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.nsphr {
width: min(240px, 70vw);
}
.nsphr__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px;
border-radius: 16px;
background: #ffffff;
}
.nsphr__label {
color: rgba(23, 20, 26, 0.72);
font-weight: 700;
font-size: 12px;
}
.nsphr__row {
display: flex;
align-items: center;
gap: 16px;
}
.nsphr__btn {
position: relative;
width: 40px;
height: 40px;
border: 0;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 18px;
line-height: 1;
display: grid;
place-items: center;
cursor: pointer;
touch-action: none;
}
.nsphr__ring {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
transform: rotate(-90deg);
pointer-events: none;
}
.nsphr__ringTrack {
fill: none;
stroke: rgba(255, 247, 230, 0.28);
stroke-width: 2;
}
.nsphr__ringFill {
fill: none;
stroke: #fff7e6;
stroke-width: 2;
stroke-linecap: round;
}
.nsphr__value {
min-width: 48px;
text-align: center;
color: #17141a;
font-weight: 700;
font-size: 28px;
font-variant-numeric: tabular-nums;
}
.nsphr__hint {
color: rgba(23, 20, 26, 0.72);
font-size: 11px;
text-align: center;
}
@media (max-width: 340px) {
.nsphr__panel {
padding: 16px;
gap: 8px;
}
.nsphr__btn {
width: 36px;
height: 36px;
font-size: 16px;
}
.nsphr__value {
font-size: 24px;
}
}
@media (prefers-reduced-motion: reduce) {
.nsphr__ringFill {
transition: none !important;
}
}(function () {
var root = document.getElementById('nsphr');
var valueEl = document.getElementById('nsphrValue');
var minusBtn = document.getElementById('nsphrMinus');
var plusBtn = document.getElementById('nsphrPlus');
var ringMinus = document.getElementById('nsphrRingMinus');
var ringPlus = document.getElementById('nsphrRingPlus');
var CIRC = 2 * Math.PI * 14;
[ringMinus, ringPlus].forEach(function (r) { r.style.strokeDasharray = CIRC.toFixed(2); });
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();
}
// 계산 겹: 반복 간격 자체는 고정(220ms)이지만, 다음 반복까지 남은 시간을 매 프레임 다시 계산해
// 링(stroke-dashoffset)에 대입한다 — 정적 트윈이 아니라 "다음 틱까지 몇 %"를 실시간으로 그린다.
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);
}
requestAnimationFrame(ringFrame);
function start(dir) {
stop();
held = dir > 0 ? 'plus' : 'minus';
step(dir);
delayTimer = setTimeout(function () {
repeatTimer = setInterval(function () { step(dir); }, INTERVAL);
}, DELAY);
}
function stop() {
held = null;
if (delayTimer) { clearTimeout(delayTimer); delayTimer = null; }
if (repeatTimer) { clearInterval(repeatTimer); repeatTimer = null; }
}
function wireReal(btn, dir) {
btn.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo'); stopAutoplay();
e.preventDefault(); start(dir);
});
['pointerup', 'pointerleave', 'pointercancel'].forEach(function (ev) {
btn.addEventListener(ev, stop);
});
}
wireReal(minusBtn, -1);
wireReal(plusBtn, 1);
var autoplayTimer = null;
function autoplayCycle() {
start(1);
setTimeout(stop, 1000);
setTimeout(function () { start(-1); }, 1400);
setTimeout(stop, 2400);
}
function stopAutoplay() { if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) {
autoplayCycle();
autoplayTimer = setInterval(autoplayCycle, 2800);
}
render();
})();import { useEffect, useRef } from "react";
import styles from "./NumberStepperPressHoldRepeat.module.scss";
export type NumberStepperPressHoldRepeatProps = {
color?: string;
};
export default function NumberStepperPressHoldRepeat({
color = "#0a4fe8",
}: NumberStepperPressHoldRepeatProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const valueEl = root.querySelector<HTMLSpanElement>(`.${styles.value}`);
const minusBtn = root.querySelector<HTMLButtonElement>(`.${styles.btnMinus}`);
const plusBtn = root.querySelector<HTMLButtonElement>(`.${styles.btnPlus}`);
const ringMinus = root.querySelector<SVGCircleElement>(`.${styles.ringMinus}`);
const ringPlus = root.querySelector<SVGCircleElement>(`.${styles.ringPlus}`);
if (!valueEl || !minusBtn || !plusBtn || !ringMinus || !ringPlus) return;
const CIRC = 2 * Math.PI * 14;
[ringMinus, ringPlus].forEach((r) => { r.style.strokeDasharray = CIRC.toFixed(2); });
const MIN = 0, MAX = 30, DELAY = 380, INTERVAL = 220;
let value = 12;
let held: "plus" | "minus" | null = null;
let lastTick = 0;
let repeatTimer: ReturnType<typeof setInterval> | null = null;
let delayTimer: ReturnType<typeof setTimeout> | null = null;
let ringRaf: number | null = null;
function render() { valueEl!.textContent = String(value); }
function step(dir: number) {
value = Math.max(MIN, Math.min(MAX, value + dir));
render();
lastTick = performance.now();
}
// 계산 겹: 반복 간격(220ms)은 고정이지만 다음 반복까지 남은 시간을 매 프레임 계산해
// 링(stroke-dashoffset)에 대입한다 — 정적 트윈이 아니라 실시간 카운트다운이다.
function ringFrame() {
const now = performance.now();
const p = held ? Math.min(1, (now - lastTick) / INTERVAL) : 0;
const offset = CIRC * (1 - p);
ringMinus!.style.strokeDashoffset = (held === "minus" ? offset : CIRC).toFixed(2);
ringPlus!.style.strokeDashoffset = (held === "plus" ? offset : CIRC).toFixed(2);
ringRaf = requestAnimationFrame(ringFrame);
}
ringRaf = requestAnimationFrame(ringFrame);
function start(dir: number) {
stop();
held = dir > 0 ? "plus" : "minus";
step(dir);
delayTimer = setTimeout(() => {
repeatTimer = setInterval(() => step(dir), INTERVAL);
}, DELAY);
}
function stop() {
held = null;
if (delayTimer) { clearTimeout(delayTimer); delayTimer = null; }
if (repeatTimer) { clearInterval(repeatTimer); repeatTimer = null; }
}
function stopAutoplay() {
if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; }
}
function wireReal(btn: HTMLButtonElement, dir: number) {
const onDown = (e: PointerEvent) => {
root!.classList.remove(styles.isDemo);
stopAutoplay();
e.preventDefault();
start(dir);
};
btn.addEventListener("pointerdown", onDown);
["pointerup", "pointerleave", "pointercancel"].forEach((ev) => btn.addEventListener(ev, stop));
return () => {
btn.removeEventListener("pointerdown", onDown);
["pointerup", "pointerleave", "pointercancel"].forEach((ev) => btn.removeEventListener(ev, stop));
};
}
const cleanMinus = wireReal(minusBtn, -1);
const cleanPlus = wireReal(plusBtn, 1);
let autoplayTimer: ReturnType<typeof setInterval> | null = null;
function autoplayCycle() {
start(1);
setTimeout(stop, 1000);
setTimeout(() => start(-1), 1400);
setTimeout(stop, 2400);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) {
autoplayCycle();
autoplayTimer = setInterval(autoplayCycle, 2800);
}
render();
return () => {
stopAutoplay();
stop();
if (ringRaf) cancelAnimationFrame(ringRaf);
cleanMinus();
cleanPlus();
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.isDemo}`} style={{ ["--color" as string]: color }}>
<div className={styles.panel}>
<span className={styles.label}>재고 수량</span>
<div className={styles.row}>
<button className={`${styles.btn} ${styles.btnMinus}`} type="button" aria-label="수량 줄이기">
<svg className={styles.ring} viewBox="0 0 32 32" aria-hidden="true">
<circle className={styles.ringTrack} cx={16} cy={16} r={14} />
<circle className={`${styles.ringFill} ${styles.ringMinus}`} cx={16} cy={16} r={14} />
</svg>
<span>-</span>
</button>
<span className={styles.value}>12</span>
<button className={`${styles.btn} ${styles.btnPlus}`} type="button" aria-label="수량 늘리기">
<svg className={styles.ring} viewBox="0 0 32 32" aria-hidden="true">
<circle className={styles.ringTrack} cx={16} cy={16} r={14} />
<circle className={`${styles.ringFill} ${styles.ringPlus}`} cx={16} cy={16} r={14} />
</svg>
<span>+</span>
</button>
</div>
<span className={styles.hint}>누르고 있으면 220ms마다 1씩 바뀝니다</span>
</div>
</div>
);
}01 · 長押しリピート — 押された状態が続く間、一定間隔で値が変わり続けるステッパー — 計測 累積面積 0.705% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が示すもの
23フレーム中14フレームで動きがあり、累積面積は0.71%、強度は136.2です — 表示が二桁の数字と細いリングの線だけなので場面系の項目ほど広くはならず、DELAY(380ms)とINTERVAL(220ms)という2つの定数が触れるのは2つの小さなSVGリングとテキストノードだけです。
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();
}
// 計算層: 繰り返し間隔自体はINTERVALで固定だが、次のティックまでの残り時間を毎フレーム再計算し
// リングのstroke-dashoffsetに反映する — 静的なトゥイーンではない。
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);
}
02長押し加速 — 押されている時間が長くなるほど間隔の値が短くなるステッパー
ボタンを押し続けると間隔の値が指数曲線に沿って徐々に短くなり下限に近づくため、同じ1秒の押し続けでも後半のほうが前半よりずっと多くのポイントが動きます。

<main class="stage">
<div class="nspla is-demo" id="nspla">
<div class="nspla__panel">
<span class="nspla__label">保有ポイント</span>
<div class="nspla__row">
<button class="nspla__btn" id="nsplaMinus" type="button" aria-label="ポイントを減らす">-</button>
<span class="nspla__value" id="nsplaValue">80</span>
<button class="nspla__btn" id="nsplaPlus" type="button" aria-label="ポイントを増やす">+</button>
</div>
<div class="nspla__meter">
<span class="nspla__meterFill" id="nsplaMeterFill"></span>
</div>
<span class="nspla__hint">長く押すほど間隔が短くなります</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 반복 간격을 START*DECAY^ticks 로 매 틱 다시 계산한다(지수 감쇠, 바닥 FLOOR) —
// 고정 주기가 아니라 누른 시간에 따라 간격 자체가 줄어든다. 속도 막대는 그 간격을 scaleX 로 보여준다.
$duration: $dur-instant;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-yellow);
.stage { position: relative; }
.nspla {
width: min(240px, 70vw);
}
.nspla__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
padding: $sp-5;
border-radius: $r-card;
background: $stage-paper;
}
.nspla__label {
color: rgba($subject-ink, .72);
font-weight: 700;
font-size: 12px;
}
.nspla__row {
display: flex;
align-items: center;
gap: $sp-4;
}
.nspla__btn {
width: 36px;
height: 36px;
border: 0;
border-radius: $r-pill;
background: $color;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 16px;
line-height: 1;
display: grid;
place-items: center;
cursor: pointer;
touch-action: none;
}
.nspla__value {
min-width: 64px;
text-align: center;
color: $subject-ink;
font-weight: 700;
font-size: 26px;
font-variant-numeric: tabular-nums;
}
.nspla__meter {
width: 100%;
height: $sp-1;
border-radius: $r-xs;
background: rgba($subject-ink, .12);
overflow: hidden;
}
.nspla__meterFill {
display: block;
width: 100%;
height: 100%;
border-radius: $r-xs;
background: $stage-orange;
transform: scaleX(0);
transform-origin: left center;
}
.nspla__hint {
color: rgba($subject-ink, .72);
font-size: 11px;
text-align: center;
}
@media (max-width: 340px) {
.nspla__panel { padding: $sp-4; gap: $sp-2; }
.nspla__btn { width: 32px; height: 32px; font-size: 14px; }
.nspla__value { font-size: 22px; }
}
@media (prefers-reduced-motion: reduce) {
.nspla__meterFill { 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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.nspla {
width: min(240px, 70vw);
}
.nspla__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px;
border-radius: 16px;
background: #ffffff;
}
.nspla__label {
color: rgba(23, 20, 26, 0.72);
font-weight: 700;
font-size: 12px;
}
.nspla__row {
display: flex;
align-items: center;
gap: 16px;
}
.nspla__btn {
width: 36px;
height: 36px;
border: 0;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 16px;
line-height: 1;
display: grid;
place-items: center;
cursor: pointer;
touch-action: none;
}
.nspla__value {
min-width: 64px;
text-align: center;
color: #17141a;
font-weight: 700;
font-size: 26px;
font-variant-numeric: tabular-nums;
}
.nspla__meter {
width: 100%;
height: 4px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.12);
overflow: hidden;
}
.nspla__meterFill {
display: block;
width: 100%;
height: 100%;
border-radius: 4px;
background: #ff4d1f;
transform: scaleX(0);
transform-origin: left center;
}
.nspla__hint {
color: rgba(23, 20, 26, 0.72);
font-size: 11px;
text-align: center;
}
@media (max-width: 340px) {
.nspla__panel {
padding: 16px;
gap: 8px;
}
.nspla__btn {
width: 32px;
height: 32px;
font-size: 14px;
}
.nspla__value {
font-size: 22px;
}
}
@media (prefers-reduced-motion: reduce) {
.nspla__meterFill {
transition: none !important;
}
}(function () {
var root = document.getElementById('nspla');
var valueEl = document.getElementById('nsplaValue');
var minusBtn = document.getElementById('nsplaMinus');
var plusBtn = document.getElementById('nsplaPlus');
var meterFill = document.getElementById('nsplaMeterFill');
var MIN = 0, MAX = 500, START = 480, FLOOR = 60, DECAY = 0.82;
var value = 80;
var timer = null;
var ticks = 0;
function render() { valueEl.textContent = String(value); }
// 계산 겹: 반복 간격을 START*DECAY^ticks 로 매 틱 다시 계산한다(지수 감쇠, 바닥은 FLOOR) —
// setInterval 고정 주기가 아니라 재귀 setTimeout 으로 간격 자체를 갱신한다.
function currentInterval() {
return Math.max(FLOOR, START * Math.pow(DECAY, ticks));
}
function paintMeter() {
var iv = currentInterval();
var speed = (START - iv) / (START - FLOOR); // 0(느림) ~ 1(가장 빠름)
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());
}
function start(dir) {
stop();
ticks = 0;
value = Math.max(MIN, Math.min(MAX, value + dir * 5));
render();
paintMeter();
scheduleNext(dir);
}
function stop() {
if (timer) { clearTimeout(timer); timer = null; }
ticks = 0;
meterFill.style.transform = 'scaleX(0)';
}
function wireReal(btn, dir) {
btn.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo'); stopAutoplay();
e.preventDefault(); start(dir);
});
['pointerup', 'pointerleave', 'pointercancel'].forEach(function (ev) {
btn.addEventListener(ev, stop);
});
}
wireReal(minusBtn, -1);
wireReal(plusBtn, 1);
var autoplayTimer = null;
function autoplayCycle() {
start(1);
setTimeout(stop, 1900);
setTimeout(function () { start(-1); }, 2300);
setTimeout(stop, 4200);
}
function stopAutoplay() { if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) {
autoplayCycle();
autoplayTimer = setInterval(autoplayCycle, 4700);
}
render();
})();import { useEffect, useRef } from "react";
import styles from "./NumberStepperLongpressAcceleration.module.scss";
export type NumberStepperLongpressAccelerationProps = {
color?: string;
};
export default function NumberStepperLongpressAcceleration({
color = "#0a4fe8",
}: NumberStepperLongpressAccelerationProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const valueEl = root.querySelector<HTMLSpanElement>(`.${styles.value}`);
const minusBtn = root.querySelector<HTMLButtonElement>(`.${styles.btnMinus}`);
const plusBtn = root.querySelector<HTMLButtonElement>(`.${styles.btnPlus}`);
const meterFill = root.querySelector<HTMLSpanElement>(`.${styles.meterFill}`);
if (!valueEl || !minusBtn || !plusBtn || !meterFill) return;
const MIN = 0, MAX = 500, START = 480, FLOOR = 60, DECAY = 0.82;
let value = 80;
let timer: ReturnType<typeof setTimeout> | null = null;
let ticks = 0;
function render() { valueEl!.textContent = String(value); }
// 계산 겹: 반복 간격을 START*DECAY^ticks 로 매 틱 다시 계산한다(지수 감쇠, 바닥은 FLOOR) —
// setInterval 고정 주기가 아니라 재귀 setTimeout 으로 간격 자체를 갱신한다.
function currentInterval() {
return Math.max(FLOOR, START * Math.pow(DECAY, ticks));
}
function paintMeter() {
const iv = currentInterval();
const speed = (START - iv) / (START - FLOOR);
meterFill!.style.transform = "scaleX(" + speed.toFixed(3) + ")";
}
function scheduleNext(dir: number) {
timer = setTimeout(() => {
value = Math.max(MIN, Math.min(MAX, value + dir * 5));
render();
ticks += 1;
paintMeter();
scheduleNext(dir);
}, currentInterval());
}
function start(dir: number) {
stop();
ticks = 0;
value = Math.max(MIN, Math.min(MAX, value + dir * 5));
render();
paintMeter();
scheduleNext(dir);
}
function stop() {
if (timer) { clearTimeout(timer); timer = null; }
ticks = 0;
meterFill!.style.transform = "scaleX(0)";
}
function stopAutoplay() {
if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; }
}
function wireReal(btn: HTMLButtonElement, dir: number) {
const onDown = (e: PointerEvent) => {
root!.classList.remove(styles.isDemo);
stopAutoplay();
e.preventDefault();
start(dir);
};
btn.addEventListener("pointerdown", onDown);
["pointerup", "pointerleave", "pointercancel"].forEach((ev) => btn.addEventListener(ev, stop));
return () => {
btn.removeEventListener("pointerdown", onDown);
["pointerup", "pointerleave", "pointercancel"].forEach((ev) => btn.removeEventListener(ev, stop));
};
}
const cleanMinus = wireReal(minusBtn, -1);
const cleanPlus = wireReal(plusBtn, 1);
let autoplayTimer: ReturnType<typeof setInterval> | null = null;
function autoplayCycle() {
start(1);
setTimeout(stop, 1900);
setTimeout(() => start(-1), 2300);
setTimeout(stop, 4200);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) {
autoplayCycle();
autoplayTimer = setInterval(autoplayCycle, 4700);
}
render();
return () => {
stopAutoplay();
stop();
cleanMinus();
cleanPlus();
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.isDemo}`} style={{ ["--color" as string]: color }}>
<div className={styles.panel}>
<span className={styles.label}>보유 포인트</span>
<div className={styles.row}>
<button className={`${styles.btn} ${styles.btnMinus}`} type="button" aria-label="포인트 줄이기">-</button>
<span className={styles.value}>80</span>
<button className={`${styles.btn} ${styles.btnPlus}`} type="button" aria-label="포인트 늘리기">+</button>
</div>
<div className={styles.meter}>
<span className={styles.meterFill} />
</div>
<span className={styles.hint}>오래 누를수록 간격이 짧아집니다</span>
</div>
</div>
);
}02 · 長押し加速 — 押されている時間が長くなるほど間隔の値が短くなるステッパー — 計測 累積面積 0.872% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が示すもの
23フレーム中8フレームで動きがあり、累積面積0.87%・強度171.0で、paintMeterがティックごとにバーの塗りを計算し直す分、01の単純なリング再描画よりわずかに上回ります。
var timer = null;
var ticks = 0;
function render() { valueEl.textContent = String(value); }
// 計算層: 間隔をティックごとにSTART*DECAY^ticksで再計算する(指数減衰、下限はFLOOR) —
// setIntervalの固定周期ではなく、再帰的なsetTimeoutで間隔自体を更新する。
function currentInterval() {
return Math.max(FLOOR, START * Math.pow(DECAY, ticks));
}
function paintMeter() {
var iv = currentInterval();
var speed = (START - iv) / (START - FLOOR); // 0(最も遅い)〜1(最も速い)
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());
}
03限界バウンス — 値が上限や下限を少し超えてからばね曲線に沿って戻るステッパー
座席数の値が上限や下限に達した状態になると、値はそこで止まらず境界を少し超えた位置まで進んでから、減衰する正弦波に沿って境界の値へ戻ります。

<main class="stage">
<div class="nspcb is-demo" id="nspcb">
<div class="nspcb__panel">
<span class="nspcb__label">座席数(0〜8)</span>
<div class="nspcb__row">
<button class="nspcb__btn" id="nspcbMinus" type="button" aria-label="座席を減らす">-</button>
<span class="nspcb__valueWrap">
<span class="nspcb__edge" id="nspcbEdgeLeft"></span>
<span class="nspcb__value" id="nspcbValue">4</span>
<span class="nspcb__edge" id="nspcbEdgeRight"></span>
</span>
<button class="nspcb__btn" id="nspcbPlus" type="button" aria-label="座席を増やす">+</button>
</div>
<span class="nspcb__hint">境界に触れると少し行き過ぎてから戻ります</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 경계(0·8)에 닿는 순간부터 지난 시간(t)에 감쇠 사인파를 매 프레임 계산해
// translateX 에 대입한다 — 목표를 살짝 넘었다가 스프링 곡선을 따라 되돌아온다.
$duration: $dur-quick;
$easing: $ease-elastic;
$color: $error;
@include stage($stage-orange);
.stage { position: relative; }
.nspcb {
width: min(240px, 70vw);
}
.nspcb__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
padding: $sp-5;
border-radius: $r-card;
background: $stage-paper;
}
.nspcb__label {
color: rgba($subject-ink, .72);
font-weight: 700;
font-size: 12px;
}
.nspcb__row {
display: flex;
align-items: center;
gap: $sp-4;
}
.nspcb__btn {
width: 36px;
height: 36px;
border: 0;
border-radius: $r-pill;
background: $subject-blue-deep;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 16px;
line-height: 1;
display: grid;
place-items: center;
cursor: pointer;
}
.nspcb__valueWrap {
position: relative;
display: inline-flex;
align-items: center;
min-width: 56px;
justify-content: center;
will-change: transform;
}
.nspcb__value {
color: $subject-ink;
font-weight: 700;
font-size: 26px;
font-variant-numeric: tabular-nums;
}
.nspcb__edge {
position: absolute;
top: -6px;
bottom: -6px;
width: 4px;
border-radius: $r-xs;
background: $color;
opacity: 0;
}
.nspcb__edge:first-child { left: -14px; }
.nspcb__edge:last-child { right: -14px; }
.nspcb__hint {
color: rgba($subject-ink, .72);
font-size: 11px;
text-align: center;
}
@media (max-width: 340px) {
.nspcb__panel { padding: $sp-4; gap: $sp-2; }
.nspcb__btn { width: 32px; height: 32px; font-size: 14px; }
.nspcb__value { font-size: 22px; }
}
@media (prefers-reduced-motion: reduce) {
.nspcb__valueWrap { 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: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.nspcb {
width: min(240px, 70vw);
}
.nspcb__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px;
border-radius: 16px;
background: #ffffff;
}
.nspcb__label {
color: rgba(23, 20, 26, 0.72);
font-weight: 700;
font-size: 12px;
}
.nspcb__row {
display: flex;
align-items: center;
gap: 16px;
}
.nspcb__btn {
width: 36px;
height: 36px;
border: 0;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 16px;
line-height: 1;
display: grid;
place-items: center;
cursor: pointer;
}
.nspcb__valueWrap {
position: relative;
display: inline-flex;
align-items: center;
min-width: 56px;
justify-content: center;
will-change: transform;
}
.nspcb__value {
color: #17141a;
font-weight: 700;
font-size: 26px;
font-variant-numeric: tabular-nums;
}
.nspcb__edge {
position: absolute;
top: -6px;
bottom: -6px;
width: 4px;
border-radius: 4px;
background: #c2361a;
opacity: 0;
}
.nspcb__edge:first-child {
left: -14px;
}
.nspcb__edge:last-child {
right: -14px;
}
.nspcb__hint {
color: rgba(23, 20, 26, 0.72);
font-size: 11px;
text-align: center;
}
@media (max-width: 340px) {
.nspcb__panel {
padding: 16px;
gap: 8px;
}
.nspcb__btn {
width: 32px;
height: 32px;
font-size: 14px;
}
.nspcb__value {
font-size: 22px;
}
}
@media (prefers-reduced-motion: reduce) {
.nspcb__valueWrap {
transition: none !important;
}
}(function () {
var root = document.getElementById('nspcb');
var valueEl = document.getElementById('nspcbValue');
var wrapEl = valueEl.parentNode;
var minusBtn = document.getElementById('nspcbMinus');
var plusBtn = document.getElementById('nspcbPlus');
var edgeLeft = document.getElementById('nspcbEdgeLeft');
var edgeRight = document.getElementById('nspcbEdgeRight');
var MIN = 0, MAX = 8;
var value = 4;
var raf = null;
function render() { valueEl.textContent = String(value); }
// 계산 겹: 경계에 닿는 순간부터 지난 시간(t)에 감쇠 사인파를 매 프레임 계산해
// translateX 에 대입한다 — 목표 지점을 살짝 넘었다가 스프링 곡선으로 되돌아온다.
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);
}
function bounce(dir) {
var edge = dir > 0 ? edgeRight : edgeLeft;
var start = performance.now();
if (raf) { cancelAnimationFrame(raf); }
function frame(now) {
var t = (now - start) / 1000;
var px = dir * overshoot(t);
wrapEl.style.transform = 'translateX(' + px.toFixed(2) + 'px)';
var op = Math.max(0, 1 - t / 0.4);
edge.style.opacity = op.toFixed(3);
if (t < 0.45) {
raf = requestAnimationFrame(frame);
} else {
wrapEl.style.transform = 'translateX(0px)';
edge.style.opacity = '0';
raf = null;
}
}
raf = requestAnimationFrame(frame);
}
function step(dir) {
var next = value + dir;
if (next > MAX || next < MIN) {
bounce(dir);
return;
}
value = next;
render();
}
function wireReal(btn, dir) {
btn.addEventListener('click', function () {
root.classList.remove('is-demo'); stopAutoplay();
step(dir);
});
}
wireReal(minusBtn, -1);
wireReal(plusBtn, 1);
var seq = [1, 1, 1, 1, 1, 1, 1, -1, -1, -1, -1, -1, -1, -1, -1, -1];
var idx = 0;
var autoplayTimer = null;
function autoplayTick() {
step(seq[idx % seq.length]);
idx += 1;
}
function stopAutoplay() { if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) {
autoplayTimer = setInterval(autoplayTick, 260);
}
render();
})();import { useEffect, useRef } from "react";
import styles from "./NumberStepperMinMaxClampBounce.module.scss";
export type NumberStepperMinMaxClampBounceProps = {
color?: string;
};
export default function NumberStepperMinMaxClampBounce({
color = "#c2361a",
}: NumberStepperMinMaxClampBounceProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const valueEl = root.querySelector<HTMLSpanElement>(`.${styles.value}`);
const wrapEl = root.querySelector<HTMLSpanElement>(`.${styles.valueWrap}`);
const minusBtn = root.querySelector<HTMLButtonElement>(`.${styles.btnMinus}`);
const plusBtn = root.querySelector<HTMLButtonElement>(`.${styles.btnPlus}`);
const edgeLeft = root.querySelector<HTMLSpanElement>(`.${styles.edgeLeft}`);
const edgeRight = root.querySelector<HTMLSpanElement>(`.${styles.edgeRight}`);
if (!valueEl || !wrapEl || !minusBtn || !plusBtn || !edgeLeft || !edgeRight) return;
const MIN = 0, MAX = 8;
let value = 4;
let raf: number | null = null;
function render() { valueEl!.textContent = String(value); }
// 계산 겹: 경계에 닿는 순간부터 지난 시간(t)에 감쇠 사인파를 매 프레임 계산해
// translateX 에 대입한다 — 목표 지점을 살짝 넘었다가 스프링 곡선으로 되돌아온다.
const AMPLITUDE = 10, FREQ = 16, DECAY = 10;
function overshoot(tSec: number) {
if (tSec <= 0) { return 0; }
return AMPLITUDE * Math.sin(tSec * FREQ) * Math.exp(-DECAY * tSec);
}
function bounce(dir: number) {
const edge = dir > 0 ? edgeRight! : edgeLeft!;
const start = performance.now();
if (raf) { cancelAnimationFrame(raf); }
function frame(now: number) {
const t = (now - start) / 1000;
const px = dir * overshoot(t);
wrapEl!.style.transform = "translateX(" + px.toFixed(2) + "px)";
const op = Math.max(0, 1 - t / 0.4);
edge.style.opacity = op.toFixed(3);
if (t < 0.45) {
raf = requestAnimationFrame(frame);
} else {
wrapEl!.style.transform = "translateX(0px)";
edge.style.opacity = "0";
raf = null;
}
}
raf = requestAnimationFrame(frame);
}
function step(dir: number) {
const next = value + dir;
if (next > MAX || next < MIN) {
bounce(dir);
return;
}
value = next;
render();
}
function wireReal(btn: HTMLButtonElement, dir: number) {
const onClick = () => {
root!.classList.remove(styles.isDemo);
stopAutoplay();
step(dir);
};
btn.addEventListener("click", onClick);
return () => btn.removeEventListener("click", onClick);
}
const cleanMinus = wireReal(minusBtn, -1);
const cleanPlus = wireReal(plusBtn, 1);
const seq = [1, 1, 1, 1, 1, 1, 1, -1, -1, -1, -1, -1, -1, -1, -1, -1];
let idx = 0;
let autoplayTimer: ReturnType<typeof setInterval> | null = null;
function autoplayTick() {
step(seq[idx % seq.length]);
idx += 1;
}
function stopAutoplay() {
if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; }
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) {
autoplayTimer = setInterval(autoplayTick, 260);
}
render();
return () => {
stopAutoplay();
if (raf) cancelAnimationFrame(raf);
cleanMinus();
cleanPlus();
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.isDemo}`} style={{ ["--color" as string]: color }}>
<div className={styles.panel}>
<span className={styles.label}>좌석 수 (0~8)</span>
<div className={styles.row}>
<button className={`${styles.btn} ${styles.btnMinus}`} type="button" aria-label="좌석 줄이기">-</button>
<span className={styles.valueWrap}>
<span className={`${styles.edge} ${styles.edgeLeft}`} />
<span className={styles.value}>4</span>
<span className={`${styles.edge} ${styles.edgeRight}`} />
</span>
<button className={`${styles.btn} ${styles.btnPlus}`} type="button" aria-label="좌석 늘리기">+</button>
</div>
<span className={styles.hint}>경계에 닿으면 살짝 넘었다가 되돌아옵니다</span>
</div>
</div>
);
}03 · 限界バウンス — 値が上限や下限を少し超えてからばね曲線に沿って戻るステッパー — 計測 累積面積 0.547% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が示すもの
23フレーム中15フレームで動きがあり、この9つの中で最も多く、累積面積0.55%・強度126.0です — overshootの減衰正弦波が単純なクランプよりずっと長くtranslateXを描き直し続けるためです。
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);
}
function bounce(dir) {
var edge = dir > 0 ? edgeRight : edgeLeft;
var start = performance.now();
if (raf) { cancelAnimationFrame(raf); }
function frame(now) {
var t = (now - start) / 1000;
var px = dir * overshoot(t);
wrapEl.style.transform = 'translateX(' + px.toFixed(2) + 'px)';
var op = Math.max(0, 1 - t / 0.4);
edge.style.opacity = op.toFixed(3);
if (t < 0.45) {
raf = requestAnimationFrame(frame);
} else {
wrapEl.style.transform = 'translateX(0px)';
edge.style.opacity = '0';
raf = null;
}
}
raf = requestAnimationFrame(frame);
}
04ホイールデルタ — 入力欄上のホイール移動の値の分だけ同じ向きに値が変わるステッパー
拡大パネルの上でホイールが動くたびにその値が残余値へ積み上がり、その残余値がTHRESHを超えたときだけ表示されている倍率が実際に動きます。

<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">ホイールの移動量がたまると1段ずつ変わります</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 オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が示すもの
9つの中で最も低い累積面積0.33%ながら23フレーム全部で動きがあるのは、decayFrameがほとんどのフレームで細い塗りバーを1ピクセル未満だけ動かし、数字そのものを丸ごと入れ替えることは少ないためです。
function render() { valueEl.textContent = value + '%'; }
function paintTrack() {
var p = Math.min(1, Math.abs(residual) / THRESH);
fillUp.style.transform = (residual > 0 ? p : 0).toFixed(3);
fillDown.style.transform = (residual < 0 ? p : 0).toFixed(3);
}
// 計算層: ホイールのdeltaをresidualに積み上げ、THRESHを超えた分だけ1段コミットする。
// コミットされなかった残りのresidualは毎フレーム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();
}
05縦ドラッグ慣性 — 引く距離の値に比例して変わり、離した後も数段階分続くステッパー
ハンドルを縦にドラッグするとSTEP_PXごとに体感温度の値が1段変わり、指を離した瞬間に記録された速度が摩擦のループへ引き継がれてさらに数フレーム値を動かし続けます。

<main class="stage">
<div class="nspdi is-demo" id="nspdi">
<div class="nspdi__panel">
<span class="nspdi__label">体感温度</span>
<div class="nspdi__body">
<div class="nspdi__track">
<span class="nspdi__handle" id="nspdiHandle"></span>
</div>
<span class="nspdi__value" id="nspdiValue">20<small>°C</small></span>
</div>
<span class="nspdi__hint">上下にドラッグすると、離した後も慣性で動き続けます</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 손을 놓는 순간의 속도(px/ms)에 지수 마찰을 매 프레임 곱해 감쇠시키고, 감쇠 중 이동한
// 가상 거리를 20px 단위로 값에 계속 반영한다 — 놓은 뒤에도 관성으로 몇 단계 더 이어진다.
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue-deep;
@include stage($stage-ink);
.stage { position: relative; }
.nspdi {
width: min(220px, 68vw);
}
.nspdi__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
padding: $sp-5;
border-radius: $r-card;
background: $stage-paper;
}
.nspdi__label {
color: rgba($subject-ink, .72);
font-weight: 700;
font-size: 12px;
}
.nspdi__body {
display: flex;
align-items: center;
gap: $sp-5;
}
.nspdi__track {
position: relative;
width: 28px;
height: 96px;
border-radius: $r-pill;
background: rgba($subject-ink, .10);
display: grid;
place-items: center;
touch-action: none;
}
.nspdi__handle {
width: 36px;
height: 36px;
border-radius: $r-pill;
background: $color;
box-shadow: $shadow-press;
cursor: grab;
transform: translateY(0);
transition: box-shadow $dur-quick $easing;
}
.nspdi__handle--active {
cursor: grabbing;
box-shadow: $shadow-raised;
}
.nspdi__value {
color: $subject-ink;
font-weight: 700;
font-size: 30px;
font-variant-numeric: tabular-nums;
small { font-size: 16px; font-weight: 700; }
}
.nspdi__hint {
color: rgba($subject-ink, .72);
font-size: 11px;
text-align: center;
}
@media (max-width: 340px) {
.nspdi__panel { padding: $sp-3; gap: $sp-1; }
.nspdi__track { height: 64px; }
.nspdi__value { font-size: 22px; }
.nspdi__hint { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
.nspdi__handle { 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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.nspdi {
width: min(220px, 68vw);
}
.nspdi__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px;
border-radius: 16px;
background: #ffffff;
}
.nspdi__label {
color: rgba(23, 20, 26, 0.72);
font-weight: 700;
font-size: 12px;
}
.nspdi__body {
display: flex;
align-items: center;
gap: 20px;
}
.nspdi__track {
position: relative;
width: 28px;
height: 96px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.1);
display: grid;
place-items: center;
touch-action: none;
}
.nspdi__handle {
width: 36px;
height: 36px;
border-radius: 999px;
background: #0a4fe8;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
cursor: grab;
transform: translateY(0);
transition: box-shadow 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.nspdi__handle--active {
cursor: grabbing;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.nspdi__value {
color: #17141a;
font-weight: 700;
font-size: 30px;
font-variant-numeric: tabular-nums;
}
.nspdi__value small {
font-size: 16px;
font-weight: 700;
}
.nspdi__hint {
color: rgba(23, 20, 26, 0.72);
font-size: 11px;
text-align: center;
}
@media (max-width: 340px) {
.nspdi__panel {
padding: 12px;
gap: 4px;
}
.nspdi__track {
height: 64px;
}
.nspdi__value {
font-size: 22px;
}
.nspdi__hint {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.nspdi__handle {
transition: none !important;
}
}(function () {
var root = document.getElementById('nspdi');
var handle = document.getElementById('nspdiHandle');
var valueEl = document.getElementById('nspdiValue');
var MIN = -10, MAX = 40, STEP_PX = 20, MAX_OFFSET = 34;
var value = 20;
var dragging = false;
var startY = 0, startValue = 0, lastY = 0, lastT = 0, vel = 0;
var inertiaRaf = null;
function render() { valueEl.innerHTML = value + '<small>°C</small>'; }
function setHandle(px) {
var c = Math.max(-MAX_OFFSET, Math.min(MAX_OFFSET, px));
handle.style.transform = 'translateY(' + c.toFixed(1) + 'px)';
}
function beginDrag(y) {
if (inertiaRaf) { cancelAnimationFrame(inertiaRaf); inertiaRaf = null; }
dragging = true;
startY = y; startValue = value; lastY = y; lastT = performance.now(); vel = 0;
handle.classList.add('nspdi__handle--active');
}
function moveDrag(y) {
var now = performance.now();
var dtMove = Math.max(1, now - lastT);
vel = (y - lastY) / dtMove;
lastY = y; lastT = now;
var dy = y - startY;
value = Math.max(MIN, Math.min(MAX, startValue - Math.round(dy / STEP_PX)));
render();
setHandle(dy);
}
// 계산 겹: 놓는 순간의 속도(px/ms)에 지수 마찰(FRICTION)을 매 프레임 곱해 감쇠시키고,
// 감쇠 중 이동한 가상 거리(virtual)를 20px 단위로 값에 계속 반영한다 — 손을 놓아도 몇 단계 더 이어진다.
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);
}
handle.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo'); stopAutoplay();
e.preventDefault();
handle.setPointerCapture(e.pointerId);
beginDrag(e.clientY);
});
handle.addEventListener('pointermove', function (e) { if (dragging) { moveDrag(e.clientY); } });
['pointerup', 'pointercancel'].forEach(function (ev) {
handle.addEventListener(ev, function () { if (dragging) { endDrag(); } });
});
var autoplayTimer = null;
function autoplayFling(vpx) {
vel = vpx;
value = 20;
render();
endDrag();
}
function stopAutoplay() { if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; } }
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (!reduce) {
autoplayFling(-0.85);
autoplayTimer = setInterval(function () {
autoplayFling(Math.random() > 0.5 ? -0.85 : 0.85);
}, 1900);
}
render();
})();import { useEffect, useRef } from "react";
import styles from "./NumberStepperDragVerticalInertia.module.scss";
export type NumberStepperDragVerticalInertiaProps = {
color?: string;
};
export default function NumberStepperDragVerticalInertia({
color = "#0a4fe8",
}: NumberStepperDragVerticalInertiaProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const handle = root.querySelector<HTMLSpanElement>(`.${styles.handle}`);
const valueEl = root.querySelector<HTMLSpanElement>(`.${styles.value}`);
if (!handle || !valueEl) return;
const MIN = -10, MAX = 40, STEP_PX = 20, MAX_OFFSET = 34;
let value = 20;
let dragging = false;
let startY = 0, startValue = 0, lastY = 0, lastT = 0, vel = 0;
let inertiaRaf: number | null = null;
function render() { valueEl!.innerHTML = value + "<small>°C</small>"; }
function setHandle(px: number) {
const c = Math.max(-MAX_OFFSET, Math.min(MAX_OFFSET, px));
handle!.style.transform = "translateY(" + c.toFixed(1) + "px)";
}
function beginDrag(y: number) {
if (inertiaRaf) { cancelAnimationFrame(inertiaRaf); inertiaRaf = null; }
dragging = true;
startY = y; startValue = value; lastY = y; lastT = performance.now(); vel = 0;
handle!.classList.add(styles.handleActive);
}
function moveDrag(y: number) {
const now = performance.now();
const dtMove = Math.max(1, now - lastT);
vel = (y - lastY) / dtMove;
lastY = y; lastT = now;
const dy = y - startY;
value = Math.max(MIN, Math.min(MAX, startValue - Math.round(dy / STEP_PX)));
render();
setHandle(dy);
}
// 계산 겹: 놓는 순간의 속도(px/ms)에 지수 마찰(FRICTION)을 매 프레임 곱해 감쇠시키고,
// 감쇠 중 이동한 가상 거리(virtual)를 20px 단위로 값에 계속 반영한다 — 손을 놓아도 몇 단계 더 이어진다.
const FRICTION = 6.2;
function endDrag() {
dragging = false;
handle!.classList.remove(styles.handleActive);
const baseValue = value;
let virtual = 0;
let v = vel;
let last = performance.now();
function frame(now: number) {
const 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);
}
const onDown = (e: PointerEvent) => {
root!.classList.remove(styles.isDemo);
stopAutoplay();
e.preventDefault();
handle!.setPointerCapture(e.pointerId);
beginDrag(e.clientY);
};
const onMove = (e: PointerEvent) => { if (dragging) { moveDrag(e.clientY); } };
const onUp = () => { if (dragging) { endDrag(); } };
handle.addEventListener("pointerdown", onDown);
handle.addEventListener("pointermove", onMove);
handle.addEventListener("pointerup", onUp);
handle.addEventListener("pointercancel", onUp);
let autoplayTimer: ReturnType<typeof setInterval> | null = null;
function autoplayFling(vpx: number) {
vel = vpx;
value = 20;
render();
endDrag();
}
function stopAutoplay() {
if (autoplayTimer) { clearInterval(autoplayTimer); autoplayTimer = null; }
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (!reduce) {
autoplayFling(-0.85);
autoplayTimer = setInterval(() => {
autoplayFling(Math.random() > 0.5 ? -0.85 : 0.85);
}, 1900);
}
render();
return () => {
stopAutoplay();
if (inertiaRaf) cancelAnimationFrame(inertiaRaf);
handle!.removeEventListener("pointerdown", onDown);
handle!.removeEventListener("pointermove", onMove);
handle!.removeEventListener("pointerup", onUp);
handle!.removeEventListener("pointercancel", onUp);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.isDemo}`} style={{ ["--color" as string]: color }}>
<div className={styles.panel}>
<span className={styles.label}>체감 온도</span>
<div className={styles.body}>
<div className={styles.track}>
<span className={styles.handle} />
</div>
<span className={styles.value}>20<small>°C</small></span>
</div>
<span className={styles.hint}>위아래로 끌면 놓은 뒤에도 관성으로 이어집니다</span>
</div>
</div>
);
}05 · 縦ドラッグ慣性 — 引く距離の値に比例して変わり、離した後も数段階分続くステッパー — 計測 累積面積 3.352% · 動いたフレーム 6/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が示すもの
9つの中で最も広い累積面積3.35%・強度154.7を23フレーム中6フレームで記録しています — ハンドルの縦方向の全移動に加えて指を離した後の慣性まで描き直すため、クリック操作より1フレームあたりのピクセル数が多くなります。
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);
}
06キーボードジャンプ — 矢印キーは1単位、修飾キーを加えるとより大きな単位で値が変わるステッパー
矢印キーを押すとポイントの値が1単位変わり、同じ矢印キーをShiftと一緒に押すと10単位変わり、実際の値の後ろでは直前の値を示す残像がその跳躍の大きさに応じてスライドします。

<main class="stage">
<div class="nspkj is-demo" id="nspkj">
<div class="nspkj__panel">
<span class="nspkj__label">割り当てポイント</span>
<div class="nspkj__stage" tabindex="0" role="spinbutton" aria-valuemin="0" aria-valuemax="200" id="nspkjBox" aria-label="ポイント調整、矢印キーで1ずつ、Shift+矢印キーで10ずつ">
<span class="nspkj__ghost" id="nspkjGhost"></span>
<span class="nspkj__value" id="nspkjValue">40</span>
</div>
<span class="nspkj__hint">↑↓で1ずつ・Shiftで10ずつ・Home/Endで最小最大</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 점프 폭(delta)의 로그값으로 잔상(ghost)의 이동 거리를 계산한다 — 1씩 바뀌면
// 잔상이 살짝, Home/End 처럼 크게 뛰면 더 멀리·더 오래 미끄러지며 사라진다(고정 키프레임 아님).
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue-deep;
@include stage($stage-yellow);
.stage { position: relative; }
.nspkj {
width: min(220px, 68vw);
}
.nspkj__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
padding: $sp-5;
border-radius: $r-card;
background: $stage-paper;
}
.nspkj__label {
color: rgba($subject-ink, .72);
font-weight: 700;
font-size: 12px;
}
.nspkj__stage {
position: relative;
display: grid;
place-items: center;
width: 100%;
height: 64px;
border-radius: $r-control;
background: rgba($subject-ink, .06);
outline: none;
overflow: hidden;
&:focus-visible {
box-shadow: inset 0 0 0 2px $color;
}
}
.nspkj__ghost {
position: absolute;
color: rgba($subject-ink, .40);
font-weight: 700;
font-size: 26px;
font-variant-numeric: tabular-nums;
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
.nspkj__value {
position: relative;
color: $subject-ink;
font-weight: 700;
font-size: 30px;
font-variant-numeric: tabular-nums;
transition: transform $duration $easing;
will-change: transform;
}
.nspkj__hint {
color: rgba($subject-ink, .72);
font-size: 11px;
text-align: center;
}
@media (max-width: 340px) {
.nspkj__panel { padding: $sp-4; gap: $sp-2; }
.nspkj__stage { height: 54px; }
.nspkj__value { font-size: 26px; }
.nspkj__ghost { font-size: 22px; }
}
@media (prefers-reduced-motion: reduce) {
.nspkj__value { transition: none !important; }
.nspkj__ghost { 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: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.nspkj {
width: min(220px, 68vw);
}
.nspkj__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px;
border-radius: 16px;
background: #ffffff;
}
.nspkj__label {
color: rgba(23, 20, 26, 0.72);
font-weight: 700;
font-size: 12px;
}
.nspkj__stage {
position: relative;
display: grid;
place-items: center;
width: 100%;
height: 64px;
border-radius: 12px;
background: rgba(23, 20, 26, 0.06);
outline: none;
overflow: hidden;
}
.nspkj__stage:focus-visible {
box-shadow: inset 0 0 0 2px #0a4fe8;
}
.nspkj__ghost {
position: absolute;
color: rgba(23, 20, 26, 0.4);
font-weight: 700;
font-size: 26px;
font-variant-numeric: tabular-nums;
opacity: 0;
pointer-events: none;
will-change: transform, opacity;
}
.nspkj__value {
position: relative;
color: #17141a;
font-weight: 700;
font-size: 30px;
font-variant-numeric: tabular-nums;
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
will-change: transform;
}
.nspkj__hint {
color: rgba(23, 20, 26, 0.72);
font-size: 11px;
text-align: center;
}
@media (max-width: 340px) {
.nspkj__panel {
padding: 16px;
gap: 8px;
}
.nspkj__stage {
height: 54px;
}
.nspkj__value {
font-size: 26px;
}
.nspkj__ghost {
font-size: 22px;
}
}
@media (prefers-reduced-motion: reduce) {
.nspkj__value {
transition: none !important;
}
.nspkj__ghost {
transition: none !important;
}
}(function () {
var root = document.getElementById('nspkj');
var box = document.getElementById('nspkjBox');
var valueEl = document.getElementById('nspkjValue');
var ghost = document.getElementById('nspkjGhost');
var MIN = 0, MAX = 200;
var value = 40;
var raf = null;
function render() { valueEl.textContent = String(value); }
// 계산 겹: 점프 폭(delta)의 로그값으로 잔상 이동 거리를 계산한다 — 1씩 바뀌면 살짝,
// Home/End 처럼 크게 뛰면 잔상이 더 멀리 더 오래 미끄러지며 사라진다(고정 키프레임이 아니다).
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);
}
function commit(next) {
next = Math.max(MIN, Math.min(MAX, next));
if (next === value) { return; }
var delta = next - value;
var old = value;
value = next;
render();
jump(old, delta);
}
box.addEventListener('keydown', function (e) {
root.classList.remove('is-demo'); stopAutoplay();
if (e.key === 'ArrowUp') { commit(value + (e.shiftKey ? 10 : 1)); e.preventDefault(); }
else if (e.key === 'ArrowDown') { commit(value - (e.shiftKey ? 10 : 1)); e.preventDefault(); }
else if (e.key === 'Home') { commit(MIN); e.preventDefault(); }
else if (e.key === 'End') { commit(MAX); e.preventDefault(); }
});
var seq = [1, 1, 10, -1, -200, 200, -10];
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 () {
var d = seq[idx % seq.length];
idx += 1;
commit(d <= -100 ? MIN : d >= 100 ? MAX : value + d);
}, 620);
}
render();
})();import { useEffect, useRef } from "react";
import styles from "./NumberStepperKeyboardShiftJump.module.scss";
export type NumberStepperKeyboardShiftJumpProps = {
color?: string;
};
export default function NumberStepperKeyboardShiftJump({
color = "#0a4fe8",
}: NumberStepperKeyboardShiftJumpProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const box = root.querySelector<HTMLDivElement>(`.${styles.box}`);
const valueEl = root.querySelector<HTMLSpanElement>(`.${styles.value}`);
const ghost = root.querySelector<HTMLSpanElement>(`.${styles.ghost}`);
if (!box || !valueEl || !ghost) return;
const MIN = 0, MAX = 200;
let value = 40;
let raf: number | null = null;
function render() { valueEl!.textContent = String(value); }
// 계산 겹: 점프 폭(delta)의 로그값으로 잔상 이동 거리를 계산한다 — 1씩 바뀌면 살짝,
// Home/End 처럼 크게 뛰면 잔상이 더 멀리 더 오래 미끄러지며 사라진다(고정 키프레임이 아니다).
function jump(oldVal: number, delta: number) {
ghost!.textContent = String(oldVal);
const dist = Math.min(46, 12 + Math.log2(1 + Math.abs(delta)) * 13);
const dir = delta >= 0 ? -1 : 1;
const dur = Math.min(520, 220 + Math.abs(delta));
const start = performance.now();
if (raf) { cancelAnimationFrame(raf); }
ghost!.style.opacity = "1";
function frame(now: number) {
const t = Math.min(1, (now - start) / dur);
const 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(() => { valueEl!.style.transform = "scale(1)"; }, 30);
}
function commit(next: number) {
next = Math.max(MIN, Math.min(MAX, next));
if (next === value) { return; }
const delta = next - value;
const old = value;
value = next;
render();
jump(old, delta);
}
const onKeydown = (e: KeyboardEvent) => {
root!.classList.remove(styles.isDemo);
stopAutoplay();
if (e.key === "ArrowUp") { commit(value + (e.shiftKey ? 10 : 1)); e.preventDefault(); }
else if (e.key === "ArrowDown") { commit(value - (e.shiftKey ? 10 : 1)); e.preventDefault(); }
else if (e.key === "Home") { commit(MIN); e.preventDefault(); }
else if (e.key === "End") { commit(MAX); e.preventDefault(); }
};
box.addEventListener("keydown", onKeydown);
const seq = [1, 1, 10, -1, -200, 200, -10];
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(() => {
const d = seq[idx % seq.length];
idx += 1;
commit(d <= -100 ? MIN : d >= 100 ? MAX : value + d);
}, 620);
}
render();
return () => {
stopAutoplay();
if (raf) cancelAnimationFrame(raf);
box!.removeEventListener("keydown", onKeydown);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.isDemo}`} style={{ ["--color" as string]: color }}>
<div className={styles.panel}>
<span className={styles.label}>할당 포인트</span>
<div
className={styles.box}
tabIndex={0}
role="spinbutton"
aria-valuemin={0}
aria-valuemax={200}
aria-label="포인트 조절, 화살표는 1씩 Shift+화살표는 10씩"
>
<span className={styles.ghost} />
<span className={styles.value}>40</span>
</div>
<span className={styles.hint}>↑↓ 1씩 · Shift 10씩 · Home/End 끝값</span>
</div>
</div>
);
}06 · キーボードジャンプ — 矢印キーは1単位、修飾キーを加えるとより大きな単位で値が変わるステッパー — 計測 累積面積 0.785% · 動いたフレーム 9/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が示すもの
23フレーム中9フレームで動きがあり、累積面積0.79%・9つの中で最も低い強度59.6です — 残像はMath.log2で求めたスライド距離と持続時間の分だけ動いて消えるため、1単位のジャンプでは位置の変化よりも透明度の変化のほうが目立ちます。
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);
}
07通貨のライブ書式 — 値が変わるたびに桁区切りの位置を求め直すステッパー
値が変わるたびに4つの桁のうちどの列が実際に変わったかが求め直され、変わった列だけが新しい数字へロールし、残りの列はそのまま止まっています。

<main class="stage">
<div class="nspcf is-demo" id="nspcf">
<div class="nspcf__panel">
<span class="nspcf__label">コイン残高</span>
<div class="nspcf__amount">
<span class="nspcf__symbol">◆</span>
<span class="nspcf__col" data-col="0"><span class="nspcf__digit nspcf__digit--old">0</span><span class="nspcf__digit nspcf__digit--new">0</span></span>
<span class="nspcf__comma">,</span>
<span class="nspcf__col" data-col="1"><span class="nspcf__digit nspcf__digit--old">0</span><span class="nspcf__digit nspcf__digit--new">0</span></span>
<span class="nspcf__col" data-col="2"><span class="nspcf__digit nspcf__digit--old">0</span><span class="nspcf__digit nspcf__digit--new">0</span></span>
<span class="nspcf__col" data-col="3"><span class="nspcf__digit nspcf__digit--old">0</span><span class="nspcf__digit nspcf__digit--new">0</span></span>
</div>
<div class="nspcf__row">
<button class="nspcf__btn" id="nspcfMinus" aria-label="250減らす" type="button">−</button>
<button class="nspcf__btn" id="nspcfPlus" aria-label="250増やす" type="button">+</button>
</div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 자릿수(place)가 클수록 굴러가는 시작을 늦추고(delay) 더 오래 돈다(duration) —
// 1의 자리가 먼저 넘어가고 그 여파가 왼쪽 자리로 번지는 기계식 계기판 캐스케이드.
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-orange);
.stage { position: relative; }
.nspcf {
width: min(240px, 70vw);
}
.nspcf__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
padding: $sp-5;
border-radius: $r-card;
background: $stage-paper;
}
.nspcf__label {
color: rgba($subject-ink, .72);
font-weight: 700;
font-size: 12px;
}
.nspcf__amount {
display: flex;
align-items: center;
gap: $sp-1;
}
.nspcf__symbol {
color: $color;
font-weight: 700;
font-size: 22px;
margin-right: $sp-1;
}
.nspcf__col {
position: relative;
display: inline-block;
width: 18px;
height: 32px;
overflow: hidden;
}
.nspcf__digit {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: $subject-ink;
font-weight: 700;
font-size: 26px;
font-variant-numeric: tabular-nums;
will-change: transform;
}
.nspcf__comma {
color: $subject-ink;
font-weight: 700;
font-size: 22px;
transform: translateY(6px);
}
.nspcf__row {
display: flex;
gap: $sp-3;
}
.nspcf__btn {
width: 36px;
height: 36px;
border: 0;
border-radius: $r-pill;
background: $color;
color: $subject-cream;
font-size: 18px;
font-weight: 700;
line-height: 1;
cursor: pointer;
&:active { transform: scale(.92); }
}
@media (max-width: 340px) {
.nspcf__panel { padding: $sp-4; gap: $sp-3; }
.nspcf__symbol { font-size: 18px; }
.nspcf__digit { font-size: 22px; }
.nspcf__col { width: 15px; height: 28px; }
.nspcf__btn { width: 32px; height: 32px; }
}
@media (prefers-reduced-motion: reduce) {
.nspcf__digit { 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: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.nspcf {
width: min(240px, 70vw);
}
.nspcf__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 20px;
border-radius: 16px;
background: #ffffff;
}
.nspcf__label {
color: rgba(23, 20, 26, 0.72);
font-weight: 700;
font-size: 12px;
}
.nspcf__amount {
display: flex;
align-items: center;
gap: 4px;
}
.nspcf__symbol {
color: #0a4fe8;
font-weight: 700;
font-size: 22px;
margin-right: 4px;
}
.nspcf__col {
position: relative;
display: inline-block;
width: 18px;
height: 32px;
overflow: hidden;
}
.nspcf__digit {
position: absolute;
inset: 0;
display: grid;
place-items: center;
color: #17141a;
font-weight: 700;
font-size: 26px;
font-variant-numeric: tabular-nums;
will-change: transform;
}
.nspcf__comma {
color: #17141a;
font-weight: 700;
font-size: 22px;
transform: translateY(6px);
}
.nspcf__row {
display: flex;
gap: 12px;
}
.nspcf__btn {
width: 36px;
height: 36px;
border: 0;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
font-size: 18px;
font-weight: 700;
line-height: 1;
cursor: pointer;
}
.nspcf__btn:active {
transform: scale(0.92);
}
@media (max-width: 340px) {
.nspcf__panel {
padding: 16px;
gap: 12px;
}
.nspcf__symbol {
font-size: 18px;
}
.nspcf__digit {
font-size: 22px;
}
.nspcf__col {
width: 15px;
height: 28px;
}
.nspcf__btn {
width: 32px;
height: 32px;
}
}
@media (prefers-reduced-motion: reduce) {
.nspcf__digit {
transition: none !important;
}
}(function () {
var root = document.getElementById('nspcf');
var btnPlus = document.getElementById('nspcfPlus');
var btnMinus = document.getElementById('nspcfMinus');
var cols = Array.prototype.slice.call(root.querySelectorAll('.nspcf__col'));
var MIN = 0, MAX = 9999, STEP = 250;
var value = 0;
var H = 32;
function toDigits(n) {
var s = ('0000' + n).slice(-4);
return [+s[0], +s[1], +s[2], +s[3]];
}
// 계산 겹: 자릿수(place)가 클수록 굴러가는 시작을 더 늦추고(delay) 더 오래 돈다(duration) —
// 실제 기계식 계기판처럼 1의 자리가 먼저 넘어가고 그 여파가 왼쪽으로 번져 간다.
var CASCADE = 60, BASE_DUR = 220;
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);
}
function commit(next) {
next = Math.max(MIN, Math.min(MAX, next));
if (next === value) { return; }
var dir = next > value ? 1 : -1;
var oldDigits = toDigits(value);
var newDigits = toDigits(next);
value = next;
for (var i = 0; i < 4; i++) {
if (oldDigits[i] !== newDigits[i]) {
startColumnRoll(cols[i], oldDigits[i], newDigits[i], dir, 3 - i);
}
}
}
btnPlus.addEventListener('click', function () { root.classList.remove('is-demo'); stopAutoplay(); commit(value + STEP); });
btnMinus.addEventListener('click', function () { root.classList.remove('is-demo'); stopAutoplay(); commit(value - STEP); });
var seq = [250, 750, 1750, 4000, 9999, 0, 3333];
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 () {
commit(seq[idx % seq.length]);
idx += 1;
}, 1000);
}
})();import { useEffect, useRef } from "react";
import styles from "./NumberStepperCurrencyLiveFormat.module.scss";
export type NumberStepperCurrencyLiveFormatProps = {
color?: string;
};
export default function NumberStepperCurrencyLiveFormat({
color = "#0a4fe8",
}: NumberStepperCurrencyLiveFormatProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const btnPlus = root.querySelector<HTMLButtonElement>(`.${styles.btnPlus}`);
const btnMinus = root.querySelector<HTMLButtonElement>(`.${styles.btnMinus}`);
const cols = Array.from(root.querySelectorAll<HTMLSpanElement>(`.${styles.col}`));
if (!btnPlus || !btnMinus || cols.length !== 4) return;
const MIN = 0, MAX = 9999, STEP = 250;
let value = 0;
const H = 32;
function toDigits(n: number) {
const s = ("0000" + n).slice(-4);
return [+s[0], +s[1], +s[2], +s[3]];
}
// 계산 겹: 자릿수(place)가 클수록 굴러가는 시작을 더 늦추고(delay) 더 오래 돈다(duration) —
// 실제 기계식 계기판처럼 1의 자리가 먼저 넘어가고 그 여파가 왼쪽으로 번져 간다.
const CASCADE = 60, BASE_DUR = 220;
function startColumnRoll(colEl: HTMLSpanElement, newD: number, dir: number, place: number) {
const oldEl = colEl.querySelector<HTMLSpanElement>(`.${styles.digitOld}`);
const newEl = colEl.querySelector<HTMLSpanElement>(`.${styles.digitNew}`);
if (!oldEl || !newEl) return;
const delay = place * CASCADE;
const duration = BASE_DUR + place * 40;
newEl.textContent = String(newD);
newEl.style.transform = "translateY(" + dir * H + "px)";
setTimeout(() => {
const start = performance.now();
function frame(now: number) {
const t = Math.min(1, (now - start) / duration);
const 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);
}
function commit(next: number) {
next = Math.max(MIN, Math.min(MAX, next));
if (next === value) { return; }
const dir = next > value ? 1 : -1;
const oldDigits = toDigits(value);
const newDigits = toDigits(next);
value = next;
for (let i = 0; i < 4; i++) {
if (oldDigits[i] !== newDigits[i]) {
startColumnRoll(cols[i], newDigits[i], dir, 3 - i);
}
}
}
const onPlus = () => { root!.classList.remove(styles.isDemo); stopAutoplay(); commit(value + STEP); };
const onMinus = () => { root!.classList.remove(styles.isDemo); stopAutoplay(); commit(value - STEP); };
btnPlus.addEventListener("click", onPlus);
btnMinus.addEventListener("click", onMinus);
const seq = [250, 750, 1750, 4000, 9999, 0, 3333];
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(() => {
commit(seq[idx % seq.length]);
idx += 1;
}, 1000);
}
return () => {
stopAutoplay();
btnPlus!.removeEventListener("click", onPlus);
btnMinus!.removeEventListener("click", onMinus);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.isDemo}`} style={{ ["--color" as string]: color }}>
<div className={styles.panel}>
<span className={styles.label}>코인 잔액</span>
<div className={styles.amount}>
<span className={styles.symbol}>◆</span>
<span className={styles.col}>
<span className={`${styles.digit} ${styles.digitOld}`}>0</span>
<span className={`${styles.digit} ${styles.digitNew}`}>0</span>
</span>
<span className={styles.comma}>,</span>
<span className={styles.col}>
<span className={`${styles.digit} ${styles.digitOld}`}>0</span>
<span className={`${styles.digit} ${styles.digitNew}`}>0</span>
</span>
<span className={styles.col}>
<span className={`${styles.digit} ${styles.digitOld}`}>0</span>
<span className={`${styles.digit} ${styles.digitNew}`}>0</span>
</span>
<span className={styles.col}>
<span className={`${styles.digit} ${styles.digitOld}`}>0</span>
<span className={`${styles.digit} ${styles.digitNew}`}>0</span>
</span>
</div>
<div className={styles.row}>
<button className={`${styles.btn} ${styles.btnMinus}`} aria-label="250 감소" type="button">−</button>
<button className={`${styles.btn} ${styles.btnPlus}`} aria-label="250 증가" type="button">+</button>
</div>
</div>
</div>
);
}07 · 通貨のライブ書式 — 値が変わるたびに桁区切りの位置を求め直すステッパー — 計測 累積面積 0.493% · 動いたフレーム 8/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が示すもの
23フレーム中8フレームで動きがあり、累積面積0.49%・強度125.7です — CASCADEが桁位置ごとに開始を60msずつ遅らせるため、4つの列が同じフレームで一斉に描き直されることはありません。
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);
}
08単位フリップ切替 — 単位が変わった状態になるとカードが裏返り、90度の地点で表示値が切り替わるステッパー
単位が変わった状態になると、フリップカードがコサインの絶対値から自分の縦の比率を求め直し、その比率がゼロに近づく90度の地点でkgとlbの表示を切り替えます — 重なって薄れるクロスフェードではなく、裏返って変わるカットです。

<main class="stage">
<div class="nsputc is-demo" id="nsputc">
<div class="nsputc__panel">
<span class="nsputc__label">荷物の重さ</span>
<div class="nsputc__row">
<button class="nsputc__btn" id="nsputcMinus" aria-label="1減らす" type="button">−</button>
<div class="nsputc__flipBox" id="nsputcFlipBox">
<span class="nsputc__value" id="nsputcValue">20<small id="nsputcUnit">kg</small></span>
</div>
<button class="nsputc__btn" id="nsputcPlus" aria-label="1増やす" type="button">+</button>
</div>
<button class="nsputc__toggle" id="nsputcToggle" type="button">kg ⇄ lb 単位を切り替え</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 카드가 0~180도 도는 동안 |cos(angle)| 로 세로 비율을 계산해 90도에서 두께 0 —
// 그 한 프레임에 진짜 환산식(factor 곱)으로 값을 바꿔 끼운다. 흐려지는 크로스페이드가 아니다.
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-paper);
.stage { position: relative; }
.nsputc {
width: min(240px, 70vw);
}
.nsputc__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-4;
padding: $sp-5;
border-radius: $r-card;
background: rgba($subject-ink, .05);
}
.nsputc__label {
color: rgba($subject-ink, .72);
font-weight: 700;
font-size: 12px;
}
.nsputc__row {
display: flex;
align-items: center;
gap: $sp-4;
}
.nsputc__flipBox {
width: 92px;
height: 44px;
display: grid;
place-items: center;
will-change: transform;
}
.nsputc__value {
color: $subject-ink;
font-weight: 700;
font-size: 28px;
font-variant-numeric: tabular-nums;
transition: transform $duration $easing;
will-change: transform;
small {
margin-left: $sp-1;
font-size: 14px;
font-weight: 700;
color: $color;
}
}
.nsputc__btn {
width: 34px;
height: 34px;
border: 0;
border-radius: $r-pill;
background: $color;
color: $subject-cream;
font-size: 17px;
font-weight: 700;
line-height: 1;
cursor: pointer;
flex: 0 0 auto;
&:active { transform: scale(.92); }
}
.nsputc__toggle {
border: 0;
border-radius: $r-pill;
padding: $sp-2 $sp-4;
background: $stage-paper;
box-shadow: inset 0 0 0 1px rgba($subject-ink, .16);
color: $subject-ink;
font-size: 11px;
font-weight: 700;
cursor: pointer;
}
@media (max-width: 340px) {
.nsputc__panel { padding: $sp-4; gap: $sp-3; }
.nsputc__value { font-size: 24px; }
.nsputc__flipBox { width: 78px; height: 38px; }
.nsputc__btn { width: 30px; height: 30px; }
}
@media (prefers-reduced-motion: reduce) {
.nsputc__value { 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;
}
.nsputc {
width: min(240px, 70vw);
}
.nsputc__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 16px;
padding: 20px;
border-radius: 16px;
background: rgba(23, 20, 26, 0.05);
}
.nsputc__label {
color: rgba(23, 20, 26, 0.72);
font-weight: 700;
font-size: 12px;
}
.nsputc__row {
display: flex;
align-items: center;
gap: 16px;
}
.nsputc__flipBox {
width: 92px;
height: 44px;
display: grid;
place-items: center;
will-change: transform;
}
.nsputc__value {
color: #17141a;
font-weight: 700;
font-size: 28px;
font-variant-numeric: tabular-nums;
transition: transform 300ms ease-out;
will-change: transform;
}
.nsputc__value small {
margin-left: 4px;
font-size: 14px;
font-weight: 700;
color: #0a4fe8;
}
.nsputc__btn {
width: 34px;
height: 34px;
border: 0;
border-radius: 999px;
background: #0a4fe8;
color: #fff7e6;
font-size: 17px;
font-weight: 700;
line-height: 1;
cursor: pointer;
flex: 0 0 auto;
}
.nsputc__btn:active {
transform: scale(0.92);
}
.nsputc__toggle {
border: 0;
border-radius: 999px;
padding: 8px 16px;
background: #ffffff;
box-shadow: inset 0 0 0 1px rgba(23, 20, 26, 0.16);
color: #17141a;
font-size: 11px;
font-weight: 700;
cursor: pointer;
}
@media (max-width: 340px) {
.nsputc__panel {
padding: 16px;
gap: 12px;
}
.nsputc__value {
font-size: 24px;
}
.nsputc__flipBox {
width: 78px;
height: 38px;
}
.nsputc__btn {
width: 30px;
height: 30px;
}
}
@media (prefers-reduced-motion: reduce) {
.nsputc__value {
transition: none !important;
}
}(function () {
var root = document.getElementById('nsputc');
var btnPlus = document.getElementById('nsputcPlus');
var btnMinus = document.getElementById('nsputcMinus');
var btnToggle = document.getElementById('nsputcToggle');
var flipBox = document.getElementById('nsputcFlipBox');
var valueEl = document.getElementById('nsputcValue');
var unitEl = document.getElementById('nsputcUnit');
var MIN_KG = 1, MAX_KG = 200, STEP_KG = 1;
var FACTOR = 2.20462;
var baseKg = 20;
var unit = 'kg';
var flipRaf = null;
function displayValue() {
if (unit === 'kg') { return String(baseKg); }
return (Math.round(baseKg * FACTOR * 10) / 10).toFixed(1);
}
function render() {
valueEl.firstChild.textContent = displayValue();
unitEl.textContent = unit;
}
function bump() {
valueEl.style.transform = 'scale(1.1)';
setTimeout(function () { valueEl.style.transform = 'scale(1)'; }, 90);
}
function setKg(next) {
next = Math.max(MIN_KG, Math.min(MAX_KG, next));
if (next === baseKg) { return; }
baseKg = next;
render();
bump();
}
// 계산 겹: 카드가 0~180도 도는 동안 |cos(angle)| 로 세로 비율을 계산해 90도 지점에서
// 두께가 0 이 되게 만들고, 그 순간(딱 한 프레임)에 값을 실제 환산식(factor 곱)으로 바꿔 끼운다 —
// 겹쳐서 흐려지는 크로스페이드가 아니라 진짜로 "뒤집혀서 바뀌는" 컷이다.
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);
}
btnPlus.addEventListener('click', function () { root.classList.remove('is-demo'); stopAutoplay(); setKg(baseKg + STEP_KG); });
btnMinus.addEventListener('click', function () { root.classList.remove('is-demo'); stopAutoplay(); setKg(baseKg - STEP_KG); });
btnToggle.addEventListener('click', function () { root.classList.remove('is-demo'); stopAutoplay(); flipToggle(); });
var seq = ['bump', 'bump', 'flip', 'bump', 'flip', 'bump'];
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 () {
var op = seq[idx % seq.length];
idx += 1;
if (op === 'flip') { flipToggle(); } else { setKg(baseKg + (Math.random() > 0.5 ? 3 : -3)); }
}, 700);
}
render();
})();import { useEffect, useRef } from "react";
import styles from "./NumberStepperUnitToggleCrossfade.module.scss";
export type NumberStepperUnitToggleCrossfadeProps = {
color?: string;
};
export default function NumberStepperUnitToggleCrossfade({
color = "#0a4fe8",
}: NumberStepperUnitToggleCrossfadeProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const btnPlus = root.querySelector<HTMLButtonElement>(`.${styles.btnPlus}`);
const btnMinus = root.querySelector<HTMLButtonElement>(`.${styles.btnMinus}`);
const btnToggle = root.querySelector<HTMLButtonElement>(`.${styles.toggle}`);
const flipBox = root.querySelector<HTMLDivElement>(`.${styles.flipBox}`);
const valueEl = root.querySelector<HTMLSpanElement>(`.${styles.value}`);
const unitEl = root.querySelector<HTMLElement>(`.${styles.unit}`);
if (!btnPlus || !btnMinus || !btnToggle || !flipBox || !valueEl || !unitEl) return;
const MIN_KG = 1, MAX_KG = 200, STEP_KG = 1;
const FACTOR = 2.20462;
let baseKg = 20;
let unit: "kg" | "lb" = "kg";
let flipRaf: number | null = null;
function displayValue() {
if (unit === "kg") { return String(baseKg); }
return (Math.round(baseKg * FACTOR * 10) / 10).toFixed(1);
}
function render() {
valueEl!.firstChild!.textContent = displayValue();
unitEl!.textContent = unit;
}
function bump() {
valueEl!.style.transform = "scale(1.1)";
setTimeout(() => { valueEl!.style.transform = "scale(1)"; }, 90);
}
function setKg(next: number) {
next = Math.max(MIN_KG, Math.min(MAX_KG, next));
if (next === baseKg) { return; }
baseKg = next;
render();
bump();
}
// 계산 겹: 카드가 0~180도 도는 동안 |cos(angle)| 로 세로 비율을 계산해 90도 지점에서
// 두께가 0 이 되게 만들고, 그 순간(딱 한 프레임)에 값을 실제 환산식(factor 곱)으로 바꿔 끼운다 —
// 겹쳐서 흐려지는 크로스페이드가 아니라 진짜로 "뒤집혀서 바뀌는" 컷이다.
function flipToggle() {
if (flipRaf) { cancelAnimationFrame(flipRaf); }
const duration = 340;
const start = performance.now();
let swapped = false;
function frame(now: number) {
const t = Math.min(1, (now - start) / duration);
const angle = t * Math.PI;
const 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);
}
const onPlus = () => { root!.classList.remove(styles.isDemo); stopAutoplay(); setKg(baseKg + STEP_KG); };
const onMinus = () => { root!.classList.remove(styles.isDemo); stopAutoplay(); setKg(baseKg - STEP_KG); };
const onToggle = () => { root!.classList.remove(styles.isDemo); stopAutoplay(); flipToggle(); };
btnPlus.addEventListener("click", onPlus);
btnMinus.addEventListener("click", onMinus);
btnToggle.addEventListener("click", onToggle);
const seq = ["bump", "bump", "flip", "bump", "flip", "bump"];
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(() => {
const op = seq[idx % seq.length];
idx += 1;
if (op === "flip") { flipToggle(); } else { setKg(baseKg + (Math.random() > 0.5 ? 3 : -3)); }
}, 700);
}
render();
return () => {
stopAutoplay();
if (flipRaf) cancelAnimationFrame(flipRaf);
btnPlus!.removeEventListener("click", onPlus);
btnMinus!.removeEventListener("click", onMinus);
btnToggle!.removeEventListener("click", onToggle);
};
}, []);
return (
<div ref={rootRef} className={`${styles.stage} ${styles.isDemo}`} style={{ ["--color" as string]: color }}>
<div className={styles.panel}>
<span className={styles.label}>짐 무게</span>
<div className={styles.row}>
<button className={`${styles.btn} ${styles.btnMinus}`} aria-label="1 감소" type="button">−</button>
<div className={styles.flipBox}>
<span className={styles.value}>20<small className={styles.unit}>kg</small></span>
</div>
<button className={`${styles.btn} ${styles.btnPlus}`} aria-label="1 증가" type="button">+</button>
</div>
<button className={styles.toggle} type="button">kg ⇄ lb 단위 바꾸기</button>
</div>
</div>
);
}08 · 単位フリップ切替 — 単位が変わった状態になるとカードが裏返り、90度の地点で表示値が切り替わるステッパー — 計測 累積面積 0.93% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が示すもの
23フレーム中12フレームで動きがあり、これは04を除くと9つの中で最多です。累積面積0.93%・強度79.1で、プラス・マイナス・切り替えのどのボタンを押してもフリップカードの再描画か重さの値の更新のどちらかが起きるためです。
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);
}
09目盛りスナップ — 値が決まった目盛りの間にあると最も近い目盛りの値に引き寄せられるステッパー
ハンドルを2つの目盛りの間のどこで放しても、そこから近いほうの目盛りへ向かうばねの力が働き始め、決まった一方向へ動くわけではありません。

<main class="stage">
<div class="nspts is-demo" id="nspts">
<div class="nspts__panel">
<span class="nspts__label">画面の明るさ</span>
<span class="nspts__value" id="nsptsValue">5</span>
<div class="nspts__track" id="nsptsTrack">
<span class="nspts__tick" data-i="0"></span>
<span class="nspts__tick" data-i="1"></span>
<span class="nspts__tick" data-i="2"></span>
<span class="nspts__tick" data-i="3"></span>
<span class="nspts__tick" data-i="4"></span>
<span class="nspts__tick" data-i="5"></span>
<span class="nspts__tick" data-i="6"></span>
<span class="nspts__tick" data-i="7"></span>
<span class="nspts__tick" data-i="8"></span>
<span class="nspts__tick" data-i="9"></span>
<span class="nspts__tick" data-i="10"></span>
<span class="nspts__handle" id="nsptsHandle"></span>
</div>
<span class="nspts__hint">ドラッグして離すと、近い目盛りに磁石のように吸着します</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 손을 놓은 위치에서 가장 가까운 눈금까지 매 프레임 스프링 힘(거리*강성 - 속도*감쇠)을
// 계산해 자석처럼 빨려 들어가게 한다 — 드래그 중에도 눈금 근접도로 인력을 미리 섞는다.
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-cream;
@include stage($stage-ink);
.stage { position: relative; }
.nspts {
width: min(260px, 76vw);
}
.nspts__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
padding: $sp-5;
border-radius: $r-card;
background: $stage-paper;
}
.nspts__label {
color: rgba($subject-ink, .72);
font-weight: 700;
font-size: 12px;
}
.nspts__value {
color: $subject-ink;
font-weight: 700;
font-size: 26px;
font-variant-numeric: tabular-nums;
}
.nspts__track {
position: relative;
width: 200px;
height: 4px;
border-radius: $r-xs;
background: rgba($subject-ink, .14);
margin: $sp-4 0 $sp-1;
}
.nspts__tick {
position: absolute;
top: 50%;
left: 0;
width: 4px;
height: 4px;
border-radius: $r-pill;
background: $subject-blue-deep;
transform: translate(-50%, -50%);
transform-origin: center;
@for $i from 0 through 10 {
&[data-i="#{$i}"] { left: calc(#{$i} / 10 * 100%); }
}
}
.nspts__handle {
position: absolute;
top: 50%;
left: 0;
width: 20px;
height: 20px;
border-radius: $r-pill;
background: $subject-blue-deep;
box-shadow: $shadow-raised;
cursor: grab;
touch-action: none;
will-change: transform;
transform: translate(-50%, -50%);
&:active { cursor: grabbing; }
}
.nspts__hint {
color: rgba($subject-ink, .72);
font-size: 11px;
text-align: center;
}
@media (max-width: 340px) {
.nspts__panel { padding: $sp-4; gap: $sp-2; }
.nspts__track { width: 160px; }
.nspts__value { font-size: 22px; }
}
@media (prefers-reduced-motion: reduce) {
.nspts__handle, .nspts__tick { 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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stage {
position: relative;
}
.nspts {
width: min(260px, 76vw);
}
.nspts__panel {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px;
border-radius: 16px;
background: #ffffff;
}
.nspts__label {
color: rgba(23, 20, 26, 0.72);
font-weight: 700;
font-size: 12px;
}
.nspts__value {
color: #17141a;
font-weight: 700;
font-size: 26px;
font-variant-numeric: tabular-nums;
}
.nspts__track {
position: relative;
width: 200px;
height: 4px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.14);
margin: 16px 0 4px;
}
.nspts__tick {
position: absolute;
top: 50%;
left: 0;
width: 4px;
height: 4px;
border-radius: 999px;
background: #0a4fe8;
transform: translate(-50%, -50%);
transform-origin: center;
}
.nspts__tick[data-i="0"] {
left: calc(0 / 10 * 100%);
}
.nspts__tick[data-i="1"] {
left: calc(1 / 10 * 100%);
}
.nspts__tick[data-i="2"] {
left: calc(2 / 10 * 100%);
}
.nspts__tick[data-i="3"] {
left: calc(3 / 10 * 100%);
}
.nspts__tick[data-i="4"] {
left: calc(4 / 10 * 100%);
}
.nspts__tick[data-i="5"] {
left: calc(5 / 10 * 100%);
}
.nspts__tick[data-i="6"] {
left: calc(6 / 10 * 100%);
}
.nspts__tick[data-i="7"] {
left: calc(7 / 10 * 100%);
}
.nspts__tick[data-i="8"] {
left: calc(8 / 10 * 100%);
}
.nspts__tick[data-i="9"] {
left: calc(9 / 10 * 100%);
}
.nspts__tick[data-i="10"] {
left: calc(10 / 10 * 100%);
}
.nspts__handle {
position: absolute;
top: 50%;
left: 0;
width: 20px;
height: 20px;
border-radius: 999px;
background: #0a4fe8;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
cursor: grab;
touch-action: none;
will-change: transform;
transform: translate(-50%, -50%);
}
.nspts__handle:active {
cursor: grabbing;
}
.nspts__hint {
color: rgba(23, 20, 26, 0.72);
font-size: 11px;
text-align: center;
}
@media (max-width: 340px) {
.nspts__panel {
padding: 16px;
gap: 8px;
}
.nspts__track {
width: 160px;
}
.nspts__value {
font-size: 22px;
}
}
@media (prefers-reduced-motion: reduce) {
.nspts__handle, .nspts__tick {
transition: none !important;
}
}(function () {
var root = document.getElementById('nspts');
var track = document.getElementById('nsptsTrack');
var handle = document.getElementById('nsptsHandle');
var valueEl = document.getElementById('nsptsValue');
var ticks = Array.prototype.slice.call(track.querySelectorAll('.nspts__tick'));
var TICK_COUNT = 11, TRACK_W = 200, GAP = TRACK_W / (TICK_COUNT - 1);
var currentX = 5 * GAP;
var dragging = false, startClientX = 0, startX = 0, settleRaf = null;
function xToIndex(x) { return Math.max(0, Math.min(10, Math.round(x / GAP))); }
function nearestTickX(x) { return xToIndex(x) * GAP; }
function paint(x) {
handle.style.transform = 'translate(-50%, -50%) translateX(' + x.toFixed(1) + 'px)';
for (var i = 0; i < ticks.length; i++) {
var tx = i * GAP;
var d = Math.abs(x - tx);
var proximity = Math.max(0, 1 - d / GAP);
ticks[i].style.transform = 'translate(-50%, -50%) scale(' + (1 + proximity * 0.9).toFixed(2) + ')';
ticks[i].style.opacity = (0.35 + proximity * 0.65).toFixed(2);
}
valueEl.textContent = String(xToIndex(x));
}
// 계산 겹: 놓는 순간의 위치(x0)에서 가장 가까운 눈금(target)까지 매 프레임 스프링 힘
// (거리 * 강성 - 속도 * 감쇠)을 계산해 자석처럼 서서히 빨려 들어가게 만든다.
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);
}
function moveTo(clientX) {
var raw = Math.max(0, Math.min(TRACK_W, startX + (clientX - startClientX)));
// 계산 겹: 가장 가까운 눈금까지 거리 비율로 자석 인력 세기(pull)를 계산해
// 손가락 위치와 눈금 사이를 보간한다 — 드래그 중에도 살짝 끌려간다.
var tx = nearestTickX(raw);
var dist = Math.abs(raw - tx);
var pull = Math.max(0, 1 - dist / (GAP * 0.9));
var x = raw + (tx - raw) * pull * 0.55;
currentX = x;
paint(x);
}
handle.addEventListener('pointerdown', function (e) {
root.classList.remove('is-demo'); stopAutoplay();
if (settleRaf) { cancelAnimationFrame(settleRaf); settleRaf = null; }
dragging = true;
startClientX = e.clientX;
startX = currentX;
handle.setPointerCapture(e.pointerId);
});
handle.addEventListener('pointermove', function (e) { if (dragging) { moveTo(e.clientX); } });
['pointerup', 'pointercancel'].forEach(function (ev) {
handle.addEventListener(ev, function () {
if (!dragging) { return; }
dragging = false;
springSettle(currentX, nearestTickX(currentX));
});
});
var seq = [3, 8, 1, 6, 9, 2, 5];
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 () {
springSettle(currentX, seq[idx % seq.length] * GAP);
idx += 1;
}, 850);
}
paint(currentX);
})();import { useEffect, useRef } from "react";
import styles from "./NumberStepperTickMarkSnap.module.scss";
export type NumberStepperTickMarkSnapProps = {
color?: string;
};
export default function NumberStepperTickMarkSnap({
color = "#0a4fe8",
}: NumberStepperTickMarkSnapProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const track = root.querySelector<HTMLDivElement>(`.${styles.track}`);
const handle = root.querySelector<HTMLSpanElement>(`.${styles.handle}`);
const valueEl = root.querySelector<HTMLSpanElement>(`.${styles.value}`);
const ticks = track ? Array.from(track.querySelectorAll<HTMLSpanElement>(`.${styles.tick}`)) : [];
if (!track || !handle || !valueEl || ticks.length !== 11) return;
const TICK_COUNT = 11, TRACK_W = 200, GAP = TRACK_W / (TICK_COUNT - 1);
let currentX = 5 * GAP;
let dragging = false, startClientX = 0, startX = 0, settleRaf: number | null = null;
function xToIndex(x: number) { return Math.max(0, Math.min(10, Math.round(x / GAP))); }
function nearestTickX(x: number) { return xToIndex(x) * GAP; }
function paint(x: number) {
handle!.style.transform = "translate(-50%, -50%) translateX(" + x.toFixed(1) + "px)";
for (let i = 0; i < ticks.length; i++) {
const tx = i * GAP;
const d = Math.abs(x - tx);
const proximity = Math.max(0, 1 - d / GAP);
ticks[i].style.transform = "translate(-50%, -50%) scale(" + (1 + proximity * 0.9).toFixed(2) + ")";
ticks[i].style.opacity = (0.35 + proximity * 0.65).toFixed(2);
}
valueEl!.textContent = String(xToIndex(x));
}
// 계산 겹: 놓는 순간의 위치(x0)에서 가장 가까운 눈금(target)까지 매 프레임 스프링 힘
// (거리 * 강성 - 속도 * 감쇠)을 계산해 자석처럼 서서히 빨려 들어가게 만든다.
const STIFF = 0.28, DAMP = 0.62;
function springSettle(x0: number, target: number) {
if (settleRaf) { cancelAnimationFrame(settleRaf); }
let x = x0, v = 0;
function frame() {
const 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);
}
function moveTo(clientX: number) {
const raw = Math.max(0, Math.min(TRACK_W, startX + (clientX - startClientX)));
// 계산 겹: 가장 가까운 눈금까지 거리 비율로 자석 인력 세기(pull)를 계산해
// 손가락 위치와 눈금 사이를 보간한다 — 드래그 중에도 살짝 끌려간다.
const tx = nearestTickX(raw);
const dist = Math.abs(raw - tx);
const pull = Math.max(0, 1 - dist / (GAP * 0.9));
const x = raw + (tx - raw) * pull * 0.55;
currentX = x;
paint(x);
}
const onDown = (e: PointerEvent) => {
root!.classList.remove(styles.isDemo);
stopAutoplay();
if (settleRaf) { cancelAnimationFrame(settleRaf); settleRaf = null; }
dragging = true;
startClientX = e.clientX;
startX = currentX;
handle!.setPointerCapture(e.pointerId);
};
const onMove = (e: PointerEvent) => { if (dragging) { moveTo(e.clientX); } };
const onUp = () => {
if (!dragging) { return; }
dragging = false;
springSettle(currentX, nearestTickX(currentX));
};
handle.addEventListener("pointerdown", onDown);
handle.addEventListener("pointermove", onMove);
handle.addEventListener("pointerup", onUp);
handle.addEventListener("pointercancel", onUp);
const seq = [3, 8, 1, 6, 9, 2, 5];
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(() => {
springSettle(currentX, seq[idx % seq.length] * GAP);
idx += 1;
}, 850);
}
paint(currentX);
return () => {
stopAutoplay();
if (settleRaf) cancelAnimationFrame(settleRaf);
handle!.removeEventListener("pointerdown", onDown);
handle!.removeEventListener("pointermove", onMove);
handle!.removeEventListener("pointerup", onUp);
handle!.removeEventListener("pointercancel", onUp);
};
}, []);
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}>5</span>
<div className={styles.track}>
{Array.from({ length: 11 }).map((_, i) => (
<span key={i} className={styles.tick} data-i={i} />
))}
<span className={styles.handle} />
</div>
<span className={styles.hint}>끌어서 놓으면 가까운 눈금에 자석처럼 붙습니다</span>
</div>
</div>
);
}09 · 目盛りスナップ — 値が決まった目盛りの間にあると最も近い目盛りの値に引き寄せられるステッパー — 計測 累積面積 3.068% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
数字が示すもの
23フレーム中12フレームで動きがあり、累積面積3.07%・強度110.9と、9つの中でいちばん小さな部品でありながら05のドラッグに次ぐ広さを記録しています — ハンドルかばねのどちらかが動いている間は11個の目盛りがpaintのたびに大きさと不透明度を変え続けるためです。
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);
}
値がパターンを変える場所 — 加速係数・抵抗曲線・スナップ間隔
3つの定数はパターンの感触だけを変え、パターンが何をするかは変えません — 値を入れ替えても同じクリック・ドラッグ・目盛りは結局同じ最終値に落ち着き、そこへ至る経路だけが変わります。この設計のおかげで数量 ステッパーの見た目を調整するたびにロジックを書き直す必要がありません。
02のDECAYが決めるのは間隔が縮む速さで、繰り返すかどうかではない理由
currentInterval()はSTART * DECAY^ticksをFLOORでクランプして返します。DECAYが1に近いと何十ティック経っても間隔はほとんど縮まず、1から離れるとほぼ即座にFLOORまで落ちますが、scheduleNextが自分自身を再帰的に呼び続けるという「繰り返す」動作そのものはこの定数とは関係がありません。
05のFRICTIONが決めるのは慣性が続く長さで、1回のドラッグが動ける距離ではない理由
endDrag()は指を離した瞬間の速度にMath.exp(-FRICTION * dt / 1000)を毎フレーム掛けるため、FRICTIONが小さいほど体感温度の値は離した後も長く動き続けます。一方で1回のドラッグそのものが動く距離はmoveDrag内のSTEP_PXによる単純な1対1の対応で決まり、FRICTIONとは無関係です。
09のSTIFFとDAMPが決めるのはどう吸着するかで、どの目盛りに吸着するかではない理由
nearestTickX()がspringSettleを呼ぶ前にすでに生のピクセル距離だけで目標の目盛りを決めています。STIFFとDAMPはハンドルがその決まった目標へ向かう経路の力と減衰だけを形づくり、STIFFが低ければゆっくり吸い寄せられ、高ければ一気に吸着しますが、どちらも別の目盛りへ向き先を変えることはできません。
どこで壊れるか — 高速タップ・桁数の急増・範囲外の入力
素早い連打が03のオーバーシュートのアニメーションを最後まで終わらせない場合
bounce(dir)は呼ばれるたびに実行中のrafを即座にキャンセルするため、最初のオーバーシュートがまだ途中の状態で境界付近をもう一度クリックすると、その進行はそこで捨てられて正弦波がt = 0から描き直されます — 座席数の表示は一度ゼロの位置へ視覚的に戻ってから、あらためて外へ振れます。
この9つが入力欄 デザイン 9選 — コピペで動くフォーム部品・html カラーピッカー デザイン9選 — ホイールから履歴色まで・CSS 数字 カウントアップ アニメーション9選 — 一行でコピペOKと違うのは、それぞれの回が実際にどの値を求め直しているかです。この9つの元ファイルはすべてパスワードa3azqrsbで解除でき、架空のラベルを実在の商品名に差し替えるだけで実際の数量欄にそのまま使えます。入力欄の回は主に欄自体の検証状態や書式の値を求め直すだけで、押し続け・ドラッグ・ホイールで動く上限付きの数量の値は扱いません。カラーピッカーの回はホイールやポインター移動から色相・彩度・位置の値を求め直しますが、数値のMINとMAXでクランプする処理はこの9つのようには登場しません。カウントアップの回はスクロールや読み込みで一度だけ発生するイージングのトゥイーンで表示中の数字を求め直すだけで、この9つが毎回応答する押し続け・ドラッグ・ホイール・キー入力の繰り返しは扱いません。
07の値が4桁を超えようとするとどうなるか
commit()はすべてのステップをMAX = 9999でクランプし、toDigits()は常にゼロ埋めした文字列の末尾4文字だけを取り出します。このクランプがあるからコイン残高は安全に保たれていて、これを外して実際に5桁の残高を扱おうとすると、toDigitsは5つ目の列を表示に加える代わりに先頭の桁を静かに落としてしまいます。
04で積み上がったホイールの残余値がMAXやMINを超えた場合
applyDelta()はコミットされる値そのものはMath.min・Math.maxでクランプしますが、whileループはそのクランプに関係なくresidualをTHRESHずつ律儀に消費し続けます。拡大率の上限を超えてスクロールし続けても、表示されている倍率はすでに止まっているのに、塗りバーだけは閾値を超えるたびに点滅するように上下し続けます。
3つのバリエーションとアクセシビリティ
このzipは定数ごとにひとつの値だけを出荷していますが、この3つはまわりのマークアップに触れずに、まったく違う感触へ差し替えられます。
| バリエーション | 変わるもの | 引き換えになるもの |
|---|---|---|
| 07のCASCADEを60msから140msへ | 桁の列がロールを始めるタイミングの間隔が広がる | 60msでは数字全体がひとつの塊のように一斉に変わって見えるが、140msにすると1の位と1000の位の間の時間差が目に見えるようになり、機械式の計器盤が1列ずつ繰り上がるように読める |
| 09のSTIFFを0.28から0.5へ | ばねがハンドルを目盛りへ引き込む力が強くなる | 0.28では指を離してから数フレームかけてゆっくり最も近い目盛りへ寄っていくが、0.5では同じ距離でもずっと速く引き込まれ、磁石がハンドルをつかむような即座のスナップに感じられる |
| 05のFRICTIONを6.2から3へ | 指を離した後の減衰がゆっくりになる | 6.2では指を離した直後に速度がほぼ死んで1〜2段先で止まるが、3まで下げると減衰が緩やかになり、止まるまでにさらに数段分滑り続ける |
このzipが実際に出荷しているのは、ここまでのコードに出てきた220ms・0.28・6.2という値で、より速いCASCADE・より硬いSTIFF・より低いFRICTIONへの差し替えはコードの書き直しではなく定数1行の変更で済みます。
prefers-reduced-motionが有効でも、すべての計算済みの値に手動で届く理由
9つとも自分の自動再生プレビューを始める前にwindow.matchMedia('(prefers-reduced-motion: reduce)').matchesを確認するため、reduced-motion環境の訪問者が押す・ドラッグする・スクロールする・キーを打つといった手動の操作をしても同じ計算済みの値へ届き、止まるのはデモが自分で再生するループだけです。01のリングSVGはこの9つの中で唯一aria-hiddenが付いた純粋に装飾だけの再描画で、それ以外の再描画はすべてすでに自分のaria-label(「数量を減らす」「ポイントを増やす」「座席を減らす」など)を持つコントロールのすぐ後ろにあり、06のrole="spinbutton"のボックスはそのラベルに加えて自分のaria-valueminとaria-valuemaxも持っているため、スクリーンリーダーは下に重なったアニメーションではなくコントロールそのものについて1つの状態だけを読み上げます。
FAQ
ステッパー ui デザインは長押しの繰り返しとキーボード対応、どちらから作るべきですか
キーボード対応から作るべきです — commit()がすでに値をクランプしているので06の矢印キーとHome/End対応を足す追加コストはほとんどなく、ポインターを01や02が意味を持つほど長く押せない訪問者でも06が届く値にはすべて届きます。長押しの繰り返しは、単発のクリックだけでは何十回も押す必要が出るほど範囲が広くなってから足す価値が生まれます。
この9つのどれかが実際のサーバーへ数量を送信・検証していますか
いいえ。ここにある値はすべてローカルのMIN・MAXの組でクランプされてそのまま画面に描かれるだけで、fetchを呼ぶことも実在の在庫を確認することも注文を送信することもなく、コイン残高・座席数・荷物の重さはすべて実在のカタログから取ってきたものではない架空の数字です。
この9つは読むだけの参考例ですか、それともフォームへ貼り付けて使える実装ですか
読むだけでなく貼り付けて使うことを前提に作られています。上に載せたコードはどれも25行以内の核心部分だけで、9つとも外部ライブラリへ手を伸ばすことはなく、置き換えが必要なのはローカルのvalue・MIN・MAXという実際のフォームの状態に合わせるべき部分だけです。
Sources: MDN — Element: wheel イベント · MDN — Window.requestAnimationFrame · MDN — Intl.NumberFormat · MDN — ARIA spinbutton ロール · MDN — prefers-reduced-motion









