07통화 실시간 서식 — 값이 바뀔 때마다 자릿수 구분 위치가 다시 계산되는 스텝퍼
값이 바뀐 상태가 될 때마다 그 값의 자릿수 구분 기호 위치가 다시 계산되어 표시 문자열이 매번 새로 맞춰지고, 커서가 놓인 자리 값도 문자열 길이 변화에 맞게 다시 계산됩니다.
쓰임가격 입력 폼, 견적 계산기의 금액 필드
CASCADE=60BASE_DUR=220startColumnRoll

<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 원작 · zip 에 SCSS·React 포함 · 숫자 스텝퍼 UI 패턴 9종 — 길게 누를수록 빨라지는 것부터 눈금에 붙는 것까지
이 데모가 속한 글
숫자 스텝퍼 UI 패턴 9종 — 길게 누를수록 빨라지는 것부터 눈금에 붙는 것까지숫자 스텝퍼는 버튼이나 드래그로 값을 하나씩 늘리고 줄이는 입력 장치인데, 아홉 개 중 하나는 눌린 상태가 길어질수록 값이 바뀌는 간격이 점점 짧아지고, 하나는 값이 상한에 닿으면 그 지점을 살짝 넘었다가 스프링 곡 이 글의 데모 전부 보기


