08모션 줄이기 대응 — 시스템 설정에 따라 애니메이션이 거의 멈추는 전환
모션을 줄이도록 맞춘 환경을 시뮬레이션하는 스위치를 켜면, 같은 카드 전환이 화려하게 움직이던 상태에서 거의 즉시 나타나는 상태로 바뀝니다.
쓰임전정기관 민감 사용자용 옵션, 접근성 설정 화면
prefers-reduced-motionmatchMediaREDUCED_MOTION_SCALE

<main class="stage">
<div class="wrap is-demo" id="wrap">
<div class="head">
<button class="simBtn" id="simBtn" type="button" aria-pressed="false">줄임 모션 시뮬레이션</button>
<button class="playBtn" id="playBtn" type="button">재생</button>
</div>
<div class="track" id="track">
<div class="dot" id="dot"></div>
</div>
<div class="scaleRow">
<label class="scaleLabel" for="scaleRange">축소 비율</label>
<input class="scaleRange" id="scaleRange" type="range" min="0.02" max="0.1" step="0.01" value="0.1" aria-label="줄임 모션 지속시간 비율">
</div>
<div class="readout" id="readout">이동 시간 —ms</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 에 있다 — performance.now() 로 transitionend 까지 실제 걸린 ms 를
// 다시 재 설정값과 나란히 보여준다. 여기는 트랙·점 모양과 두 겹의 줄임 모션 규칙만 맡는다.
@include stage($stage-yellow);
.wrap {
width: 300px;
display: flex;
flex-direction: column;
gap: $sp-3;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-2;
}
.simBtn,
.playBtn {
appearance: none;
border: none;
border-radius: $r-pill;
padding: $sp-1 $sp-3;
font-size: 10px;
font-weight: 700;
cursor: pointer;
transition: background $dur-instant $ease-out, transform $dur-instant $ease-out;
&:active { transform: scale(.96); }
&:focus-visible { outline: 2px solid $subject-ink; outline-offset: 2px; }
}
.simBtn {
background: $subject-ink;
color: $subject-cream;
&[aria-pressed="true"] { background: $subject-blue-deep; }
}
.playBtn {
background: $subject-cream;
color: $subject-ink;
border: 2px solid $subject-ink;
}
.track {
position: relative;
width: 240px;
height: 30px;
border-radius: $r-pill;
background: rgba($subject-ink, .12);
}
.dot {
position: absolute;
top: 3px;
left: 3px;
width: 24px;
height: 24px;
border-radius: $r-pill;
background: $subject-orange-deep;
transform: translateX(0);
transition-property: transform;
transition-timing-function: $ease-out;
&.at-end { transform: translateX(210px); }
}
.scaleRow {
display: flex;
align-items: center;
gap: $sp-2;
}
.scaleLabel {
color: $subject-ink;
font-size: 11px;
font-weight: 700;
}
.scaleRange {
flex: 1 1 auto;
accent-color: $subject-orange-deep;
}
.readout {
color: $subject-ink;
font-size: 11px;
}
// 시연 스위치 — 화면 안에서 "줄임 모션이 켜지면" 을 흉내 낸다(진짜 media query 는 아래)
.wrap.reduceSim .track {
outline: 2px dashed $subject-blue-deep;
outline-offset: 3px;
}
@media (max-width: 340px) {
.wrap { width: 260px; }
.track, .scaleRange { width: 200px; }
}
@media (prefers-reduced-motion: reduce) {
// 실사용 경로 — 진짜 OS/브라우저 설정이 켜지면 JS 가 무슨 배율을 넣었든 이동은 거의 즉시다.
// !important 로 인라인 transitionDuration 보다 우선하게 만든다(이 블록만 실사용에 실제로 영향).
.dot { transition-duration: 90ms !important; }
.simBtn, .playBtn { transition: none; }
}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);
}
.wrap {
width: 300px;
display: flex;
flex-direction: column;
gap: 12px;
}
.head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.simBtn,
.playBtn {
appearance: none;
border: none;
border-radius: 999px;
padding: 4px 12px;
font-size: 10px;
font-weight: 700;
cursor: pointer;
transition: background 140ms ease-out, transform 140ms ease-out;
}
.simBtn:active,
.playBtn:active {
transform: scale(0.96);
}
.simBtn:focus-visible,
.playBtn:focus-visible {
outline: 2px solid #17141a;
outline-offset: 2px;
}
.simBtn {
background: #17141a;
color: #fff7e6;
}
.simBtn[aria-pressed=true] {
background: #0a4fe8;
}
.playBtn {
background: #fff7e6;
color: #17141a;
border: 2px solid #17141a;
}
.track {
position: relative;
width: 240px;
height: 30px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.12);
}
.dot {
position: absolute;
top: 3px;
left: 3px;
width: 24px;
height: 24px;
border-radius: 999px;
background: #c93000;
transform: translateX(0);
transition-property: transform;
transition-timing-function: ease-out;
}
.dot.at-end {
transform: translateX(210px);
}
.scaleRow {
display: flex;
align-items: center;
gap: 8px;
}
.scaleLabel {
color: #17141a;
font-size: 11px;
font-weight: 700;
}
.scaleRange {
flex: 1 1 auto;
accent-color: #c93000;
}
.readout {
color: #17141a;
font-size: 11px;
}
.wrap.reduceSim .track {
outline: 2px dashed #0a4fe8;
outline-offset: 3px;
}
@media (max-width: 340px) {
.wrap {
width: 260px;
}
.track, .scaleRange {
width: 200px;
}
}
@media (prefers-reduced-motion: reduce) {
.dot {
transition-duration: 90ms !important;
}
.simBtn, .playBtn {
transition: none;
}
}(function () {
var BASE_DURATION_MS = 900;
var REDUCED_MOTION_SCALE = 0.1;
var STEP_MS = 1400;
// 실제 OS/브라우저 줄임 모션 설정 — 이건 자동 루프를 멈추는 데만 쓴다(시연 게이트 전용 판단).
// reduceSim(아래) 은 이 데모가 "줄임 모션이 켜지면 무슨 일이 일어나는지" 를 보여주기 위해
// 화면 안에서 흉내 내는 스위치로, 실사용에서는 진짜 media query 가 이 스위치 없이도 똑같이 동작한다.
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var wrap = document.getElementById('wrap');
var track = document.getElementById('track');
var dot = document.getElementById('dot');
var simBtn = document.getElementById('simBtn');
var playBtn = document.getElementById('playBtn');
var scaleRange = document.getElementById('scaleRange');
var readout = document.getElementById('readout');
var isDemo = true;
var reduceSim = false;
var runStart = 0;
var running = false;
function currentDurationMs() {
return reduceSim ? Math.round(BASE_DURATION_MS * REDUCED_MOTION_SCALE) : BASE_DURATION_MS;
}
function applyDuration() {
dot.style.transitionDuration = currentDurationMs() + 'ms';
}
function play() {
if (running) return;
running = true;
applyDuration();
dot.classList.remove('at-end');
// 강제 리플로 — 위치를 원점으로 되돌린 상태를 화면에 반영한 뒤에 이동을 건다
void dot.offsetWidth;
runStart = performance.now();
dot.classList.add('at-end');
}
dot.addEventListener('transitionend', function () {
// 실측 — 실제로 걸린 시간을 performance.now() 로 다시 잰다(계산값을 미리 적지 않는다)
var elapsed = Math.round(performance.now() - runStart);
readout.textContent = '이동 시간 ' + elapsed + 'ms (설정 ' + currentDurationMs() + 'ms · 비율 ' + REDUCED_MOTION_SCALE.toFixed(2) + ')';
running = false;
});
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
simBtn.addEventListener('click', function () {
human();
reduceSim = !reduceSim;
wrap.classList.toggle('reduceSim', reduceSim);
simBtn.setAttribute('aria-pressed', String(reduceSim));
applyDuration();
});
playBtn.addEventListener('click', function () { human(); play(); });
scaleRange.addEventListener('input', function () {
human();
REDUCED_MOTION_SCALE = Number(scaleRange.value);
applyDuration();
});
scaleRange.addEventListener('pointerdown', human);
var last = 0;
function loop(ts) {
if (!last) last = ts;
if (isDemo && !reduce && ts - last >= STEP_MS) {
last = ts;
reduceSim = !reduceSim;
wrap.classList.toggle('reduceSim', reduceSim);
simBtn.setAttribute('aria-pressed', String(reduceSim));
play();
}
requestAnimationFrame(loop);
}
applyDuration();
// 첫 재생은 즉시(느린 900ms 쪽)로 걸어 캡처 구간 안에 이동이 충분히 퍼지게 한다 —
// 루프가 STEP_MS 를 기다렸다 시작하면 첫 번째 재생이 하필 짧은 줄임 모션(90ms) 이 되어
// 2초 구간 안에 움직임이 한두 프레임 만에 끝나 버린다(실측 2026-09-22)
play();
requestAnimationFrame(loop);
})();// ReducedMotion — vanilla 판(run/376/demo/08-reduced-motion/index.html) 의 React 포트.
// performance.now() 로 transitionend 까지 실제 걸린 ms 를 다시 재 설정값과 나란히 보여준다.
// reduceSim(JS 스위치, 시연 게이트 전용)과 실제 OS/브라우저 prefers-reduced-motion(reduce,
// 자동 루프를 멈추는 데만 쓴다)은 서로 다른 것 — !important 실사용 규칙은 .module.scss 에 있다.
import { useEffect, useRef } from "react";
import styles from "./ReducedMotion.module.scss";
type Box<T> = { current: T };
const BASE_DURATION_MS = 900;
const STEP_MS = 1400;
export default function ReducedMotion() {
const wrapRef = useRef(null) as unknown as Box<HTMLDivElement>;
const dotRef = useRef(null) as unknown as Box<HTMLDivElement>;
const simBtnRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const playBtnRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const scaleRangeRef = useRef(null) as unknown as Box<HTMLInputElement>;
const readoutRef = useRef(null) as unknown as Box<HTMLDivElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const wrap = wrapRef.current;
const dot = dotRef.current;
const simBtn = simBtnRef.current;
const playBtn = playBtnRef.current;
const scaleRange = scaleRangeRef.current;
const readout = readoutRef.current;
if (!wrap || !dot || !simBtn || !playBtn || !scaleRange || !readout) return;
let isDemo = true;
let reduceSim = false;
let runStart = 0;
let running = false;
let reducedMotionScale = 0.1;
function currentDurationMs() {
return reduceSim ? Math.round(BASE_DURATION_MS * reducedMotionScale) : BASE_DURATION_MS;
}
function applyDuration() {
dot!.style.transitionDuration = currentDurationMs() + "ms";
}
function play() {
if (running) return;
running = true;
applyDuration();
dot!.classList.remove(styles.atEnd);
void dot!.offsetWidth;
runStart = performance.now();
dot!.classList.add(styles.atEnd);
}
function onTransitionEnd() {
const elapsed = Math.round(performance.now() - runStart);
readout!.textContent =
"이동 시간 " + elapsed + "ms (설정 " + currentDurationMs() + "ms · 비율 " + reducedMotionScale.toFixed(2) + ")";
running = false;
}
dot.addEventListener("transitionend", onTransitionEnd);
function human() { if (isDemo) { wrap!.classList.remove(styles.isDemo); isDemo = false; } }
function onSimClick() {
human();
reduceSim = !reduceSim;
wrap!.classList.toggle(styles.reduceSim, reduceSim);
simBtn!.setAttribute("aria-pressed", String(reduceSim));
applyDuration();
}
function onPlayClick() { human(); play(); }
function onScaleInput() {
human();
reducedMotionScale = Number(scaleRange!.value);
applyDuration();
}
simBtn.addEventListener("click", onSimClick);
playBtn.addEventListener("click", onPlayClick);
scaleRange.addEventListener("input", onScaleInput);
scaleRange.addEventListener("pointerdown", human);
let last = 0;
function loop(ts: number) {
if (!last) last = ts;
if (isDemo && !reduce && ts - last >= STEP_MS) {
last = ts;
reduceSim = !reduceSim;
wrap!.classList.toggle(styles.reduceSim, reduceSim);
simBtn!.setAttribute("aria-pressed", String(reduceSim));
play();
}
rafRef.current = requestAnimationFrame(loop);
}
applyDuration();
// 첫 재생은 즉시(느린 900ms 쪽)로 걸어 캡처 구간 안에 이동이 충분히 퍼지게 한다
play();
rafRef.current = requestAnimationFrame(loop);
return () => {
dot!.removeEventListener("transitionend", onTransitionEnd);
simBtn!.removeEventListener("click", onSimClick);
playBtn!.removeEventListener("click", onPlayClick);
scaleRange!.removeEventListener("input", onScaleInput);
scaleRange!.removeEventListener("pointerdown", human);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={wrapRef}>
<div className={styles.head}>
<button className={styles.simBtn} ref={simBtnRef} type="button" aria-pressed="false">줄임 모션 시뮬레이션</button>
<button className={styles.playBtn} ref={playBtnRef} type="button">재생</button>
</div>
<div className={styles.track}>
<div className={styles.dot} ref={dotRef}></div>
</div>
<div className={styles.scaleRow}>
<label className={styles.scaleLabel} htmlFor="scaleRange08">축소 비율</label>
<input
className={styles.scaleRange}
ref={scaleRangeRef}
id="scaleRange08"
type="range"
min={0.02}
max={0.1}
step={0.01}
defaultValue={0.1}
aria-label="줄임 모션 지속시간 비율"
/>
</div>
<div className={styles.readout} ref={readoutRef}>이동 시간 —ms</div>
</div>
</main>
);
}08 모션 줄이기 대응 — 시스템 설정에 따라 애니메이션이 거의 멈추는 전환 — 측정 누적면적 5.87% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · css 초기화 9종 — 리셋부터 안전 영역까지 한 번에 정리
이 데모가 속한 글
css 초기화 9종 — 리셋부터 안전 영역까지 한 번에 정리아홉 개의 css 초기화 패턴 중 하나는 화면 노치를 감지해 안전 영역만큼 여백을 다시 계산하고, 하나는 모션을 줄이는 환경에서 전환 시간이 거의 0에 가깝게 줄어들고, 하나는 각진 기본 모서리가 매끈한 곡선 모양으 이 글의 데모 전부 보기


