09モーション低減値を検知して遷移を切る安全装置
システムのモーション低減設定値を検知し、その値がオンなら遷移の継続時間をゼロにして画面を即座に切り替え、オフのときだけ上の八つの遷移がそのまま再生される。
使いどころアクセシビリティを守る全項目共通の安全装置
effectiveReduce::view-transition-new(rg-card)SUPPORTS_VT

<main class="stage">
<div class="rg is-demo" id="rg">
<div class="rg__card" id="rgCard">
<span class="rg__badge" id="rgBadge">オン</span>
</div>
<p class="rg__meta">
<span class="rg__pill" id="rgReduce">動作軽減オフ</span>
<span class="rg__pill" id="rgDur">継続時間 <b id="rgDurV">320</b>ms</span>
</p>
<button type="button" class="rg__btn" id="rgBtn" aria-label="動作軽減の値を反転してみる">値を反転</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// ── 변수 3개 ──────────────────────────────────────────
$duration: $dur-base;
$easing: $ease-pop;
$color: $subject-blue-light;
@include stage($stage-ink);
.rg { width: min(100%, 260px); display: flex; flex-direction: column; align-items: center; gap: $sp-3; }
.rg__card {
view-transition-name: rg-card;
width: 120px; height: 90px; border-radius: $r-card; background: $color;
display: grid; place-items: center;
}
.rg__card.is-off { background: rgba($subject-cream, .16); }
::view-transition-old(rg-card),
::view-transition-new(rg-card) {
animation-duration: $duration;
animation-timing-function: $easing;
}
.rg__badge { font: 700 20px/1 -apple-system, "Segoe UI", sans-serif; color: $subject-ink; }
.rg__card.is-off .rg__badge { color: $subject-cream; }
.rg__meta { margin: 0; display: flex; gap: $sp-2; }
.rg__pill {
border-radius: $r-pill; background: rgba($subject-cream, .12); color: $subject-cream;
font: 700 10px/1 -apple-system, sans-serif; padding: $sp-1 $sp-3;
}
.rg__pill b { font-weight: 700; }
.rg__btn {
border: none; border-radius: $r-pill; cursor: pointer; background: $subject-cream;
color: $subject-ink; font: 700 11px/1 -apple-system, sans-serif; padding: $sp-2 $sp-4;
}
.rg__btn:focus-visible { outline: 2px solid $subject-cream; outline-offset: 2px; }
@media (max-width: 340px) {
.rg__card { width: 96px; height: 72px; }
.rg__badge { font-size: 16px; }
.rg__pill { font-size: 9px; padding: $sp-1 $sp-2; }
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) { animation: none !important; }
}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);
}
.rg {
width: min(100%, 260px);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.rg__card {
view-transition-name: rg-card;
width: 120px;
height: 90px;
border-radius: 16px;
background: #5b8cf8;
display: grid;
place-items: center;
}
.rg__card.is-off {
background: rgba(255, 247, 230, 0.16);
}
::view-transition-old(rg-card),
::view-transition-new(rg-card) {
animation-duration: 300ms;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
}
.rg__badge {
font: 700 20px/1 -apple-system, "Segoe UI", sans-serif;
color: #17141a;
}
.rg__card.is-off .rg__badge {
color: #fff7e6;
}
.rg__meta {
margin: 0;
display: flex;
gap: 8px;
}
.rg__pill {
border-radius: 999px;
background: rgba(255, 247, 230, 0.12);
color: #fff7e6;
font: 700 10px/1 -apple-system, sans-serif;
padding: 4px 12px;
}
.rg__pill b {
font-weight: 700;
}
.rg__btn {
border: none;
border-radius: 999px;
cursor: pointer;
background: #fff7e6;
color: #17141a;
font: 700 11px/1 -apple-system, sans-serif;
padding: 8px 16px;
}
.rg__btn:focus-visible {
outline: 2px solid #fff7e6;
outline-offset: 2px;
}
@media (max-width: 340px) {
.rg__card {
width: 96px;
height: 72px;
}
.rg__badge {
font-size: 16px;
}
.rg__pill {
font-size: 9px;
padding: 4px 8px;
}
}
@media (prefers-reduced-motion: reduce) {
::view-transition-group(*),
::view-transition-old(*),
::view-transition-new(*) {
animation: none !important;
}
}(function () {
var SUPPORTS_VT = 'startViewTransition' in document;
var REAL_REDUCE = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var NORMAL_MS = 300;
var root = document.getElementById('rg');
var card = document.getElementById('rgCard');
var badge = document.getElementById('rgBadge');
var reduceEl = document.getElementById('rgReduce');
var durVEl = document.getElementById('rgDurV');
var btn = document.getElementById('rgBtn');
var on = true;
var isDemo = true;
// 데모 전용: 실제 환경 값(REAL_REDUCE)은 헤드리스 렌더에서 대개 꺼짐으로 읽히므로,
// 값이 켜졌을 때의 トランジション 붕괴를 눈으로 보이도록 번갈아 흉내 낸다.
var simulateReduce = false;
function human() {
if (isDemo) { root.classList.remove('is-demo'); isDemo = false; }
}
function render() {
on = !on;
card.classList.toggle('is-off', !on);
badge.textContent = on ? 'オン' : 'オフ';
}
// 계산 겹: 실제 동작 줄임 값과 시연용 값을 합쳐 하나의 효과값으로 만들고, 그 값에 따라
// 지속시간을 0으로 낮출지 정상값을 쓸지 결정한다 — 접근성 판단은 항상 REAL_REDUCE 를 포함한다.
function effectiveReduce() { return REAL_REDUCE || simulateReduce; }
function step() {
var reduceNow = effectiveReduce();
var dur = reduceNow ? 0 : NORMAL_MS;
reduceEl.textContent = reduceNow ? '動作軽減オン' : '動作軽減オフ';
durVEl.textContent = String(dur);
if (SUPPORTS_VT && !reduceNow) {
document.startViewTransition(render);
} else {
render();
}
}
btn.addEventListener('click', function () { human(); simulateReduce = !simulateReduce; step(); });
root.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') { human(); e.preventDefault(); simulateReduce = !simulateReduce; step(); }
});
var HOLD_MS = 480;
function loop() {
if (!isDemo) return;
simulateReduce = !simulateReduce;
step();
window.setTimeout(loop, HOLD_MS);
}
reduceEl.textContent = effectiveReduce() ? '動作軽減オン' : '動作軽減オフ';
durVEl.textContent = String(effectiveReduce() ? 0 : NORMAL_MS);
if (!REAL_REDUCE) {
window.setTimeout(loop, HOLD_MS);
} else {
root.classList.remove('is-demo');
}
})();// ReducedMotionGuard — vanilla 판(run/401/demo/09-view-transition-reduced-motion-guard/index.html) 의 React 포트.
// 계산 겹: 실제 동작 줄임 값과 시연용 값을 합쳐 하나의 효과값으로 만들고, 그 값에 따라
// 지속시간을 0으로 낮출지 정상값을 쓸지 결정한다 — 접근성 판단은 항상 REAL_REDUCE 를 포함한다.
import { useEffect, useRef } from "react";
import styles from "./ReducedMotionGuard.module.scss";
type Box<T> = { current: T };
const NORMAL_MS = 300;
const HOLD_MS = 480;
export default function ReducedMotionGuard() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const cardRef = useRef(null) as unknown as Box<HTMLDivElement>;
const badgeRef = useRef(null) as unknown as Box<HTMLElement>;
const reduceRef = useRef(null) as unknown as Box<HTMLElement>;
const durVRef = useRef(null) as unknown as Box<HTMLElement>;
const btnRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const timerRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const SUPPORTS_VT = "startViewTransition" in document;
const REAL_REDUCE = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let isDemo = true;
let on = true;
// 데모 전용: 실제 환경 값(REAL_REDUCE)은 헤드리스 렌더에서 대개 꺼짐으로 읽히므로,
// 값이 켜졌을 때의 전환 붕괴를 눈으로 보이도록 번갈아 흉내 낸다.
let simulateReduce = false;
function human() {
if (isDemo) { rootRef.current?.classList.remove(styles.isDemo); isDemo = false; }
}
function render() {
on = !on;
cardRef.current?.classList.toggle(styles.isOff, !on);
if (badgeRef.current) badgeRef.current.textContent = on ? "켬" : "끔";
}
// 계산 겹: 실제 값과 시연 값을 합쳐 하나의 효과값을 만든다 — 접근성 판단은 항상 REAL_REDUCE 를 포함한다.
function effectiveReduce() {
return REAL_REDUCE || simulateReduce;
}
function step() {
const reduceNow = effectiveReduce();
const dur = reduceNow ? 0 : NORMAL_MS;
if (reduceRef.current) reduceRef.current.textContent = reduceNow ? "동작 줄임 켜짐" : "동작 줄임 꺼짐";
if (durVRef.current) durVRef.current.textContent = String(dur);
if (SUPPORTS_VT && !reduceNow) {
document.startViewTransition(render);
} else {
render();
}
}
function onClick() { human(); simulateReduce = !simulateReduce; step(); }
function onKeydown(e: KeyboardEvent) {
if (e.key === "Enter" || e.key === " ") { human(); e.preventDefault(); simulateReduce = !simulateReduce; step(); }
}
btnRef.current?.addEventListener("click", onClick);
rootRef.current?.addEventListener("keydown", onKeydown);
function loop() {
if (!isDemo) return;
simulateReduce = !simulateReduce;
step();
timerRef.current = window.setTimeout(loop, HOLD_MS);
}
if (reduceRef.current) reduceRef.current.textContent = effectiveReduce() ? "동작 줄임 켜짐" : "동작 줄임 꺼짐";
if (durVRef.current) durVRef.current.textContent = String(effectiveReduce() ? 0 : NORMAL_MS);
if (!REAL_REDUCE) {
timerRef.current = window.setTimeout(loop, HOLD_MS);
} else {
rootRef.current?.classList.remove(styles.isDemo);
}
return () => {
window.clearTimeout(timerRef.current);
btnRef.current?.removeEventListener("click", onClick);
rootRef.current?.removeEventListener("keydown", onKeydown);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.rg + " " + styles.isDemo} ref={rootRef}>
<div className={styles.card} ref={cardRef}>
<span className={styles.badge} ref={badgeRef}>켬</span>
</div>
<p className={styles.meta}>
<span className={styles.pill} ref={reduceRef}>동작 줄임 꺼짐</span>
<span className={styles.pill}>지속시간 <b ref={durVRef}>320</b>ms</span>
</p>
<button type="button" className={styles.btn} ref={btnRef} aria-label="동작 줄임 값 뒤집어 보기">값 뒤집기</button>
</div>
</main>
);
}09 モーション低減値を検知して遷移を切る安全装置 — 計測 累積面積 8.36% · 動いたフレーム 6/23GODRICH オリジナル · SCSS と React は zip に同梱 · view transitions api 9パターン — 値で画面接続
このデモが属する記事
view transitions api 9パターン — 値で画面接続view transitions api の9パターンをまとめました。画面はそのままつながって見えるが、実際は直前の画面をスナップショット値として残し次の画面と重ねて過ぎ去る — 名前値で要素を対応させ、対応可否の値で退避し、モーション低減 この記事のデモをすべて見る


