08Reduced-motion response — a transition that nearly stops animating based on system settings
Flipping a switch that simulates an environment set to reduce motion changes the same card transition from an elaborate slide-and-fade into something that appears almost instantly.
Best forAn option for vestibular-sensitive users, an accessibility settings screen
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">Reduced motion simulation</button>
<button class="playBtn" id="playBtn" type="button">Play</button>
</div>
<div class="track" id="track">
<div class="dot" id="dot"></div>
</div>
<div class="scaleRow">
<label class="scaleLabel" for="scaleRange">Scale ratio</label>
<input class="scaleRange" id="scaleRange" type="range" min="0.02" max="0.1" step="0.01" value="0.1" aria-label="Reduced-motion duration ratio">
</div>
<div class="readout" id="readout">Move duration —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 = 'Move duration ' + elapsed + 'ms (set ' + currentDurationMs() + 'ms · ratio ' + 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 를 기다렸다 시작하면 First 재생이 하필 짧은 줄임 모션(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 Reduced-motion response — a transition that nearly stops animating based on system settings — Measured area 5.87% · moving frames 13/23Original by GODRICH · SCSS and React ship in the zip · CSS Reset: 9 Patterns From Basics to Safe-Area
The post this demo lives in
CSS Reset: 9 Patterns From Basics to Safe-AreaNine css reset patterns: one recalculates padding for the safe area, one shrinks transition duration near zero when moti See all demos in this post
If you like this effect - similar demos
base01 System font stack comparerCSS Typography Playground: 9 Demos, No Webfonts
base01 Squircle card — a superellipse exponent value flows between circle and square while the card's corner ridge changesCSS corner-shape: 9 Corner Cutting Patterns
Embeds02 YouTube lite facade — an embed that switches to a full frame once elapsed time crosses a set point9 YouTube Embed Code UI Patterns (Copy-Paste)