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 オリジナル · SCSS と React は zip に同梱 · 数値ステッパー UI 9選 — 長押しから目盛りスナップまで
このデモが属する記事
数値ステッパー UI 9選 — 長押しから目盛りスナップまで9つのうち1つは押された状態が長引くほど値が変わる間隔が短くなり、1つは値が上限に触れるとその地点を少し超えてからばね曲線に沿って戻り、もう1つは引く距離の値に比例して数字が変わり、指を離した後も慣性の値に沿って数段階分さらに進みます。ステ この記事のデモをすべて見る


