09リデュースモーション — 慣性・スプリングを切り即時に値を反映する静的代替
matchMediaでprefers-reduced-motionを検知して慣性・オーバーシュートのループを飛ばし、値を即座に位置へ反映すると、アニメーションなしで機能が同じスライダーになります。減衰係数を0にする場合とループ自体を切る場合の違い、aria-valuenowの更新タイミングまで整理します。
使いどころアクセシビリティ対応の仕上げ
prefers-reduced-motiontransitionaria-valuenow

<main class="stage">
<div class="rm" id="rm">
<p class="rm__hint">左はスプリングで追従し、右は値に即座にジャンプします — 実際のOS設定を検知すると左も即座に切り替わります</p>
<div class="rm__row">
<div class="rm__col">
<span class="rm__label">通常</span>
<div class="rm__wrap" id="rmWrapA">
<input class="rm__range" id="rmRangeA" type="range" min="0" max="100" step="1" value="50" aria-label="通常スライダー">
</div>
</div>
<div class="rm__col">
<span class="rm__label">動作を減らす</span>
<div class="rm__wrap" id="rmWrapB">
<input class="rm__range" id="rmRangeB" type="range" min="0" max="100" step="1" value="50" aria-label="動作軽減スライダー">
</div>
</div>
</div>
<p class="rm__meta"><b class="rm__tag" id="rmMode">検知: デフォルト</b><span class="rm__unit">prefers-reduced-motion</span></p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 동작 감소 대응(09) — 두 트랙을 나란히 두고 채움 계산은 동일한 커스텀
// 프로퍼티(--pct) 패턴으로 통일한다. 차이는 JS 쪽 위치 갱신 방식(스프링 vs 즉시)뿐.
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-ink);
$track-h: 8px;
$thumb-d: 18px;
.rm {
display: flex; flex-direction: column; align-items: center; gap: $sp-3;
width: 100%; max-width: 380px;
}
.rm__hint {
margin: 0; font-weight: 700; font-size: clamp(9px, 2.6vw, 11px); line-height: 1.35;
color: $subject-cream; text-align: center;
}
.rm__row { display: flex; gap: $sp-4; width: 100%; justify-content: center; }
.rm__col { display: flex; flex-direction: column; align-items: center; gap: $sp-2; width: 45%; }
.rm__label { font-weight: 700; font-size: 10px; color: rgba($subject-cream, .82); }
.rm__wrap {
position: relative; width: 100%; height: 32px;
display: flex; align-items: center;
--pct: 50;
}
.rm__range {
appearance: none; -webkit-appearance: none;
width: 100%; height: $thumb-d; margin: 0; background: transparent;
}
.rm__range:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 4px; border-radius: $r-pill; }
.rm__range::-webkit-slider-runnable-track {
height: $track-h; border-radius: $r-pill;
background: linear-gradient(to right, $color calc(var(--pct) * 1%), rgba($subject-cream, .16) calc(var(--pct) * 1%));
}
.rm__range::-webkit-slider-thumb {
appearance: none; -webkit-appearance: none;
width: $thumb-d; height: $thumb-d; border-radius: 50%;
background: $subject-cream; box-shadow: $shadow-press;
margin-top: calc(($track-h - $thumb-d) / 2);
}
.rm__range::-moz-range-track {
height: $track-h; border-radius: $r-pill; background: rgba($subject-cream, .16);
}
.rm__range::-moz-range-progress {
height: $track-h; border-radius: $r-pill; background: $color;
}
.rm__range::-moz-range-thumb {
width: $thumb-d; height: $thumb-d; border: 0; border-radius: 50%;
background: $subject-cream; box-shadow: $shadow-press;
}
.rm__meta { margin: 0; font-size: clamp(9px, 2.2vw, 10px); color: rgba($subject-cream, .82); }
.rm__tag { font-weight: 700; color: $subject-blue-light; font-variant-numeric: tabular-nums; margin-right: $sp-1; }
.rm__unit { color: rgba($subject-cream, .72); }
@media (max-width: 340px) {
.rm { gap: $sp-2; max-width: 280px; }
.rm__row { gap: $sp-2; }
.rm__hint { font-size: 7px; }
.rm__meta { font-size: 7px; }
}
@media (prefers-reduced-motion: reduce) {
.rm__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: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rm {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
max-width: 380px;
}
.rm__hint {
margin: 0;
font-weight: 700;
font-size: clamp(9px, 2.6vw, 11px);
line-height: 1.35;
color: #fff7e6;
text-align: center;
}
.rm__row {
display: flex;
gap: 16px;
width: 100%;
justify-content: center;
}
.rm__col {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
width: 45%;
}
.rm__label {
font-weight: 700;
font-size: 10px;
color: rgba(255, 247, 230, 0.82);
}
.rm__wrap {
position: relative;
width: 100%;
height: 32px;
display: flex;
align-items: center;
--pct: 50;
}
.rm__range {
appearance: none;
-webkit-appearance: none;
width: 100%;
height: 18px;
margin: 0;
background: transparent;
}
.rm__range:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 4px;
border-radius: 999px;
}
.rm__range::-webkit-slider-runnable-track {
height: 8px;
border-radius: 999px;
background: linear-gradient(to right, #2f6df6 calc(var(--pct) * 1%), rgba(255, 247, 230, 0.16) calc(var(--pct) * 1%));
}
.rm__range::-webkit-slider-thumb {
appearance: none;
-webkit-appearance: none;
width: 18px;
height: 18px;
border-radius: 50%;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
margin-top: -5px;
}
.rm__range::-moz-range-track {
height: 8px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.16);
}
.rm__range::-moz-range-progress {
height: 8px;
border-radius: 999px;
background: #2f6df6;
}
.rm__range::-moz-range-thumb {
width: 18px;
height: 18px;
border: 0;
border-radius: 50%;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.rm__meta {
margin: 0;
font-size: clamp(9px, 2.2vw, 10px);
color: rgba(255, 247, 230, 0.82);
}
.rm__tag {
font-weight: 700;
color: #5b8cf8;
font-variant-numeric: tabular-nums;
margin-right: 4px;
}
.rm__unit {
color: rgba(255, 247, 230, 0.72);
}
@media (max-width: 340px) {
.rm {
gap: 8px;
max-width: 280px;
}
.rm__row {
gap: 8px;
}
.rm__hint {
font-size: 7px;
}
.rm__meta {
font-size: 7px;
}
}
@media (prefers-reduced-motion: reduce) {
.rm__range {
transition: none;
}
}// 동작 감소 대응(09) — 같은 목표값 배열을 두 패널이 같이 따라가되, 왼쪽(A)은
// 스프링 보간, 오른쪽(B)은 목표가 바뀌는 즉시 그 값으로 점프한다(애니메이션이
// 아니라 상태 전환). matchMedia('(prefers-reduced-motion: reduce)') 로 실제
// OS 설정을 읽어 reduce 이면 A 도 B 와 같은 즉시 점프로 전환한다 — 두 값 중
// 하나를 죽이는 게 아니라 "감쇠를 0 으로" 만드는 방식으로 구현 원리를 보여준다.
// aria-valuenow 는 두 패널 모두 화면 갱신과 같은 프레임에서 갈아준다.
(function () {
var root = document.getElementById('rm');
var wrapA = document.getElementById('rmWrapA');
var wrapB = document.getElementById('rmWrapB');
var rangeA = document.getElementById('rmRangeA');
var rangeB = document.getElementById('rmRangeB');
var modeEl = document.getElementById('rmMode');
if (!root || !wrapA || !wrapB || !rangeA || !rangeB || !modeEl) return;
var MIN = 0, MAX = 100;
var K = 150, C = 17;
var TAU = 0.13;
var mq = window.matchMedia('(prefers-reduced-motion: reduce)');
var reduce = mq.matches;
var MODE_TEXT = { def: '検知: デフォルト', reduced: '検知: 動作軽減' };
modeEl.textContent = reduce ? MODE_TEXT.reduced : MODE_TEXT.def;
function paint(wrap, range, v) {
var pct = (v - MIN) / (MAX - MIN) * 100;
wrap.style.setProperty('--pct', pct.toFixed(2));
range.setAttribute('aria-valuenow', String(Math.round(v)));
}
var posA = 50, velA = 0, target = 50;
var posB = 50;
paint(wrapA, rangeA, posA);
paint(wrapB, rangeB, posB);
var playing = true;
function stop() { playing = false; }
[rangeA, rangeB].forEach(function (r) {
r.addEventListener('pointerdown', stop);
r.addEventListener('keydown', stop);
});
rangeA.addEventListener('input', function () {
stop();
target = Number(rangeA.value); posA = target; velA = 0; posB = target;
rangeB.value = String(target);
paint(wrapA, rangeA, posA); paint(wrapB, rangeB, posB);
});
var targets = [50, 12, 88, 30, 96, 50];
var seg = 0, segFrom = target;
var SEG_MS = 400;
var segStart = performance.now();
var last = segStart;
if (reduce) {
target = targets[0]; posA = target; posB = target;
rangeA.value = String(target); rangeB.value = String(target);
paint(wrapA, rangeA, posA); paint(wrapB, rangeB, posB);
} 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) % targets.length;
segStart = now; elapsed = 0; segFrom = target;
posB = targets[seg];
}
var t = elapsed / 1000;
var k = 1 - Math.exp(-t / TAU);
target = segFrom + (targets[seg] - segFrom) * k;
}
var acc = -K * (posA - target) - C * velA;
velA += acc * dt;
posA += velA * dt;
rangeA.value = String(Math.round(posA));
rangeB.value = String(Math.round(posB));
paint(wrapA, rangeA, posA);
paint(wrapB, rangeB, posB);
requestAnimationFrame(step);
})(last);
}
})();// RangeSliderReducedMotionStatic — vanilla 판(run/334/demo/09-range-slider-reduced-motion-static/index.html)
// 의 React 포트. 같은 목표값 배열을 두 패널이 같이 따라가되, 왼쪽(A)은 스프링 보간,
// 오른쪽(B)은 목표가 바뀌는 즉시 그 값으로 점프한다(애니메이션이 아니라 상태 전환).
// matchMedia('(prefers-reduced-motion: reduce)') 로 실제 OS 설정을 읽어 reduce 이면 A 도
// B 와 같은 즉시 점프로 전환한다 — 두 값 중 하나를 죽이는 게 아니라 "감쇠를 0 으로" 만드는
// 방식으로 구현 원리를 보여준다. aria-valuenow 는 두 패널 모두 화면 갱신과 같은 프레임에서 갈아준다.
import { useEffect, useRef } from "react";
import type { CSSProperties } from "react";
import styles from "./RangeSliderReducedMotionStatic.module.scss";
export type RangeSliderReducedMotionStaticProps = {
duration?: string;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type Box<T> = { current: T };
const HINT = "왼쪽은 스프링으로 뒤쫓고 오른쪽은 즉시 값으로 점프합니다 — 실제 OS 설정을 감지하면 왼쪽도 즉시 전환됩니다";
const LABEL_A = "일반";
const LABEL_B = "동작 줄이기";
const LABEL_RANGE_A = "일반 슬라이더";
const LABEL_RANGE_B = "동작을 줄인 슬라이더";
const MODE_DEF = "감지: 기본";
const MODE_REDUCED = "감지: 동작 줄이기";
const UNIT = "prefers-reduced-motion";
export default function RangeSliderReducedMotionStatic({
duration = "220ms",
easing = "ease-out",
color = "#2f6df6"
}: RangeSliderReducedMotionStaticProps) {
const rootRef = useRef<HTMLDivElement | null>(null);
const wrapARef = useRef<HTMLDivElement | null>(null);
const wrapBRef = useRef<HTMLDivElement | null>(null);
const rangeARef = useRef<HTMLInputElement | null>(null);
const rangeBRef = useRef<HTMLInputElement | null>(null);
const modeRef = useRef<HTMLElement | null>(null);
const rafRef = useRef(0) as Box<number>;
useEffect(() => {
const root = rootRef.current;
const wrapA = wrapARef.current;
const wrapB = wrapBRef.current;
const rangeA = rangeARef.current;
const rangeB = rangeBRef.current;
const modeEl = modeRef.current;
if (!root || !wrapA || !wrapB || !rangeA || !rangeB || !modeEl) return;
const MIN = 0, MAX = 100;
const K = 150, C = 17;
const TAU = 0.13;
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
const reduce = mq.matches;
modeEl.textContent = reduce ? MODE_REDUCED : MODE_DEF;
function paint(wrap: HTMLDivElement, range: HTMLInputElement, v: number): void {
const pct = ((v - MIN) / (MAX - MIN)) * 100;
wrap.style.setProperty("--pct", pct.toFixed(2));
range.setAttribute("aria-valuenow", String(Math.round(v)));
}
let posA = 50, velA = 0, target = 50;
let posB = 50;
paint(wrapA, rangeA, posA);
paint(wrapB, rangeB, posB);
let playing = true;
const stop = (): void => {
playing = false;
};
for (const r of [rangeA, rangeB]) {
r.addEventListener("pointerdown", stop);
r.addEventListener("keydown", stop);
}
const onInputA = (): void => {
stop();
target = Number(rangeA.value);
posA = target;
velA = 0;
posB = target;
rangeB.value = String(target);
paint(wrapA, rangeA, posA);
paint(wrapB, rangeB, posB);
};
rangeA.addEventListener("input", onInputA);
const targets = [50, 12, 88, 30, 96, 50];
let seg = 0, segFrom = target;
const SEG_MS = 400;
let segStart = performance.now();
let last = segStart;
if (reduce) {
target = targets[0];
posA = target;
posB = target;
rangeA.value = String(target);
rangeB.value = String(target);
paint(wrapA, rangeA, posA);
paint(wrapB, rangeB, posB);
} 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) % targets.length;
segStart = now;
elapsed = 0;
segFrom = target;
posB = targets[seg];
}
const t = elapsed / 1000;
const k = 1 - Math.exp(-t / TAU);
target = segFrom + (targets[seg] - segFrom) * k;
}
const acc = -K * (posA - target) - C * velA;
velA += acc * dt;
posA += velA * dt;
rangeA.value = String(Math.round(posA));
rangeB.value = String(Math.round(posB));
paint(wrapA, rangeA, posA);
paint(wrapB, rangeB, posB);
rafRef.current = requestAnimationFrame(step);
};
step(last);
}
return () => {
for (const r of [rangeA, rangeB]) {
r.removeEventListener("pointerdown", stop);
r.removeEventListener("keydown", stop);
}
rangeA.removeEventListener("input", onInputA);
cancelAnimationFrame(rafRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<main className={styles.stage}>
<div
className={styles.rm}
ref={rootRef}
style={{ "--duration": duration, "--easing": easing, "--color": color } as Vars}
>
<p className={styles.hint}>{HINT}</p>
<div className={styles.row}>
<div className={styles.col}>
<span className={styles.label}>{LABEL_A}</span>
<div className={styles.wrap} ref={wrapARef}>
<input
className={styles.range}
ref={rangeARef}
type="range"
min={0}
max={100}
step={1}
defaultValue={50}
aria-label={LABEL_RANGE_A}
/>
</div>
</div>
<div className={styles.col}>
<span className={styles.label}>{LABEL_B}</span>
<div className={styles.wrap} ref={wrapBRef}>
<input
className={styles.range}
ref={rangeBRef}
type="range"
min={0}
max={100}
step={1}
defaultValue={50}
aria-label={LABEL_RANGE_B}
/>
</div>
</div>
</div>
<p className={styles.meta}>
<b className={styles.tag} ref={modeRef}>{MODE_DEF}</b>
<span className={styles.unit}>{UNIT}</span>
</p>
</div>
</main>
);
}09 リデュースモーション — 慣性・スプリングを切り即時に値を反映する静的代替 — 計測 累積面積 2.876% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · css レンジスライダー — 慣性・スナップ計算を重ねたカスタム9種
このデモが属する記事
css レンジスライダー — 慣性・スナップ計算を重ねたカスタム9種css レンジスライダーは値が変わるたびに計算が動く一枚の層です。ドラッグを離すと速度ベースの慣性lerpが減衰して止まり、値が変わるたびにスプリングでオーバーシュートしてから収まります。目盛りスナップ・縦型ボリューム・温度グラデーショント この記事のデモをすべて見る

