●PRODUCT — Rork Max generates native Swift apps for iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessage●NATIVE — Rork Max unlocks AR/LiDAR, Metal 3D games, Dynamic Island, Live Activities, HealthKit, and Core ML●CLASSIC — The original Rork uses React Native (Expo), turning plain-English prompts into shippable iOS/Android apps●FUNDING — Rork raised $2.8M from a16z (plus $15M more), reaching 743,000 monthly visits at 85% growth●PRICING — Rork is free to start, with paid plans from $25/month; Rork Max is $200/month●CHOICE — Pick cross-platform Rork or Rork Max for deep Apple-native capabilities, depending on your goal●PRODUCT — Rork Max generates native Swift apps for iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and iMessage●NATIVE — Rork Max unlocks AR/LiDAR, Metal 3D games, Dynamic Island, Live Activities, HealthKit, and Core ML●CLASSIC — The original Rork uses React Native (Expo), turning plain-English prompts into shippable iOS/Android apps●FUNDING — Rork raised $2.8M from a16z (plus $15M more), reaching 743,000 monthly visits at 85% growth●PRICING — Rork is free to start, with paid plans from $25/month; Rork Max is $200/month●CHOICE — Pick cross-platform Rork or Rork Max for deep Apple-native capabilities, depending on your goal
What Your App Should Do When Someone Turns On Reduce Motion — A Motion-Respecting Layer in Expo
When a user enables iOS Reduce Motion or Android Remove Animations, how should your app respond? Combine AccessibilityInfo with Reanimated's ReduceMotion to replace heavy motion with a calmer alternative instead of simply switching it off.
One day a review on a wallpaper app I maintain included a single line: "the slideshow makes me feel sick." Three stars. The note was short, but as an indie developer I sat with it for a while. The transition I had built as a cross-fade actually layered in a slight zoom and some parallax. For someone with Reduce Motion enabled, that movement was not pleasant — it was a burden.
When you run several apps in parallel on your own, you keep discovering this kind of "bug you can't see from your own device." Reduce Motion was a textbook case. Users with the setting on certainly exist, and our animations were reaching them too forcefully.
This article walks through how to build a layer that returns a gentler alternative based on the user's setting — not one that strips animation away wholesale — following an Expo + Reanimated implementation.
Where the device tells you about Reduce Motion
First, let's pin down how the OS exposes this preference.
On iOS it lives under Settings → Accessibility → Motion → Reduce Motion. On Android the closest equivalent is Settings → Accessibility → Remove animations. From React Native, both are read through the same AccessibilityInfo API.
import { AccessibilityInfo } from 'react-native';// Read the current state onceconst enabled = await AccessibilityInfo.isReduceMotionEnabled();
The easy thing to miss is that isReduceMotionEnabled() is an asynchronous API returning Promise<boolean>. You'll be tempted to read it synchronously, but the value isn't settled on the first render. Worse, users sometimes toggle the setting while your app is open — they jump to Settings, turn it on, and come back. To follow that round trip you need a subscription, not a one-time read.
A useReducedMotion hook that follows mid-session changes
You can subscribe to changes with AccessibilityInfo.addEventListener('reduceMotionChanged', ...). Let's wrap it in a small hook.
import { useEffect, useState } from 'react';import { AccessibilityInfo } from 'react-native';export function useReducedMotion(): boolean { const [reduced, setReduced] = useState(false); useEffect(() => { let mounted = true; // Read the initial value (async, so handle it with then, not await) AccessibilityInfo.isReduceMotionEnabled().then((value) => { if (mounted) setReduced(value); }); // Follow toggles made while the app is running const sub = AccessibilityInfo.addEventListener( 'reduceMotionChanged', (value) => setReduced(value), ); return () => { mounted = false; sub.remove(); }; }, []); return reduced;}
The mounted flag is there so that if the component unmounts before isReduceMotionEnabled() resolves, the later setReduced never fires. It's a small move, but it quietly prevents warnings on short-lived screens.
Note that since v3.5 Reanimated ships an official useReducedMotion() hook of the same name. If you already use Reanimated, reaching for that is the straightforward choice. I'm showing the hand-rolled version because understanding the mechanism lets you explain to yourself why the first value is false and why toggles are reflected.
// If you already use Reanimated, this is enoughimport { useReducedMotion } from 'react-native-reanimated';
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦A useReducedMotion hook that subscribes to AccessibilityInfo and follows changes made while the app is running
✦Using Reanimated's ReduceMotion.System to tune behavior per animation
✦A decision table for replacing only parallax and large motion, rather than killing everything
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
This is the part I most want to land. Setting every animation to duration: 0 for someone who enabled Reduce Motion looks considerate but is actually a blunt response.
Apple's Human Interface Guidelines recommend replacing motion rather than removing it. Large spatial effects like parallax and zoom are a burden, but an opacity cross-fade actually communicates a state change gently. If you erase even the motion that carries meaning — "this appeared," "the screen changed" — transitions become abrupt and create a different kind of confusion.
Here is the decision framework I actually settled on across my apps.
Effect type
Treatment under Reduce Motion
Reason
Parallax
Disable (pin position)
Large spatial shift is a leading cause of nausea
Large scale / zoom
Disable or shrink to minimal
Scale change strongly affects the vestibular sense
Slide-in (big move from screen edge)
Replace with cross-fade
Remove the distance, keep the change
Opacity fade
Keep (slightly shorter)
Calm, and helps convey state change
Haptics / color change
Keep
Not motion, so out of scope
The goal, then, is to reduce the total amount of motion and reroute the high-displacement effects to a gentler alternative. That's a different design instinct from zeroing everything with one switch.
Tuning per animation
Since v3.5, Reanimated lets you pass reduceMotion in an animation function's config. Specifying ReduceMotion.System automatically suppresses motion according to the device setting.
It's handy, but this is for suppressing a given motion, not for swapping it for a different one. When you want to replace a slide-in with a cross-fade, branching on the hook expresses the intent better.
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) { // Drop the movement, switch with opacity alone return { opacity: progress.value }; } // Normally pair the fade with a small horizontal move return { opacity: progress.value, transform: [{ translateX: (1 - progress.value) * 24 }], }; }); return <Animated.View style={[styles.slide, style]}>{/* ... */}</Animated.View>;}
When reduced is true we never build the translateX and return opacity only. The total motion drops, yet the fact that "the slide changed" still comes across. It felt like I could finally give that reviewer a proper answer.
Run the same judgment through screen transitions
Extend the same thinking beyond individual components to screen transitions. If you use Expo Router / React Navigation, dropping slide transitions to a fade under Reduce Motion is natural.
It's a one-line branch, but it changes how the whole app feels. A screen that slid in from the side now quietly fades up under Reduce Motion. You end up honoring the user's setting through the app's most visible motion.
Reproducing the "setting on" state in tests
Once it's built, confirm the behavior really changes between off and on. The manual paths are these.
On the iOS Simulator: Settings → Accessibility → Motion → Reduce Motion. On the Android emulator: Settings → Accessibility → Remove animations, or set the animation scale to 0 in Developer options. In both cases, toggle and return to the app to check that the subscription kicks in and updates immediately.
In unit tests, mock AccessibilityInfo to confirm the hook returns the value.
import { AccessibilityInfo } from 'react-native';import { renderHook, waitFor } from '@testing-library/react-native';import { useReducedMotion } from '../hooks/useReducedMotion';it('returns true when the setting is on', 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));});
Because the value arrives asynchronously, note that we wait for resolution with waitFor. A synchronous expect would catch the initial false and fail. That's the flip side of isReduceMotionEnabled() returning a Promise, and understanding it removes any guesswork from how you write the test.
To close
Supporting Reduce Motion is not a flashy feature. But for someone who has the setting on, it quietly draws the line between an app that notices their state and one that doesn't.
As a next step, pick the single screen with the most motion — usually onboarding or a content switcher — and run useReducedMotion through it. Don't try to fix everything; reroute the most burdensome effect to a gentler alternative first. That alone genuinely changes the impression your app leaves on users you weren't reaching before.
I hope this helps anyone facing the settings they can't see from their own device.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.