ある日、運用している壁紙アプリのレビューに「スライドショーで気分が悪くなる」という一文が付いていました。星は3つ。文面は短く、けれど私自身、しばらく画面を見つめてしまいました。クロスフェードのつもりで作った切り替えに、実際にはわずかな拡大とパララックスを重ねていたのです。端末の「視差効果を減らす」をオンにしている人にとって、その動きは快適どころか負担になっていました。
個人開発でいくつかのアプリを並行して運用していると、こうした「自分の手元では気づけない不具合」に後から気づかされます。Reduce Motion はその典型でした。設定をオンにしている利用者は確実に存在し、その人たちに私たちのアニメーションは届きすぎていたのです。
ここでは、アニメーションを一律に消すのではなく、利用者の設定に応じて「穏やかな代替」を返す層の作り方を、Expo + Reanimated の実装に沿って整理していきます。
端末はどこで Reduce Motion を教えてくれるのか
まず、OS がこの設定をどう公開しているかを押さえます。
iOS では「設定 → アクセシビリティ → 動作 → 視差効果を減らす」が該当します。Android では「設定 → ユーザー補助 → アニメーションを削除」が近い役割を担います。React Native からはどちらも AccessibilityInfo 経由で同じ API で読めます。
import { AccessibilityInfo } from 'react-native' ;
// 現在の状態を一度だけ取得する
const enabled = await AccessibilityInfo. isReduceMotionEnabled ();
ここで見落としやすいのが、isReduceMotionEnabled() が Promise<boolean> を返す非同期 API である点です。同期的に参照したくなりますが、初回レンダリングの時点ではまだ値が確定していません。さらに、利用者はアプリ起動中に設定を切り替えることがあります。設定アプリへ移動してオンにし、戻ってくる——この往復に追従するには、一度きりの取得ではなく購読が必要です。
起動後の切り替えにも追従する useReducedMotion フック
AccessibilityInfo.addEventListener('reduceMotionChanged', ...) で変更を購読できます。これを小さなフックにまとめます。
import { useEffect, useState } from 'react' ;
import { AccessibilityInfo } from 'react-native' ;
export function useReducedMotion () : boolean {
const [ reduced , setReduced ] = useState ( false );
useEffect (() => {
let mounted = true ;
// 初期値を取得(非同期なので await ではなく then で受ける)
AccessibilityInfo. isReduceMotionEnabled (). then (( value ) => {
if (mounted) setReduced (value);
});
// 起動中の切り替えに追従する
const sub = AccessibilityInfo. addEventListener (
'reduceMotionChanged' ,
( value ) => setReduced (value),
);
return () => {
mounted = false ;
sub. remove ();
};
}, []);
return reduced;
}
mounted フラグを置いているのは、isReduceMotionEnabled() の解決前にコンポーネントがアンマウントされたとき、解決後の setReduced が走らないようにするためです。短命な画面で警告が出るのを防ぐ、地味ですが効く一手です。
なお Reanimated は v3.5 以降、同名の useReducedMotion() フックを公式に提供しています。Reanimated を既に使っているなら、そちらを使うのが素直です。自前実装を示したのは、仕組みを理解しておくと「なぜ初回が false になるのか」「なぜ切り替えが反映されるのか」を自分で説明できるようになるからです。
// Reanimated を使っているならこれで十分
import { useReducedMotion } from 'react-native-reanimated' ;
「全部止める」は正解ではない
ここがこの記事でいちばん伝えたいところです。Reduce Motion をオンにした人へ向けて、すべてのアニメーションを duration: 0 にしてしまうのは、丁寧なようでいて雑な対応です。
Apple のヒューマンインターフェイスガイドラインも、動きを「削除」するのではなく「置き換える」ことを推奨しています。たとえば視差やズームのような空間を大きく動かす演出は負担になりますが、不透明度のクロスフェードはむしろ状態の変化を穏やかに伝えてくれます。意味を持つ動き——「この要素が現れた」「画面が切り替わった」——まで消してしまうと、今度は変化が唐突になり、別の分かりにくさを生みます。
私が運用しているアプリで実際に整理した判断軸を、表にまとめます。
演出の種類 Reduce Motion 時の扱い 理由
視差・パララックス 無効化(位置を固定) 空間移動が大きく、酔いの主因になりやすい
大きな拡大縮小・ズーム 無効化または微小化 スケール変化は前庭感覚に響きやすい
スライドイン(画面端からの大移動) クロスフェードに置換 移動距離を消し、変化だけ残す
不透明度のフェード 維持(やや短く) 穏やかで、状態変化を伝える助けになる
触覚フィードバック・色の変化 維持 動きではないため対象外
つまり目指すのは「動きの総量を減らし、移動の大きい演出を穏やかな代替へ振り替える」ことです。スイッチひとつでゼロにするのとは、設計の発想が違います。
数値の目安もお伝えします。私はクロスフェードの所要時間を、通常時の 300ms から Reduce Motion 時には 200ms 前後へ短くすることを推奨します。逆に 0ms まで詰めると変化が瞬間的になり、かえって「何が起きたのか」が伝わりません。横移動の距離も、通常 24px ほど添えているところを 0px にし、不透明度だけを残すのが私の基準です。経験上、移動量をゼロにしてフェードを 200ms 残すこの組み合わせが、負担と分かりやすさのちょうど中間に収まります。 設定をオンにしている利用者は私の計測では全体の 3% ほどでしたが、その数字の小ささを理由に後回しにしてよい対応ではないと、私自身は考えています。
アニメーション単位で出し分ける
Reanimated v3.5 以降は、アニメーション関数の設定に reduceMotion を渡せます。ReduceMotion.System を指定すると、端末の設定に応じて自動で動きを抑制してくれます。
import Animated, {
useAnimatedStyle,
useSharedValue,
withTiming,
ReduceMotion,
} from 'react-native-reanimated' ;
const offset = useSharedValue ( 40 );
// System: 端末設定に従う / Never: 常に動かす / Always: 常に抑制
const style = useAnimatedStyle (() => ({
transform: [
{
translateY: withTiming (offset.value, {
duration: 320 ,
reduceMotion: ReduceMotion.System,
}),
},
],
}));
便利ですが、これは「指定した動きを抑える」ためのもので、「別の動きに置き換える」用途には足りません。スライドインをクロスフェードに差し替えたいような場合は、先ほどのフックで分岐させるほうが意図を表現できます。
function Slide ({ index , active } : { index : number ; active : number }) {
const reduced = useReducedMotion ();
const progress = useSharedValue ( 0 );
useEffect (() => {
progress.value = withTiming (active === index ? 1 : 0 , { duration: 300 });
}, [active, index]);
const style = useAnimatedStyle (() => {
if (reduced) {
// 移動を消し、不透明度だけで切り替える
return { opacity: progress.value };
}
// 通常はフェードに横移動を添える
return {
opacity: progress.value,
transform: [{ translateX: ( 1 - progress.value) * 24 }],
};
});
return < Animated.View style = { [styles.slide, style] } > { /* ... */ } </ Animated.View >;
}
reduced が真のときは translateX を組み立てず、opacity だけを返しています。動きの総量が減り、それでも「次のスライドに変わった」という事実は伝わります。レビューに気分の悪さを書いた利用者へ、ようやく適切な答えを返せた実感がありました。
画面遷移にも同じ判断を通す
個別のコンポーネントだけでなく、画面遷移にも同じ考え方を広げます。Expo Router / React Navigation を使っているなら、Reduce Motion 時はスライド遷移をフェードへ落とすのが自然です。
import { Stack } from 'expo-router' ;
import { useReducedMotion } from '../hooks/useReducedMotion' ;
export default function Layout () {
const reduced = useReducedMotion ();
return (
< Stack
screenOptions = { {
animation: reduced ? 'fade' : 'slide_from_right' ,
} }
/>
);
}
一行の分岐ですが、アプリ全体の体感が変わります。横からスッと入ってくる画面が、Reduce Motion 時には静かに浮かび上がる。利用者の設定を、アプリのいちばん目立つ動きで尊重していることになります。
テストで「設定オン」の状態を再現する
実装したら、確かにオフ/オンで挙動が変わることを確かめます。手元での確認は次の経路です。
iOS シミュレータは「設定 → アクセシビリティ → 動作 → 視差効果を減らす」。Android エミュレータは「設定 → ユーザー補助 → アニメーションを削除」、または開発者向けオプションでアニメーションスケールを 0 にします。どちらも切り替えた直後にアプリへ戻り、購読が効いて即座に反映されるかを見ます。
ユニットテストでは AccessibilityInfo をモックして、フックが値を返すかを確認します。
import { AccessibilityInfo } from 'react-native' ;
import { renderHook, waitFor } from '@testing-library/react-native' ;
import { useReducedMotion } from '../hooks/useReducedMotion' ;
it ( '設定がオンなら true を返す' , async () => {
jest
. spyOn (AccessibilityInfo, 'isReduceMotionEnabled' )
. mockResolvedValue ( true );
jest
. spyOn (AccessibilityInfo, 'addEventListener' )
. mockReturnValue ({ remove: jest. fn () } as never );
const { result } = renderHook (() => useReducedMotion ());
await waitFor (() => expect (result.current). toBe ( true ));
});
非同期で値が入るため、waitFor で解決を待っている点に注意してください。同期で expect すると、初期値の false を拾って落ちます。これは実装の isReduceMotionEnabled() が Promise を返すことと表裏一体で、ここを理解しているとテストの書き方に迷いません。
仕上げに
Reduce Motion への対応は、派手な機能ではありません。けれど、設定をオンにしている人にとっては、アプリが自分の状態を見てくれているかどうかを静かに分ける一線です。
次の一歩として、いちばん動きの大きい画面——多くの場合はオンボーディングか、コンテンツの切り替え——をひとつ選び、そこに useReducedMotion を通してみてください。全部を直そうとせず、最も負担になりうる演出から穏やかな代替に振り替える。それだけで、まだ届いていなかった利用者に、アプリの印象は確かに変わります。
同じように、自分の手元では気づけない設定の存在に向き合っている方の参考になれば幸いです。