コンテンツにスキップ

アクセシビリティ

動きは情報を伝える一方で、前庭障害のある人にとってはめまいや吐き気の原因になります。twelve-principles はすべての再生経路で prefers-reduced-motion を既定で尊重し、空間的な動きを短いクロスフェードに置き換えます。このページでは、その変換規則と、キーボード・タッチ操作での振る舞いの扱いをまとめます。

type ReducedMotion = "auto" | "fade" | "skip" | "full";
挙動
"auto"(既定) prefers-reduced-motion: reduce が有効なら "fade"、そうでなければ "full" として扱う
"fade" 終端の形状へ即座に移り、opacity だけを最大 150ms でクロスフェードする
"skip" 終端状態へ即座に移る(尺 0、遅延 0)
"full" 設定にかかわらず常にフル再生する。意味を伝えるのに不可欠なモーションに限る

reducedMotion を受け取る API は次のとおりです。どれも省略時は "auto" です。

  • play(el, spec, { reducedMotion })playAll(items, { reducedMotion })
  • animateTo(el, pose, { reducedMotion })setLayer(el, name, pose, { reducedMotion })
  • pressable / hoverable / tiltable のオプション
  • stage(focus, { reducedMotion })
  • React の MotionProvider reducedMotion(配下のすべてのフックとコンポーネントに適用)と、useMotion().play / animateTo の第 2 引数

"auto"再生のたびに matchMedia("(prefers-reduced-motion: reduce)") を評価します。ユーザーが OS の設定を切り替えると、ページを再読み込みしなくても次のモーションから反映されます。すでに再生中のモーションは変わりません。

function prefersReducedMotion(): boolean;

現在の prefers-reduced-motion: reduce の状態を返します。matchMedia が存在しない環境(サーバー、Node.js のテスト)では false です。独自の分岐(チルトを完全に切る、自動再生のループを止めるなど)を書くときに使います。

reduceSpec: 何がどう置き換わるか

Section titled “reduceSpec: 何がどう置き換わるか”
function reduceSpec(spec: MotionSpec, mode: "fade" | "skip"): MotionSpec;

play"fade" / "skip" のとき、再生前に spec をこの関数で変換します。直接呼べば、置き換え後の spec を確認できます。

  1. 元の spec が静止する時点(directioniterations を考慮した終端)のポーズを end とし、反対側の端を start とします。
  2. 新しい spec のフレームは [end の形状 + start の opacity, end] の 2 枚です。形状(位置・回転・拡大・blur・elevation)は最初から終端の値になり、変化するのは opacity だけです。
  3. start にも end にも opacity がなければ opacity キー自体を持たず、2 枚のフレームは同一になります。つまり 見た目の変化は一切なく、終端の状態へ移るだけ です。
  4. 尺は "fade" なら Math.min(spec.duration, 150)"skip" なら 0 です。遅延は "fade" なら元の値を保ち、"skip" なら 0 です。
  5. originperspective は引き継ぎます。easingiterationsdirection は引き継がないので、無限ループも 1 回の変化に畳まれます。
元のモーション "fade" での結果
enter("rise")(y 12 → 0、opacity 0 → 1、200ms) y は最初から 0。opacity 0 → 1 を 150ms
exit("fade", { personality: "calm" })(188ms) opacity 1 → 0 を 150ms
exit("pop")(膨らんでから縮んで消える) scale 0.85 のまま opacity 1 → 0 を 150ms
jump() / shake()(静止状態で始まり静止状態で終わる) 動きなし
secondaryAction("float")iterations: Infinity 動きなし(ループも止まる)
hoverable のリフト(y と elevation) 浮いた状態と影に即座に切り替わる
stage の周囲(opacity 0.5、blur 2、scale 0.98) blur と縮小は即座に、opacity だけ 150ms でフェード
import { enter, reduceSpec } from "twelve-principles";
reduceSpec(enter("rise"), "fade");
// { frames: [{ y: 0, opacity: 0 }, { y: 0, opacity: 1 }], duration: 150, delay: undefined, origin: undefined, perspective: undefined }

"full" はユーザーの設定を上書きします。動きそのものが情報である場合(進捗や、ドラッグ中の要素がポインタに追従する表示など)に限って、要素単位で指定します。装飾的な入場やホバー演出には使いません。

OS の設定とは別に、アプリの設定画面で「動きを減らす」を提供することもできます。

import { enter, play, pressable, type ReducedMotion } from "twelve-principles";
// ユーザー設定("auto" なら OS の設定に従う)
let motionSetting: ReducedMotion = (localStorage.getItem("motion") as ReducedMotion | null) ?? "auto";
export function showPanel(panel: HTMLElement) {
play(panel, enter("rise"), { reducedMotion: motionSetting });
}
// 振る舞いはオプションを付け替えるため、設定変更時に付け直す
let cleanup = pressable(document.querySelector<HTMLButtonElement>("#save")!, { reducedMotion: motionSetting });
export function setMotion(value: ReducedMotion) {
motionSetting = value;
localStorage.setItem("motion", value);
cleanup();
cleanup = pressable(document.querySelector<HTMLButtonElement>("#save")!, { reducedMotion: value });
}

pressable(React の usePress<Motion press>)はポインタとキーボードの両方に反応します。

入力 押し込む 戻す
ポインタ pointerdown(主ボタン button === 0 のみ) pointerup / pointercancel / pointerleave
キーボード keydown の Enter / Space(キーリピートは無視) keyup の Enter / Space
フォーカス blur

キーを押したままフォーカスが外れても blur で戻るので、押し込まれたまま残ることはありません。

hoverabletiltablepointerType === "touch" のイベントを無視します。タッチには「ホバー」がなく、指で触れた瞬間に浮き上がったり傾いたりすると、タップ後に状態が残ってしまうためです。マウスとペンでは動作します。

  • キーボードフォーカスでは浮き上がりません。 hoverable はポインタイベントだけを見ます。フォーカス中であることは :focus-visible のスタイルで示してください。
  • フォーカスリングには outline を使います。 elevation を含むポーズを再生した要素では、ライブラリが box-shadow をインラインスタイルとして所有します。スタイルシートの box-shadow によるフォーカスリングはホバー中に上書きされます。
.card:focus-visible {
outline: 2px solid var(--focus-color);
outline-offset: 2px;
}
  • ループする演出は止められるようにする。 secondaryActioniterations: Infinity で再生すると、reduced motion の設定がないユーザーには無限に動き続けます。5 秒を超えて動き続けるものには、一時停止の手段を用意してください(WCAG 2.2.2)。止めるには stop(el) を呼びます。
  • 退場中も要素は DOM に残る。 Presence / usePresence は退場モーションが終わるまでアンマウントしません。exit の尺(既定 150ms × tempo)の間、スクリーンリーダーからも読める状態です。即座に取り除く必要がある内容には exit={false} を使います。
  • ステージングはフォーカス管理ではない。 stage は周囲を暗く・ぼかすだけで、フォーカスの閉じ込めや inert の設定はしません。モーダル的に使う場合は、それらを別途実装してください。
  • Chrome / Edge の DevTools で「Rendering」パネルを開き、「Emulate CSS media feature prefers-reduced-motion」を reduce にすると、"auto" の分岐をその場で確認できます。
  • React なら MotionProvider reducedMotion="fade" をツリーの一部に被せると、OS の設定を変えずに該当部分だけ確認できます。

関連: UI ガイドラインAPI リファレンス: DOM ランタイム