アクセシビリティ
動きは情報を伝える一方で、前庭障害のある人にとってはめまいや吐き気の原因になります。twelve-principles はすべての再生経路で prefers-reduced-motion を既定で尊重し、空間的な動きを短いクロスフェードに置き換えます。このページでは、その変換規則と、キーボード・タッチ操作での振る舞いの扱いをまとめます。
reducedMotion の 4 つのモード
Section titled “reducedMotion の 4 つのモード”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 の設定を切り替えると、ページを再読み込みしなくても次のモーションから反映されます。すでに再生中のモーションは変わりません。
prefersReducedMotion
Section titled “prefersReducedMotion”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 を確認できます。
- 元の spec が静止する時点(
directionとiterationsを考慮した終端)のポーズをendとし、反対側の端をstartとします。 - 新しい spec のフレームは
[end の形状 + start の opacity, end]の 2 枚です。形状(位置・回転・拡大・blur・elevation)は最初から終端の値になり、変化するのは opacity だけです。 startにもendにも opacity がなければ opacity キー自体を持たず、2 枚のフレームは同一になります。つまり 見た目の変化は一切なく、終端の状態へ移るだけ です。- 尺は
"fade"ならMath.min(spec.duration, 150)、"skip"なら 0 です。遅延は"fade"なら元の値を保ち、"skip"なら 0 です。 originとperspectiveは引き継ぎます。easing、iterations、directionは引き継がないので、無限ループも 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" を使ってよい場面
Section titled “"full" を使ってよい場面”"full" はユーザーの設定を上書きします。動きそのものが情報である場合(進捗や、ドラッグ中の要素がポインタに追従する表示など)に限って、要素単位で指定します。装飾的な入場やホバー演出には使いません。
設定をアプリ内で切り替える
Section titled “設定をアプリ内で切り替える”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 });}import { useState, type ReactNode } from "react";import type { ReducedMotion } from "twelve-principles";import { MotionProvider } from "twelve-principles/react";
export function AppShell({ children }: { children: ReactNode }) { const [motion, setMotion] = useState<ReducedMotion>("auto"); return ( <MotionProvider reducedMotion={motion}> <label> <input type="checkbox" checked={motion === "fade"} onChange={(e) => setMotion(e.target.checked ? "fade" : "auto")} /> 動きを減らす </label> {children} </MotionProvider> );}MotionProvider の値が変わると、振る舞いフックは新しい reducedMotion で付け直され、useMotion の play / animateTo も新しい値を使うようになります。
キーボード: pressable
Section titled “キーボード: pressable”pressable(React の usePress、<Motion press>)はポインタとキーボードの両方に反応します。
| 入力 | 押し込む | 戻す |
|---|---|---|
| ポインタ | pointerdown(主ボタン button === 0 のみ) |
pointerup / pointercancel / pointerleave |
| キーボード | keydown の Enter / Space(キーリピートは無視) |
keyup の Enter / Space |
| フォーカス | blur |
キーを押したままフォーカスが外れても blur で戻るので、押し込まれたまま残ることはありません。
タッチ: hoverable と tiltable
Section titled “タッチ: hoverable と tiltable”hoverable と tiltable は pointerType === "touch" のイベントを無視します。タッチには「ホバー」がなく、指で触れた瞬間に浮き上がったり傾いたりすると、タップ後に状態が残ってしまうためです。マウスとペンでは動作します。
- キーボードフォーカスでは浮き上がりません。
hoverableはポインタイベントだけを見ます。フォーカス中であることは:focus-visibleのスタイルで示してください。 - フォーカスリングには
outlineを使います。elevationを含むポーズを再生した要素では、ライブラリがbox-shadowをインラインスタイルとして所有します。スタイルシートのbox-shadowによるフォーカスリングはホバー中に上書きされます。
.card:focus-visible { outline: 2px solid var(--focus-color); outline-offset: 2px;}その他の注意
Section titled “その他の注意”- ループする演出は止められるようにする。
secondaryActionをiterations: 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 の設定を変えずに該当部分だけ確認できます。