07キーボード精密調整 — 矢印・Shiftのステップ幅とaria-valuetextの同期
keydownの矢印・Shift増分を同じスプリング目標に入れると、キーボードでも慣性・スナップ計算がそのまま動き、同じフレームでaria-valuetextを人が読む単位(「3.5秒」など)に更新します。カスタムトラックを描いてもスクリーンリーダーが元のinputの値と単位を読む二重構造です。
使いどころアクセシビリティ必須フォーム、データ入力
keydownCSS custom propertiesaria-valuetext

<main class="stage">
<div class="ka" id="ka">
<p class="ka__hint">矢印キーは0.1秒単位、Shift+矢印キーは1秒単位でaria-valuetextも一緒に更新します</p>
<div class="ka__wrap" id="kaWrap" role="img" aria-label="矢印キーで時間の値を精密に調整する場面">
<input class="ka__range" id="kaRange" type="range" min="0" max="100" step="1" value="35" aria-label="再生開始時間">
</div>
<p class="ka__meta"><b class="ka__tag" id="kaTime">3.5秒</b><span class="ka__unit" id="kaStep">矢印キー0.1秒</span></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 키보드 정밀 조정(07) — 네이티브 input 을 그대로 커스텀한다. keydown 이 실제 값을
// 바꾸고, 위치는 가벼운 스프링으로 그 값을 뒤쫓는다.
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-orange);
$track-h: 8px;
$thumb-d: 20px;
.ka {
display: flex; flex-direction: column; align-items: center; gap: $sp-3;
width: 100%; max-width: 360px;
}
.ka__hint {
margin: 0; font-weight: 700; font-size: clamp(10px, 2.8vw, 12px); line-height: 1.35;
color: $subject-ink; text-align: center;
}
.ka__wrap {
position: relative; width: 100%; height: 40px;
display: flex; align-items: center;
--pct: 35;
}
.ka__range {
appearance: none; -webkit-appearance: none;
width: 100%; height: $thumb-d; margin: 0; background: transparent;
}
.ka__range:focus-visible { outline: 2px solid $subject-ink; outline-offset: 4px; border-radius: $r-pill; }
.ka__range::-webkit-slider-runnable-track {
height: $track-h; border-radius: $r-pill;
background: linear-gradient(to right, $color calc(var(--pct) * 1%), rgba($subject-ink, .16) calc(var(--pct) * 1%));
}
.ka__range::-webkit-slider-thumb {
appearance: none; -webkit-appearance: none;
width: $thumb-d; height: $thumb-d; border-radius: 50%;
background: $subject-ink; box-shadow: $shadow-press;
margin-top: calc(($track-h - $thumb-d) / 2);
}
.ka__range::-moz-range-track {
height: $track-h; border-radius: $r-pill; background: rgba($subject-ink, .16);
}
.ka__range::-moz-range-progress {
height: $track-h; border-radius: $r-pill; background: $color;
}
.ka__range::-moz-range-thumb {
width: $thumb-d; height: $thumb-d; border: 0; border-radius: 50%;
background: $subject-ink; box-shadow: $shadow-press;
}
.ka__meta { margin: 0; font-size: clamp(9px, 2.4vw, 11px); color: rgba($subject-ink, .82); }
.ka__tag { font-weight: 700; color: $color; font-variant-numeric: tabular-nums; margin-right: $sp-1; }
.ka__unit { color: rgba($subject-ink, .72); }
@media (max-width: 340px) {
.ka { gap: $sp-2; max-width: 280px; }
.ka__hint { font-size: 8px; }
.ka__meta { font-size: 7px; }
}
@media (prefers-reduced-motion: reduce) {
.ka__range { transition: none; }
}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);
}
.ka {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
max-width: 360px;
}
.ka__hint {
margin: 0;
font-weight: 700;
font-size: clamp(10px, 2.8vw, 12px);
line-height: 1.35;
color: #17141a;
text-align: center;
}
.ka__wrap {
position: relative;
width: 100%;
height: 40px;
display: flex;
align-items: center;
--pct: 35;
}
.ka__range {
appearance: none;
-webkit-appearance: none;
width: 100%;
height: 20px;
margin: 0;
background: transparent;
}
.ka__range:focus-visible {
outline: 2px solid #17141a;
outline-offset: 4px;
border-radius: 999px;
}
.ka__range::-webkit-slider-runnable-track {
height: 8px;
border-radius: 999px;
background: linear-gradient(to right, #0a4fe8 calc(var(--pct) * 1%), rgba(23, 20, 26, 0.16) calc(var(--pct) * 1%));
}
.ka__range::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 20px;
height: 20px;
border-radius: 50%;
background: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
margin-top: -6px;
}
.ka__range::-moz-range-track {
height: 8px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.16);
}
.ka__range::-moz-range-progress {
height: 8px;
border-radius: 999px;
background: #0a4fe8;
}
.ka__range::-moz-range-thumb {
width: 20px;
height: 20px;
border: 0;
border-radius: 50%;
background: #17141a;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.ka__meta {
margin: 0;
font-size: clamp(9px, 2.4vw, 11px);
color: rgba(23, 20, 26, 0.82);
}
.ka__tag {
font-weight: 700;
color: #0a4fe8;
font-variant-numeric: tabular-nums;
margin-right: 4px;
}
.ka__unit {
color: rgba(23, 20, 26, 0.72);
}
@media (max-width: 340px) {
.ka {
gap: 8px;
max-width: 280px;
}
.ka__hint {
font-size: 8px;
}
.ka__meta {
font-size: 7px;
}
}
@media (prefers-reduced-motion: reduce) {
.ka__range {
transition: none;
}
}// 키보드 방향키 정밀 조정(07) — 실제 값(0~100, 0.1秒 단위)과 사람이 읽는 valuetext
// ("3.5秒") 를 같은 프레임에서 맞춘다. 방향키는 step=1(0.1秒), Shift+방향키는
// step=10(1秒) 로 keydown 에서 직접 처리해 네이티브 step 보다 촘촘한 미세조정을
// 보여준다. 이동은 스프링(-k·거리-c·속도)으로 가볍게 뒤쫓게 해 위치가 목표로
// 부드럽게 붙는다 — 02 번보다 가벼운 상수(오버슈트가 거의 없음).
(function () {
var root = document.getElementById('ka');
var wrap = document.getElementById('kaWrap');
var range = document.getElementById('kaRange');
var timeEl = document.getElementById('kaTime');
var stepEl = document.getElementById('kaStep');
if (!root || !wrap || !range || !timeEl || !stepEl) return;
var MIN = 0, MAX = 100;
var K = 120, C = 16; // 가벼운 스프링 — 오버슈트 최소
var TAU = 0.12;
function fmt(v) { return (v / 10).toFixed(1) + '秒'; }
var target = 35, pos = 35, vel = 0;
function paint() {
var pct = (pos - MIN) / (MAX - MIN) * 100;
wrap.style.setProperty('--pct', pct.toFixed(2));
timeEl.textContent = fmt(pos);
range.setAttribute('aria-valuetext', fmt(Math.round(pos)));
range.setAttribute('aria-valuenow', String(Math.round(pos)));
}
paint();
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = true;
function stop() { playing = false; }
range.addEventListener('pointerdown', stop);
range.addEventListener('input', function () {
stop();
target = Number(range.value);
pos = target; vel = 0;
paint();
});
range.addEventListener('keydown', function (e) {
var fine = 1, coarse = 10;
var delta = 0;
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') delta = e.shiftKey ? coarse : fine;
else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') delta = e.shiftKey ? -coarse : -fine;
else return;
e.preventDefault();
stop();
stepEl.textContent = e.shiftKey ? 'Shift+矢印キー1秒' : '矢印キー0.1秒';
target = Math.max(MIN, Math.min(MAX, target + delta));
range.value = String(target);
paint();
});
var segTargets = [35, 82, 6, 60, 95, 35];
var seg = 0, segFrom = target;
var SEG_MS = 420;
var segStart = performance.now();
var last = segStart;
if (reduce) {
target = segTargets[0]; pos = target;
range.value = String(target);
paint();
} else {
(function step(now) {
var dt = Math.min((now - last) / 1000, 0.05);
last = now;
if (playing) {
var elapsed = now - segStart;
if (elapsed >= SEG_MS) {
seg = (seg + 1) % segTargets.length;
segStart = now; elapsed = 0; segFrom = target;
}
var t = elapsed / 1000;
var k = 1 - Math.exp(-t / TAU);
target = segFrom + (segTargets[seg] - segFrom) * k;
}
var acc = -K * (pos - target) - C * vel;
vel += acc * dt;
pos += vel * dt;
range.value = String(Math.round(pos));
paint();
requestAnimationFrame(step);
})(last);
}
})();// RangeSliderKeyboardArrowPrecision — vanilla 판(run/334/demo/07-range-slider-keyboard-arrow-precision/index.html)
// 의 React 포트. 실제 값(0~100, 0.1초 단위)과 사람이 읽는 valuetext("3.5초")를 같은 프레임에서
// 맞춘다. 방향키는 step=1(0.1초), Shift+방향키는 step=10(1초)로 keydown 에서 직접 처리해
// 네이티브 step 보다 촘촘한 미세조정을 보여준다. 이동은 가벼운 스프링(-k·거리-c·속도)으로
// 뒤쫓게 해 위치가 목표로 부드럽게 붙는다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./RangeSliderKeyboardArrowPrecision.module.scss";
export type RangeSliderKeyboardArrowPrecisionProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
const HINT = "방향키는 0.1초 단위, Shift+방향키는 1초 단위로 aria-valuetext 를 함께 갱신합니다";
const ARIA_SCENE = "방향키로 시간 값을 정밀하게 조정하는 장면";
const LABEL_RANGE = "재생 시작 시간";
const STEP_FINE = "방향키 0.1초";
const STEP_COARSE = "Shift+방향키 1초";
export default function RangeSliderKeyboardArrowPrecision({
duration = "220ms",
easing = "ease-out",
color = "#0a4fe8"
}: RangeSliderKeyboardArrowPrecisionProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const wrapRef = useRef<HTMLDivElement | null>(null);
const rangeRef = useRef<HTMLInputElement | null>(null);
const timeRef = useRef<HTMLElement | null>(null);
const stepRef = useRef<HTMLElement | null>(null);
const rafRef = useRef(0) as Box<number>;
useEffect(() => {
const root = rootRef.current;
const wrap = wrapRef.current;
const range = rangeRef.current;
const timeEl = timeRef.current;
const stepEl = stepRef.current;
if (!root || !wrap || !range || !timeEl || !stepEl) return;
const MIN = 0, MAX = 100;
const K = 120, C = 16; // 가벼운 스프링 — 오버슈트 최소
const TAU = 0.12;
function fmt(v: number): string {
return (v / 10).toFixed(1) + "초";
}
let target = 35, pos = 35, vel = 0;
function paint(): void {
const pct = ((pos - MIN) / (MAX - MIN)) * 100;
wrap!.style.setProperty("--pct", pct.toFixed(2));
timeEl!.textContent = fmt(pos);
range!.setAttribute("aria-valuetext", fmt(Math.round(pos)));
range!.setAttribute("aria-valuenow", String(Math.round(pos)));
}
paint();
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = true;
const stop = (): void => {
playing = false;
};
const onInput = (): void => {
stop();
target = Number(range.value);
pos = target;
vel = 0;
paint();
};
const onKeydown = (e: KeyboardEvent): void => {
const fine = 1, coarse = 10;
let delta = 0;
if (e.key === "ArrowRight" || e.key === "ArrowUp") delta = e.shiftKey ? coarse : fine;
else if (e.key === "ArrowLeft" || e.key === "ArrowDown") delta = e.shiftKey ? -coarse : -fine;
else return;
e.preventDefault();
stop();
stepEl.textContent = e.shiftKey ? STEP_COARSE : STEP_FINE;
target = Math.max(MIN, Math.min(MAX, target + delta));
range.value = String(target);
paint();
};
range.addEventListener("pointerdown", stop);
range.addEventListener("input", onInput);
range.addEventListener("keydown", onKeydown);
const segTargets = [35, 82, 6, 60, 95, 35];
let seg = 0, segFrom = target;
const SEG_MS = 420;
let segStart = performance.now();
let last = segStart;
if (reduce) {
target = segTargets[0];
pos = target;
range.value = String(target);
paint();
} else {
const step = (now: number): void => {
const dt = Math.min((now - last) / 1000, 0.05);
last = now;
if (playing) {
let elapsed = now - segStart;
if (elapsed >= SEG_MS) {
seg = (seg + 1) % segTargets.length;
segStart = now;
elapsed = 0;
segFrom = target;
}
const t = elapsed / 1000;
const k = 1 - Math.exp(-t / TAU);
target = segFrom + (segTargets[seg] - segFrom) * k;
}
const acc = -K * (pos - target) - C * vel;
vel += acc * dt;
pos += vel * dt;
range.value = String(Math.round(pos));
paint();
rafRef.current = requestAnimationFrame(step);
};
step(last);
}
return () => {
range.removeEventListener("pointerdown", stop);
range.removeEventListener("input", onInput);
range.removeEventListener("keydown", onKeydown);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div
className={styles.ka}
ref={rootRef}
style={{ "--duration": duration, "--easing": easing, "--color": color } as Vars}
>
<p className={styles.hint}>{HINT}</p>
<div className={styles.wrap} ref={wrapRef} role="img" aria-label={ARIA_SCENE}>
<input
className={styles.range}
ref={rangeRef}
type="range"
min={0}
max={100}
step={1}
defaultValue={35}
aria-label={LABEL_RANGE}
/>
</div>
<p className={styles.meta}>
<b className={styles.tag} ref={timeRef}>3.5초</b>
<span className={styles.unit} ref={stepRef}>{STEP_FINE}</span>
</p>
</div>
</main>
);
}07 キーボード精密調整 — 矢印・Shiftのステップ幅とaria-valuetextの同期 — 計測 累積面積 4.829% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · css レンジスライダー — 慣性・スナップ計算を重ねたカスタム9種
このデモが属する記事
css レンジスライダー — 慣性・スナップ計算を重ねたカスタム9種css レンジスライダーは値が変わるたびに計算が動く一枚の層です。ドラッグを離すと速度ベースの慣性lerpが減衰して止まり、値が変わるたびにスプリングでオーバーシュートしてから収まります。目盛りスナップ・縦型ボリューム・温度グラデーショント この記事のデモをすべて見る


