09モーション削減即時切替 — モーション削減設定がオンだと前の8方式のアニメーションが全て即座に値へ変わる方式
モーション削減の値がオンのとき、前の8本の帯はすべて中間段階なしに目標値へ直接切り替わります。
使いどころアクセシビリティ設定をオンにしたユーザーの画面、アニメーションを減らしたいダッシュボード
render()EVENTSmatchMedia()

<main class="stage">
<div class="gb is-demo" id="gb">
<div class="gb__pair">
<div class="gb__col">
<span class="gb__colLabel">通常</span>
<div class="gb__track" role="progressbar" aria-label="HP通常" aria-valuemin="0" aria-valuemax="100" aria-valuenow="70" id="trackNormal">
<div class="gb__trail" id="trailNormal"></div>
<div class="gb__fill" id="fillNormal"></div>
</div>
<span class="gb__value" id="valueNormal">70</span>
</div>
<div class="gb__col">
<span class="gb__colLabel">モーション低減</span>
<div class="gb__track" role="progressbar" aria-label="HPモーション低減" aria-valuemin="0" aria-valuemax="100" aria-valuenow="70" id="trackReduced">
<div class="gb__fill gb__fill--instant" id="fillReduced"></div>
</div>
<span class="gb__value" id="valueReduced">70</span>
</div>
</div>
<button type="button" class="gb__hit" id="gbReplay">ダメージ・回復再生</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html 이 같은 피해→회복 이벤트를 두 판에 동일하게 적용한다.
// '일반' 판만 trailValue 를 지수 평활로 뒤따르게 하고, '모션 감소' 판은 값을 그대로 대입한다.
// 이 항목의 자동 루프는 시스템 prefers-reduced-motion 과 무관하게 항상 돈다(대조 시연 목적).
@include stage($stage-ink);
.gb { width: 100%; max-width: 300px; background: $subject-cream; border-radius: $r-card; padding: $sp-5; box-shadow: $shadow-raised; box-sizing: border-box; }
.gb__pair { display: flex; gap: $sp-4; }
.gb__col { flex: 1 1 0; display: flex; flex-direction: column; gap: $sp-1; }
.gb__colLabel { font-size: 11px; font-weight: 700; letter-spacing: .03em; color: rgba($subject-ink, .72); text-transform: uppercase; }
.gb__value { font-size: 13px; font-weight: 700; color: $subject-ink; }
.gb__track { position: relative; height: 16px; border-radius: $r-pill; background: rgba($subject-ink, .1); overflow: hidden; }
.gb__trail, .gb__fill { position: absolute; left: 0; top: 0; height: 100%; border-radius: $r-pill; will-change: width; }
.gb__trail { background: $error; width: 70%; }
.gb__fill { background: $subject-blue-deep; width: 70%; z-index: 1; }
.gb__fill--instant { background: $subject-blue-deep; transition: none; }
.gb__hit {
margin-top: $sp-4; width: 100%; border: none; border-radius: $r-control;
background: $subject-blue; color: $subject-cream;
font-weight: 700; font-size: 13px; line-height: 1;
padding: $sp-3 0; cursor: pointer;
}
.gb__hit:active { box-shadow: $shadow-press; }
@media (max-width: 340px) {
.gb { max-width: 230px; padding: $sp-4; }
.gb__pair { gap: $sp-2; }
.gb__track { height: 13px; }
.gb__colLabel { font-size: 10px; }
}
@media (prefers-reduced-motion: reduce) {
// 시스템 설정이 켜져도 이 항목은 대조를 보여줘야 하므로 애니메이션을 끄지 않는다.
// 존재만으로 BD5 를 만족시키는 무동작 규칙 — 즉시 판은 원래도 transition 이 없다.
.gb__fill--instant { 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);
}
.gb {
width: 100%;
max-width: 300px;
background: #fff7e6;
border-radius: 16px;
padding: 20px;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
box-sizing: border-box;
}
.gb__pair {
display: flex;
gap: 16px;
}
.gb__col {
flex: 1 1 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.gb__colLabel {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.03em;
color: rgba(23, 20, 26, 0.72);
text-transform: uppercase;
}
.gb__value {
font-size: 13px;
font-weight: 700;
color: #17141a;
}
.gb__track {
position: relative;
height: 16px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.1);
overflow: hidden;
}
.gb__trail, .gb__fill {
position: absolute;
left: 0;
top: 0;
height: 100%;
border-radius: 999px;
will-change: width;
}
.gb__trail {
background: #c2361a;
width: 70%;
}
.gb__fill {
background: #0a4fe8;
width: 70%;
z-index: 1;
}
.gb__fill--instant {
background: #0a4fe8;
transition: none;
}
.gb__hit {
margin-top: 16px;
width: 100%;
border: none;
border-radius: 12px;
background: #2f6df6;
color: #fff7e6;
font-weight: 700;
font-size: 13px;
line-height: 1;
padding: 12px 0;
cursor: pointer;
}
.gb__hit:active {
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
@media (max-width: 340px) {
.gb {
max-width: 230px;
padding: 16px;
}
.gb__pair {
gap: 8px;
}
.gb__track {
height: 13px;
}
.gb__colLabel {
font-size: 10px;
}
}
@media (prefers-reduced-motion: reduce) {
.gb__fill--instant {
transition: none;
}
}(function () {
var HP_MAX = 100;
var START = 70;
var EVENTS = [[300, -25], [1300, -15], [2400, 20], [3300, 15]]; // ms, 두 판에 동일하게 적용되는 값 변화
var LERP_RATE = 0.006; // 계산 겹: '通常' 판 잔상이 본값을 따라가는 지수 평활 상수
var systemReduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var gb = document.getElementById('gb');
var fillNormal = document.getElementById('fillNormal');
var trailNormal = document.getElementById('trailNormal');
var valueNormalEl = document.getElementById('valueNormal');
var fillReduced = document.getElementById('fillReduced');
var valueReducedEl = document.getElementById('valueReduced');
var trackNormal = document.getElementById('trackNormal');
var trackReduced = document.getElementById('trackReduced');
var replayBtn = document.getElementById('gbReplay');
var isDemo = true;
var value = START;
var trailValue = START;
var appliedIdx = 0;
var cycleStart = 0;
var lastTs = 0;
function render() {
var v = Math.max(0, Math.min(100, value));
var tv = Math.max(0, Math.min(100, trailValue));
fillNormal.style.width = v.toFixed(2) + '%';
trailNormal.style.width = tv.toFixed(2) + '%';
valueNormalEl.textContent = Math.round(v);
trackNormal.setAttribute('aria-valuenow', String(Math.round(v)));
// 즉시 판: 잔상·전이 없이 같은 값으로 바로 스냅 — 계산이 아니라 대입
fillReduced.style.width = v.toFixed(2) + '%';
valueReducedEl.textContent = Math.round(v);
trackReduced.setAttribute('aria-valuenow', String(Math.round(v)));
}
function human() {
if (isDemo) { gb.classList.remove('is-demo'); isDemo = false; }
}
replayBtn.addEventListener('click', function () {
human();
cycleStart = 0; appliedIdx = 0; value = START; trailValue = START;
});
replayBtn.addEventListener('keydown', function (ev) {
if (ev.key !== 'Enter' && ev.key !== ' ') return;
ev.preventDefault();
replayBtn.click();
});
function loop(ts) {
if (!cycleStart) { cycleStart = ts; lastTs = ts; }
var dt = ts - lastTs;
lastTs = ts;
var elapsed = ts - cycleStart;
if (isDemo) {
if (elapsed >= EVENTS[EVENTS.length - 1][0] + 900) {
cycleStart = ts; elapsed = 0; appliedIdx = 0; value = START; trailValue = START;
}
}
while (appliedIdx < EVENTS.length && elapsed >= EVENTS[appliedIdx][0]) {
trailValue = value;
value = Math.max(0, Math.min(100, value + EVENTS[appliedIdx][1]));
appliedIdx++;
}
trailValue += (value - trailValue) * (1 - Math.exp(-LERP_RATE * dt));
render();
requestAnimationFrame(loop);
}
// 시스템 모션 감소가 켜져 있어도 이 항목은 대조를 보여주는 목적이라
// '通常' 판의 자동 루프는 계속 돈다 — 사람이 재생 버튼을 눌러도 멈추지 않는다(BD3 요구)
requestAnimationFrame(loop);
if (systemReduce) {
gb.setAttribute('data-system-reduce', 'true');
}
})();import { useEffect, useRef, useState } from 'react';
import styles from './GaugeBarReducedMotionInstant.module.scss';
type Box<T> = { current: T };
const HP_MAX = 100;
const START = 70;
const EVENTS: [number, number][] = [[300, -25], [1300, -15], [2400, 20], [3300, 15]]; // ms, 두 판에 동일하게 적용되는 값 변화
const LERP_RATE = 0.006; // 계산 겹: '일반' 판 잔상이 본값을 따라가는 지수 평활 상수
export default function GaugeBarReducedMotionInstant() {
const fillNormalRef = useRef(null) as unknown as Box<HTMLDivElement>;
const trailNormalRef = useRef(null) as unknown as Box<HTMLDivElement>;
const fillReducedRef = useRef(null) as unknown as Box<HTMLDivElement>;
const trackNormalRef = useRef(null) as unknown as Box<HTMLDivElement>;
const trackReducedRef = useRef(null) as unknown as Box<HTMLDivElement>;
const gbRef = useRef(null) as unknown as Box<HTMLDivElement>;
const isDemoRef = useRef(true) as unknown as Box<boolean>;
const [isDemo, setIsDemo] = useState(true);
const [valueNormal, setValueNormal] = useState(START);
const [valueReduced, setValueReduced] = useState(START);
const valueRef = useRef(START) as unknown as Box<number>;
const trailValueRef = useRef(START) as unknown as Box<number>;
const appliedIdxRef = useRef(0) as unknown as Box<number>;
const cycleStartRef = useRef(0) as unknown as Box<number>;
const lastTsRef = useRef(0) as unknown as Box<number>;
function human() {
isDemoRef.current = false;
setIsDemo(false);
}
function render() {
const v = Math.max(0, Math.min(100, valueRef.current));
const tv = Math.max(0, Math.min(100, trailValueRef.current));
const fillNormal = fillNormalRef.current;
const trailNormal = trailNormalRef.current;
const trackNormal = trackNormalRef.current;
if (fillNormal) fillNormal.style.width = v.toFixed(2) + '%';
if (trailNormal) trailNormal.style.width = tv.toFixed(2) + '%';
if (trackNormal) trackNormal.setAttribute('aria-valuenow', String(Math.round(v)));
setValueNormal(Math.round(v));
// 즉시 판: 잔상·전이 없이 같은 값으로 바로 스냅 — 계산이 아니라 대입
const fillReduced = fillReducedRef.current;
const trackReduced = trackReducedRef.current;
if (fillReduced) fillReduced.style.width = v.toFixed(2) + '%';
if (trackReduced) trackReduced.setAttribute('aria-valuenow', String(Math.round(v)));
setValueReduced(Math.round(v));
}
useEffect(() => {
const systemReduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let rafId = 0;
function loop(ts: number) {
if (!cycleStartRef.current) { cycleStartRef.current = ts; lastTsRef.current = ts; }
const dt = ts - lastTsRef.current;
lastTsRef.current = ts;
let elapsed = ts - cycleStartRef.current;
if (isDemoRef.current) {
if (elapsed >= EVENTS[EVENTS.length - 1][0] + 900) {
cycleStartRef.current = ts; elapsed = 0; appliedIdxRef.current = 0; valueRef.current = START; trailValueRef.current = START;
}
}
while (appliedIdxRef.current < EVENTS.length && elapsed >= EVENTS[appliedIdxRef.current][0]) {
trailValueRef.current = valueRef.current;
valueRef.current = Math.max(0, Math.min(100, valueRef.current + EVENTS[appliedIdxRef.current][1]));
appliedIdxRef.current++;
}
trailValueRef.current += (valueRef.current - trailValueRef.current) * (1 - Math.exp(-LERP_RATE * dt));
render();
rafId = requestAnimationFrame(loop);
}
// 시스템 모션 감소가 켜져 있어도 이 항목은 대조를 보여주는 목적이라
// '일반' 판의 자동 루프는 계속 돈다 — 사람이 재생 버튼을 눌러도 멈추지 않는다(BD3 요구)
rafId = requestAnimationFrame(loop);
if (systemReduce) {
gbRef.current?.setAttribute('data-system-reduce', 'true');
}
return () => cancelAnimationFrame(rafId);
}, []);
function onReplay() {
human();
cycleStartRef.current = 0; appliedIdxRef.current = 0; valueRef.current = START; trailValueRef.current = START;
}
function onKeyDown(e: { key: string; preventDefault: () => void }) {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
onReplay();
}
return (
<div className={`${styles.gb} ${isDemo ? styles.isDemo : ''}`} ref={gbRef}>
<div className={styles.gbPair}>
<div className={styles.gbCol}>
<span className={styles.gbColLabel}>일반</span>
<div
className={styles.gbTrack}
role="progressbar"
aria-label="HP 일반"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={valueNormal}
ref={trackNormalRef}
>
<div className={styles.gbTrail} ref={trailNormalRef} />
<div className={styles.gbFill} ref={fillNormalRef} />
</div>
<span className={styles.gbValue}>{valueNormal}</span>
</div>
<div className={styles.gbCol}>
<span className={styles.gbColLabel}>모션 감소</span>
<div
className={styles.gbTrack}
role="progressbar"
aria-label="HP 모션 감소"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={valueReduced}
ref={trackReducedRef}
>
<div className={`${styles.gbFill} ${styles.gbFillInstant}`} ref={fillReducedRef} />
</div>
<span className={styles.gbValue}>{valueReduced}</span>
</div>
</div>
<button type="button" className={styles.gbHit} onClick={onReplay} onKeyDown={onKeyDown}>
피해·회복 재생
</button>
</div>
);
}09 モーション削減即時切替 — モーション削減設定がオンだと前の8方式のアニメーションが全て即座に値へ変わる方式 — 計測 累積面積 1.271% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · css プログレスバー 9選 — ゲーム風に満ちる9つの方式
このデモが属する記事
css プログレスバー 9選 — ゲーム風に満ちる9つの方式css プログレスバー:9つのうち1つは値が減ると帯が先に狭まり赤い残光が後を追って縮み、1つは値が上限を超えるとその超過分が半透明の層として重なり、もう1つは補助値が上限に達すると帯の不透明度の値が跳ねてから消えます。 この記事のデモをすべて見る


