06키보드 점프 — 방향키는 한 단위, 보조키를 더하면 더 큰 단위로 값이 바뀌는 스텝퍼
방향키가 눌린 상태가 되면 값이 한 단위만큼 바뀌고, 보조키가 함께 눌린 상태가 되면 같은 방향키라도 값이 바뀌는 단위 값이 더 커지며, 상한·하한에서는 그 단위만큼 남은 거리 값이 클램프됩니다.
쓰임키보드 위주의 관리자 폼, 접근성을 갖춘 수량 입력창
MAX=200Math.log2stopAutoplay

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


