09Reduced-motion instant toggle — with reduced motion on, every transition from the prior eight bars switches straight to its target value
When the reduced-motion flag is on, all eight prior spots switch straight to their target value with no in-between steps.
Best forA screen for users with the accessibility setting on, a mobile app that wants fewer animations
DURATION_MSCYCLE_MSmatchMedia

<main class="stage">
<div class="rm is-demo" id="rm">
<div class="rm__panel" data-role="normal">
<p class="rm__caption">Normal transition</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">Home</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">Shopping</span>
</button>
</div>
<p class="rm__readout" id="rmReadoutA">0 / 320ms</p>
</div>
<div class="rm__panel" data-role="reduced">
<p class="rm__caption">Reduced-motion transition</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">Home</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">Shopping</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: Normal transition — 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: Reduced-motion transition — 보간 없이 즉시 위치를 놓고, 실제 지연시간(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 frame)';
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 Reduced-motion instant toggle — with reduced motion on, every transition from the prior eight bars switches straight to its target value — Measured area 5.072% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · CSS Bottom Navigation Bar: 9 Transition Patterns
The post this demo lives in
CSS Bottom Navigation Bar: 9 Transition PatternsCSS Bottom Navigation Bar: One of nine slides the highlight to the new tab as selection changes, one grows a badge brief See all demos in this post
If you like this effect - similar demos
Transitions07 Checkout bar lift — a bottom bar whose shadow and height values grow with scroll positionCart Drawer UI Patterns: 9 Motion Techniques
Transitions09 Lightbox That Instantly Reflects Reduced MotionCSS Lightbox Gallery Animation — 9 Transitions
Transitions09 Safeguard That Detects a Reduced-Motion Value and Turns the Transition OffView Transitions API: 9 Patterns by One Value