09모션 감소 즉시 전환 — 모션 감소 설정이 켜지면 앞선 여덟 방식의 전환이 전부 중간 단계 없이 목표 값으로 바뀌는 방식
모션 감소 값이 켜져 있으면 앞선 여덟 자리 전부 중간 단계 없이 목표 값으로 바로 바뀝니다.
쓰임접근성 설정을 켠 사용자의 화면, 애니메이션을 줄이고 싶은 모바일 앱
DURATION_MSCYCLE_MSmatchMedia

<main class="stage">
<div class="rm is-demo" id="rm">
<div class="rm__panel" data-role="normal">
<p class="rm__caption">일반 전환</p>
<div class="rm__track" id="rmTrackA">
<div class="rm__indicator rm__indicator--a" id="rmIndicatorA"></div>
<button type="button" class="rm__tab is-active" data-idx="0">
<svg class="rm__icon" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2"/><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z"/></g></svg>
<span class="rm__label">홈</span>
</button>
<button type="button" class="rm__tab" data-idx="1">
<svg class="rm__icon" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 80v120a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8V80a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2"/><path d="M216 64h-40a48 48 0 0 0-96 0H40a16 16 0 0 0-16 16v120a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V80a16 16 0 0 0-16-16m-88-32a32 32 0 0 1 32 32H96a32 32 0 0 1 32-32m88 168H40V80h176Z"/></g></svg>
<span class="rm__label">쇼핑</span>
</button>
</div>
<p class="rm__readout" id="rmReadoutA">0 / 320ms</p>
</div>
<div class="rm__panel" data-role="reduced">
<p class="rm__caption">축소 모션 전환</p>
<div class="rm__track" id="rmTrackB">
<div class="rm__indicator rm__indicator--b" id="rmIndicatorB"></div>
<button type="button" class="rm__tab is-active" data-idx="0">
<svg class="rm__icon" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120" opacity=".2"/><path d="m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z"/></g></svg>
<span class="rm__label">홈</span>
</button>
<button type="button" class="rm__tab" data-idx="1">
<svg class="rm__icon" viewBox="0 0 256 256" aria-hidden="true"><g fill="currentColor"><path d="M224 80v120a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8V80a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8" opacity=".2"/><path d="M216 64h-40a48 48 0 0 0-96 0H40a16 16 0 0 0-16 16v120a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V80a16 16 0 0 0-16-16m-88-32a32 32 0 0 1 32 32H96a32 32 0 0 1 32-32m88 168H40V80h176Z"/></g></svg>
<span class="rm__label">쇼핑</span>
</button>
</div>
<p class="rm__readout" id="rmReadoutB">0ms</p>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: 일반 패널은 easeOutCubic 을 rAF 로 직접 적분해 실제 경과시간(ms)을
// 화면에 그대로 찍고, 축소 패널은 보간 없이 즉시 놓은 뒤 perf.now 차로 실제
// 지연시간을 측정해 찍는다 — 두 패널 다 CSS transition 이 아니라 계산이 위치를 만든다.
@include stage($stage-paper);
// .rm 은 is-demo 클래스와 실제 레이아웃 규칙(너비·flex)을 함께 들고 있는 그리드
// 직계 아이템이다 — is-demo 만 들고 아무 규칙도 없는 빈 래퍼를 한 겹 더 두면
// .stage 의 grid place-items:center 밑에서 자식 width:100% 가 0으로 무너진다
// (367-05 실측). 여기서는 그 빈 래퍼 자체를 두지 않는다.
.rm {
width: 100%; max-width: 420px; display: flex; gap: $sp-4; justify-content: center;
}
.rm__panel {
flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: $sp-2;
}
.rm__caption { font-size: 11px; font-weight: 700; color: $subject-ink; margin: 0; }
.rm__track {
position: relative; width: 100%; display: flex; justify-content: space-around;
background: $subject-blue-light; border-radius: $r-card; padding: $sp-2 0;
box-shadow: $shadow-raised;
}
.rm__panel[data-role="reduced"] .rm__track { background: $subject-mint; }
.rm__indicator {
position: absolute; left: 0; top: 6px; border-radius: $r-pill; background: $subject-cream;
will-change: transform;
}
.rm__tab {
position: relative; z-index: 1; flex: 0 0 auto; display: flex; flex-direction: column;
align-items: center; gap: $sp-1; border: none; background: transparent; cursor: pointer;
padding: $sp-1 $sp-2; color: rgba($subject-ink, .72); transition: color .15s ease;
}
.rm__tab.is-active { color: $subject-blue-deep; }
.rm__icon { width: 16px; height: 16px; }
.rm__label { font-size: 9px; font-weight: 700; line-height: 1; }
.rm__readout { font-size: 11px; color: $subject-ink; margin: 0; font-weight: 600; }
@media (max-width: 340px) {
.rm { gap: $sp-2; }
.rm__tab { padding: $sp-1; }
}
// 장식 요소(탭 글자색 전환)만 줄인다 — 일반 패널의 비교용 이동 자체는 rAF 계산이라
// 이 블록과 무관하게 항상 그대로 재생된다(비교를 보여주는 목적이 실제 설정보다 먼저다).
@media (prefers-reduced-motion: reduce) {
.rm__tab { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rm {
width: 100%;
max-width: 420px;
display: flex;
gap: 16px;
justify-content: center;
}
.rm__panel {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.rm__caption {
font-size: 11px;
font-weight: 700;
color: #17141a;
margin: 0;
}
.rm__track {
position: relative;
width: 100%;
display: flex;
justify-content: space-around;
background: #5b8cf8;
border-radius: 16px;
padding: 8px 0;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.rm__panel[data-role=reduced] .rm__track {
background: #4cd4a6;
}
.rm__indicator {
position: absolute;
left: 0;
top: 6px;
border-radius: 999px;
background: #fff7e6;
will-change: transform;
}
.rm__tab {
position: relative;
z-index: 1;
flex: 0 0 auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
border: none;
background: transparent;
cursor: pointer;
padding: 4px 8px;
color: rgba(23, 20, 26, 0.72);
transition: color 0.15s ease;
}
.rm__tab.is-active {
color: #0a4fe8;
}
.rm__icon {
width: 16px;
height: 16px;
}
.rm__label {
font-size: 9px;
font-weight: 700;
line-height: 1;
}
.rm__readout {
font-size: 11px;
color: #17141a;
margin: 0;
font-weight: 600;
}
@media (max-width: 340px) {
.rm {
gap: 8px;
}
.rm__tab {
padding: 4px;
}
}
@media (prefers-reduced-motion: reduce) {
.rm__tab {
transition: none;
}
}(function () {
var DURATION_MS = 320; // 계산 겹: 일반 패널의 전환 실제 시간(ms) — CSS transition 이 아니라 rAF 로 직접 적분
var CYCLE_MS = 700; // 자동 루프가 좌우 탭을 오가는 간격
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var rm = document.getElementById('rm');
var trackA = document.getElementById('rmTrackA');
var indicatorA = document.getElementById('rmIndicatorA');
var readoutA = document.getElementById('rmReadoutA');
var trackB = document.getElementById('rmTrackB');
var indicatorB = document.getElementById('rmIndicatorB');
var readoutB = document.getElementById('rmReadoutB');
var tabsA = Array.prototype.slice.call(trackA.querySelectorAll('.rm__tab'));
var tabsB = Array.prototype.slice.call(trackB.querySelectorAll('.rm__tab'));
var isDemo = true;
function human() {
if (isDemo) { rm.classList.remove('is-demo'); isDemo = false; }
}
function measure(track, tabs) {
var trackRect = track.getBoundingClientRect();
return tabs.map(function (t) {
var r = t.getBoundingClientRect();
return { left: r.left - trackRect.left, width: r.width, height: r.height };
});
}
// ---- 패널 A: 일반 전환 — easeOutCubic 을 rAF 로 직접 적분해 진짜 경과시간을 잰다.
// 실제 OS/브라우저의 prefers-reduced-motion 설정과 무관하게 항상 이 계산을 수행한다.
var geoA = measure(trackA, tabsA);
indicatorA.style.width = geoA[0].width + 'px';
indicatorA.style.height = geoA[0].height + 'px';
var activeA = 0;
var fromXA = geoA[0].left, toXA = geoA[0].left;
var transStartA = 0;
var transitioningA = false;
indicatorA.style.transform = 'translateX(' + geoA[0].left.toFixed(1) + 'px)';
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
function goToA(idx, ts) {
activeA = idx;
tabsA.forEach(function (t, i) { t.classList.toggle('is-active', i === idx); });
fromXA = currentXA(ts);
toXA = geoA[idx].left;
transStartA = ts;
transitioningA = true;
}
function currentXA(ts) {
if (!transitioningA) return toXA;
var t = Math.min(1, (ts - transStartA) / DURATION_MS);
return fromXA + (toXA - fromXA) * easeOutCubic(t);
}
// ---- 패널 B: 축소 모션 전환 — 보간 없이 즉시 위치를 놓고, 실제 지연시간(perf.now 차)만 잰다.
// 이 패널은 늘 즉시 전환한다는 사실을 보여주는 쪽이라 실제 설정과 무관하게 동작을 고정한다.
var geoB = measure(trackB, tabsB);
indicatorB.style.width = geoB[0].width + 'px';
indicatorB.style.height = geoB[0].height + 'px';
var activeB = 0;
var switchAtB = 0;
var latencyMeasured = true;
indicatorB.style.transform = 'translateX(' + geoB[0].left.toFixed(1) + 'px)';
function goToB(idx, ts) {
activeB = idx;
tabsB.forEach(function (t, i) { t.classList.toggle('is-active', i === idx); });
indicatorB.style.transform = 'translateX(' + geoB[idx].left.toFixed(1) + 'px)';
switchAtB = ts;
latencyMeasured = false;
}
tabsA.forEach(function (t, i) {
t.addEventListener('click', function () { human(); if (i !== activeA) goToA(i, performance.now()); });
});
tabsB.forEach(function (t, i) {
t.addEventListener('click', function () { human(); if (i !== activeB) goToB(i, performance.now()); });
});
var cycleStart = 0;
var lastCycleIdx = 0;
function tick(ts) {
if (!cycleStart) cycleStart = ts;
if (isDemo && !reduce) {
var step = Math.floor((ts - cycleStart) / CYCLE_MS);
var idx = step % 2;
if (idx !== lastCycleIdx) {
lastCycleIdx = idx;
if (idx !== activeA) goToA(idx, ts);
if (idx !== activeB) goToB(idx, ts);
}
}
if (transitioningA) {
var x = currentXA(ts);
indicatorA.style.transform = 'translateX(' + x.toFixed(1) + 'px)';
var elapsed = Math.min(DURATION_MS, ts - transStartA);
readoutA.textContent = Math.round(elapsed) + ' / ' + DURATION_MS + 'ms';
if (elapsed >= DURATION_MS) { transitioningA = false; }
}
if (!latencyMeasured) {
var lat = ts - switchAtB;
readoutB.textContent = Math.round(lat) + 'ms (1프레임)';
latencyMeasured = true;
}
requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
})();// ReducedMotionInstant — vanilla 판(run/367/demo/09-bottom-nav-bar-reduced-motion-instant/index.html) 의
// React 포트. 패널 A(일반)는 easeOutCubic 을 rAF 로 직접 적분해 실제 경과시간(ms)을 화면에
// 그대로 찍고, 패널 B(축소)는 보간 없이 즉시 놓은 뒤 perf.now 차로 실제 지연시간을 측정해
// 찍는다 — 두 패널 다 CSS transition 이 아니라 계산이 위치를 만든다.
import { useEffect, useRef } from "react";
import styles from "./ReducedMotionInstant.module.scss";
type Box<T> = { current: T };
type Geo = { left: number; width: number; height: number };
const DURATION_MS = 320; // 계산 겹: 일반 패널의 전환 실제 시간(ms) — CSS transition 이 아니라 rAF 로 직접 적분
const CYCLE_MS = 700; // 자동 루프가 좌우 탭을 오가는 간격
const TABS = [
{ label: "홈", d: "M216 120v96h-64v-64h-48v64H40v-96a8 8 0 0 1 2.34-5.66l80-80a8 8 0 0 1 11.32 0l80 80A8 8 0 0 1 216 120|.2#m219.31 108.68l-80-80a16 16 0 0 0-22.62 0l-80 80A15.87 15.87 0 0 0 32 120v96a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-56h32v56a8 8 0 0 0 8 8h64a8 8 0 0 0 8-8v-96a15.87 15.87 0 0 0-4.69-11.32M208 208h-48v-56a8 8 0 0 0-8-8h-48a8 8 0 0 0-8 8v56H48v-88l80-80l80 80Z" },
{ label: "쇼핑", d: "M224 80v120a8 8 0 0 1-8 8H40a8 8 0 0 1-8-8V80a8 8 0 0 1 8-8h176a8 8 0 0 1 8 8|.2#M216 64h-40a48 48 0 0 0-96 0H40a16 16 0 0 0-16 16v120a16 16 0 0 0 16 16h176a16 16 0 0 0 16-16V80a16 16 0 0 0-16-16m-88-32a32 32 0 0 1 32 32H96a32 32 0 0 1 32-32m88 168H40V80h176Z" },
];
function Icon({ spec }: { spec: string }) {
const [a, b] = spec.split("#");
const [pathA, opacity] = a.split("|");
return (
<svg className={styles.icon} viewBox="0 0 256 256" aria-hidden="true">
<g fill="currentColor">
<path d={pathA} opacity={opacity} />
<path d={b} />
</g>
</svg>
);
}
function easeOutCubic(t: number): number { return 1 - Math.pow(1 - t, 3); }
export default function ReducedMotionInstant() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const trackARef = useRef(null) as unknown as Box<HTMLDivElement>;
const indicatorARef = useRef(null) as unknown as Box<HTMLDivElement>;
const readoutARef = useRef(null) as unknown as Box<HTMLParagraphElement>;
const tabARefs = useRef([null, null]) as unknown as Box<(HTMLButtonElement | null)[]>;
const trackBRef = useRef(null) as unknown as Box<HTMLDivElement>;
const indicatorBRef = useRef(null) as unknown as Box<HTMLDivElement>;
const readoutBRef = useRef(null) as unknown as Box<HTMLParagraphElement>;
const tabBRefs = useRef([null, null]) as unknown as Box<(HTMLButtonElement | null)[]>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const trackA = trackARef.current, indicatorA = indicatorARef.current, readoutA = readoutARef.current;
const trackB = trackBRef.current, indicatorB = indicatorBRef.current, readoutB = readoutBRef.current;
const tabsA = tabARefs.current, tabsB = tabBRefs.current;
if (!trackA || !indicatorA || !readoutA || !trackB || !indicatorB || !readoutB) return;
let isDemo = true;
function human() {
if (isDemo) { rootRef.current?.classList.remove(styles.isDemo); isDemo = false; }
}
function measure(track: HTMLDivElement, tabs: (HTMLButtonElement | null)[]): Geo[] {
const trackRect = track.getBoundingClientRect();
return tabs.map((t) => {
const r = t!.getBoundingClientRect();
return { left: r.left - trackRect.left, width: r.width, height: r.height };
});
}
// ---- 패널 A: 일반 전환
const geoA = measure(trackA, tabsA);
indicatorA.style.width = geoA[0].width + "px";
indicatorA.style.height = geoA[0].height + "px";
let activeA = 0;
let fromXA = geoA[0].left, toXA = geoA[0].left;
let transStartA = 0;
let transitioningA = false;
indicatorA.style.transform = "translateX(" + geoA[0].left.toFixed(1) + "px)";
function currentXA(ts: number) {
if (!transitioningA) return toXA;
const t = Math.min(1, (ts - transStartA) / DURATION_MS);
return fromXA + (toXA - fromXA) * easeOutCubic(t);
}
function goToA(idx: number, ts: number) {
activeA = idx;
tabsA.forEach((t, i) => t?.classList.toggle(styles.isActive, i === idx));
fromXA = currentXA(ts);
toXA = geoA[idx].left;
transStartA = ts;
transitioningA = true;
}
// ---- 패널 B: 축소 모션 전환
const geoB = measure(trackB, tabsB);
indicatorB.style.width = geoB[0].width + "px";
indicatorB.style.height = geoB[0].height + "px";
let activeB = 0;
let switchAtB = 0;
let latencyMeasured = true;
indicatorB.style.transform = "translateX(" + geoB[0].left.toFixed(1) + "px)";
function goToB(idx: number, ts: number) {
activeB = idx;
tabsB.forEach((t, i) => t?.classList.toggle(styles.isActive, i === idx));
indicatorB.style.transform = "translateX(" + geoB[idx].left.toFixed(1) + "px)";
switchAtB = ts;
latencyMeasured = false;
}
const handlersA = tabsA.map((_, i) => () => { human(); if (i !== activeA) goToA(i, performance.now()); });
tabsA.forEach((t, i) => t?.addEventListener("click", handlersA[i]));
const handlersB = tabsB.map((_, i) => () => { human(); if (i !== activeB) goToB(i, performance.now()); });
tabsB.forEach((t, i) => t?.addEventListener("click", handlersB[i]));
let cycleStart = 0;
let lastCycleIdx = 0;
function tick(ts: number) {
if (!cycleStart) cycleStart = ts;
if (isDemo && !reduce) {
const step = Math.floor((ts - cycleStart) / CYCLE_MS);
const idx = step % 2;
if (idx !== lastCycleIdx) {
lastCycleIdx = idx;
if (idx !== activeA) goToA(idx, ts);
if (idx !== activeB) goToB(idx, ts);
}
}
if (transitioningA) {
const x = currentXA(ts);
indicatorA.style.transform = "translateX(" + x.toFixed(1) + "px)";
const elapsed = Math.min(DURATION_MS, ts - transStartA);
readoutA.textContent = Math.round(elapsed) + " / " + DURATION_MS + "ms";
if (elapsed >= DURATION_MS) { transitioningA = false; }
}
if (!latencyMeasured) {
const lat = ts - switchAtB;
readoutB.textContent = Math.round(lat) + "ms (1프레임)";
latencyMeasured = true;
}
rafRef.current = requestAnimationFrame(tick);
}
rafRef.current = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(rafRef.current);
tabsA.forEach((t, i) => t?.removeEventListener("click", handlersA[i]));
tabsB.forEach((t, i) => t?.removeEventListener("click", handlersB[i]));
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.root + " " + styles.isDemo} ref={rootRef}>
<div className={styles.panel}>
<p className={styles.caption}>일반 전환</p>
<div className={styles.track} ref={trackARef}>
<div className={styles.indicatorA} ref={indicatorARef} />
{TABS.map((t, i) => (
<button key={t.label} type="button"
className={styles.tab + (i === 0 ? " " + styles.isActive : "")}
aria-current={i === 0 ? "true" : undefined}
ref={(el: HTMLButtonElement | null) => { tabARefs.current[i] = el; }}>
<Icon spec={t.d} />
<span className={styles.label}>{t.label}</span>
</button>
))}
</div>
<p className={styles.readout} ref={readoutARef}>0 / 320ms</p>
</div>
<div className={styles.panel}>
<p className={styles.caption}>축소 모션 전환</p>
<div className={styles.track + " " + styles.trackReduced} ref={trackBRef}>
<div className={styles.indicatorB} ref={indicatorBRef} />
{TABS.map((t, i) => (
<button key={t.label} type="button"
className={styles.tab + (i === 0 ? " " + styles.isActive : "")}
aria-current={i === 0 ? "true" : undefined}
ref={(el: HTMLButtonElement | null) => { tabBRefs.current[i] = el; }}>
<Icon spec={t.d} />
<span className={styles.label}>{t.label}</span>
</button>
))}
</div>
<p className={styles.readout} ref={readoutBRef}>0ms</p>
</div>
</div>
</main>
);
}09 모션 감소 즉시 전환 — 모션 감소 설정이 켜지면 앞선 여덟 방식의 전환이 전부 중간 단계 없이 목표 값으로 바뀌는 방식 — 측정 누적면적 5.072% · 움직인 프레임 12/23GODRICH 원작 · zip 에 SCSS·React 포함 · 모바일 하단 탭바 9종 — 상태 값으로 바뀌는 아홉 가지 전환
이 데모가 속한 글
모바일 하단 탭바 9종 — 상태 값으로 바뀌는 아홉 가지 전환모바일 하단 탭바는 화면 아래 고정된 채 상태 값 하나로 다시 계산해 그리는 자리인데, 아홉 가지 중 하나는 선택 값이 바뀌면 강조 표시의 위치와 너비 값이 새 자리로 함께 옮겨가고, 하나는 알림 수량 값이 올라가면 이 글의 데모 전부 보기


