01길게 누르기 반복 — 눌린 상태가 이어지는 동안 일정한 간격마다 값이 계속 바뀌는 스텝퍼
버튼이 눌린 상태로 이어지는 동안 정해진 간격마다 값이 한 단위씩 계속 바뀌고, 눌림이 풀린 상태가 되면 그 간격 값이 초기화되어 반복이 멈춥니다.
쓰임수량 선택 폼, 설정값 조절 화면
DELAY=380INTERVAL=220ringFrame

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


