Skip to content

feat(native): evaluate prefers-reduced-motion so motion-reduce / motion-safe work on native - #388

Open
YevheniiKotyrlo wants to merge 1 commit into
nativewind:mainfrom
YevheniiKotyrlo:feat/prefers-reduced-motion
Open

YevheniiKotyrlo wants to merge 1 commit into
nativewind:mainfrom
YevheniiKotyrlo:feat/prefers-reduced-motion

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor

Problem

The native runtime evaluates prefers-color-scheme but not prefers-reduced-motion. Tailwind compiles motion-reduce:* to @media (prefers-reduced-motion: reduce) and motion-safe:* to @media (prefers-reduced-motion: no-preference), so both variants compile and then never apply on a device, whatever its accessibility setting.

Solution

  • reactivity.ts gains devicePreference(), which keeps a user preference as the device reports it apart from the app's override, an override holding until it is cleared, the shape MQ5 §13 gives the web's requestOverride() and clearOverride(). The reduced-motion preference is built on it: the device's answer comes from AccessibilityInfo.isReduceMotionEnabled() and stays live from reduceMotionChanged, which the package follows from import as it follows Appearance. React Native reads the setting only through a callback, on iOS and Android, so until it answers there is no preference and a reduced-motion query does not match: a first paint never starts a motion-safe: animation for someone who turned motion off. A change event that arrives before the answer wins over it. Where React Native rejects for want of an accessibility module, or a test double lacks AccessibilityInfo or its getter, the preference is no-preference.
  • prefers-reduced-motion: <value> compares the keyword, as prefers-color-scheme does, so an unrecognised value never matches; (prefers-reduced-motion) alone matches under reduce only (MQ5 §2.4.2, §12.1).
  • The API gains reducedMotion beside colorScheme, as Expose the reduced-motion preference beside colorScheme #507 proposes: get() returns the preference, undefined until the device answers; set("reduce" | "no-preference") overrides it through every change of the device's setting; set(null) returns to the device's answer at once. On web the browser evaluates the query itself, so get() reads matchMedia on each call and set() changes only what get() returns, through browserPreference(); a server, which renders every visitor's page in one process, keeps no override.
  • The react-native-css/jest helper's reset, resetBetweenTests(), clears an override before each test and puts the device back to AccessibilityInfo's answer, no-preference with React Native's mock, even when that answer arrives after the test began. It resets through the package's entries, which a consumer's components resolve to.

The opt-out form, animate-spin motion-reduce:animate-none, animates until AccessibilityInfo answers and stops then. On Android with Reanimated before 4.7.0 that stop can leave the view at its last transform (docs/known-issues.md, Reanimated #10507, fixed by #10528).

Tests

  • src/__tests__/native/media-query.test.tsx: before the answer no positive reduced-motion query matches and a mounted view takes the answer when it arrives; a mounted view follows the preference both ways; set() holds through the device's changes and set(null) returns at once; and a table of 31 queries under each motion preference in each colour scheme, as Chromium 156, Firefox 157 and WebKit 27.2 evaluate them: the two variants, the boolean and negated forms, unrecognised and CSS-wide keywords, a feature name in capitals, whitespace and a comment, media types, or, nested negations and an unknown feature beside the preference (MQ5 §2–3).
  • src/__tests__/compiler/compiler.test.tsx: the condition each reduced-motion query of that table compiles to, but for the unrecognised keywords, which fix: evaluate range pairs, and answer a condition the runtime cannot decide with unknown #426 compiles to no rule.
  • src/__tests__/native/reduce-motion-init.test.ts: the answer, a change event before and after it, a rejecting or missing getter, a missing AccessibilityInfo or change event, and an answer after the jest reset; each case waits for every pending callback.
  • src/__tests__/native/jest-reset.test.ts: the reset clears an override and restores a device a test changed.
  • src/__tests__/web/reduced-motion.test.ts: get() reads matchMedia again after a double changes its answer, an override holds until set(null), a server keeps none, and no matchMedia function or list means no preference.
  • src/__tests__/native/_reduced-motion-types.ts, checked by yarn typecheck as _styled-types.tsx is: set() takes the two keywords and null from the root, native and web entries and refuses any other value; get() may return undefined.

On main, 150 of the 204 cases fail and the type test does not typecheck; all pass here, the native files also with every sheet compiled inlineVariables: false and with every sheet through JSON.stringify and JSON.parse, as Metro hands it to a device.

Verification

Every ci.yml job passes on this head merged into main: lint, typecheck, the tests (1518 passed), both builds, the web export, nothing unstaged, commitlint, and the iOS dev build on macOS 27.0.1 with Xcode 27.1, where CI pins 26.6.

(prefers-reduced-motion: reduce) now reads the preference as (prefers-color-scheme: dark) reads the scheme: 13.3 ns per evaluation against main's 7.8 ns, where it read nothing.

No earlier issue tracks the missing query; I searched for reduced motion, prefers-reduced-motion, motion-reduce and motion-safe.

The device pair, a recording of both variants as the setting turns on, the reducedMotion snippet and the evaluator's cost are in this comment.

Merge order

@YevheniiKotyrlo

YevheniiKotyrlo commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor Author

Device evidence — before / after

UNFIXED — Both rows paint the same — the media feature evaluates false on every device, so neither variant fires.

FIXED — Exactly one row is green. Which one tracks the OS reduce-motion setting shown above it.

before — 3.1.0-rc.0 after — with this PR

Android 16 emulator (API 36, GLES on the host GPU), 1140×2400 @ 480dpi, both frames from one run. before is the published 3.1.0-rc.0 plus what my test app needs from my other patches to run: the native and native-internal subpath exports, PR 459, cascade-layer ranking, and a shim that compiles a class-qualified :root inert. after adds the rest of my open PRs, this one included. One of them shows on every screen: PR 417 narrows a var()-valued font-family to one family, so the after sets its text in the theme's face where the before falls back to the platform's.

Recording — reduce motion turning on

main on the left, this PR on the right: the library's example app as release builds of main (2421ea77) and of main with this PR merged in, on an Android 16 emulator (API 36), 1140×2400 @ 480dpi. Before each side records, the JavaScript bundle of the APK the device holds is compared byte for byte with the build's. The line on top is React Native's own AccessibilityInfo, the same code on both sides. About 2.5 s in, the transition animation scale React Native reads for reduce motion goes to 0, as Remove animations sets it.

  • main: the motion-safe:animate-spin view never moves, and the animate-spin motion-reduce:animate-none one keeps spinning after the setting turns on.
  • This PR: both spin while the setting is off, and both stop in the frame it turns on. They stop where the animation left them, which is Reanimated 4.5.1's behaviour on Android when a CSS animation is removed (docs/known-issues.md, Reanimated #10507).

screenrecord captured 41 to 43 frames a second while the screen moved; the GIF plays 5 s of each side at 25. The source videos are main and this PR.

reducedMotion — main against this PR

import { reducedMotion } from "react-native-css";

// .card { color: #000; } @media (prefers-reduced-motion: reduce) { .card { color: #020202; } }
reducedMotion.set("reduce"); // the card takes #020202, and keeps it when the device's setting changes
reducedMotion.set(null); // back to the device's answer: #000 under React Native's jest mock
result
main (eda2b286) TypeError: Cannot read properties of undefined (reading 'set'): nothing is exported under that name
this PR the card's style is { color: "#020202" } after set("reduce"), still after the device's setting changes, and { color: "#000" } after set(null)

Run as written, with the class registered and a View rendered, through the repo's jest on Windows with Node 26.

The evaluator's cost — main against this PR

testMediaQuery over one condition, read through a subscribing effect as a render reads it: the median of 21 samples of 1,000,000 evaluations, after 200,000 to warm up, two runs on each side.

condition main 2421ea77 this PR
(min-width: 640px) 13.78 ns, 13.99 ns 13.93 ns, 13.64 ns
(prefers-color-scheme: dark) 13.53 ns, 13.45 ns 13.33 ns, 13.51 ns
(prefers-reduced-motion: reduce) 7.82 ns, 7.88 ns 13.49 ns, 13.25 ns

The first two differ across the sides by less than two runs of one side differ, at most 0.29 ns. The reduced-motion condition now reads the preference as the colour-scheme one reads the scheme, where main read nothing and answered false. Windows 11 on an AMD Ryzen 9 9950X3D, Node 26.12.1, through the repo's jest.

@YevheniiKotyrlo

YevheniiKotyrlo commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor Author

Outdated: the pairs are re-shot on 3.1.0-rc.0, each in its own PR's evidence comment.

The original note

@danstepanov — I've added before/after device captures to 19 of my 26 open PRs here. Each pair is the same device in the same run, differing only in whether the patch is applied, so the difference should be quick to eyeball.

The other seven (#390, #410, #427, #428, #429, #430, #432) have nothing visual to show — they're build, typing and resolution fixes.

No rush at all, and happy to rebase or split any of them if that makes them easier to take.

…on-safe work on native

The native runtime evaluated prefers-color-scheme but not prefers-reduced-motion, so Tailwind's
motion-reduce: and motion-safe: variants compiled and then never applied on a device.

devicePreference() keeps a user preference as the device reports it apart from the app's override,
which holds until it is cleared. The reduced-motion preference is built on it: the device's answer
comes from AccessibilityInfo.isReduceMotionEnabled() and follows reduceMotionChanged from import, as
colorScheme follows Appearance. React Native reads the setting only through a callback, so the
preference is unknown until it answers, and a reduced-motion query does not match before then. The
evaluator compares the prefers-reduced-motion keyword, and (prefers-reduced-motion) alone matches
reduce.

reducedMotion joins colorScheme in the API: get() returns the preference, and set() overrides it
until set(null). On web get() reads matchMedia on each call and set() changes only what get()
returns. The jest helper's reset clears an override and puts the device back to
AccessibilityInfo's answer.
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the feat/prefers-reduced-motion branch from 8951326 to 48eb155 Compare October 27, 2026 00:43
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review October 27, 2026 05:00

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant