Skip to content

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

Draft
YevheniiKotyrlo wants to merge 12 commits into
nativewind:mainfrom
YevheniiKotyrlo:feat/prefers-reduced-motion
Draft

YevheniiKotyrlo wants to merge 12 commits 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.

Keywords and media types are also compared as written, so (prefers-reduced-motion: REDUCE), (prefers-color-scheme: DARK) and @media IOS never match. Both are ASCII case-insensitive (css-values-4 §4.1, MQ4 §3), and lightningcss lowercases only all, print and screen.

Solution

  • reactivity.ts keeps the device's answer, read from AccessibilityInfo.isReduceMotionEnabled() and kept live from reduceMotionChanged as colorScheme is from Appearance, apart from a script's override, and the prefers-reduced-motion keyword is the override or else the device's answer. Until AccessibilityInfo answers there is none, and a reduced-motion query does not match, so a first paint never starts a motion-safe: animation for someone who turned motion off; a negated one does (Known limits). A change event that arrives before the answer wins over it, and where React Native rejects for want of an accessibility module, or a test double lacks the getter or the event, 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) on its own matches under reduce only (MQ5 §2.4.2, §12.1). fix: evaluate range pairs, and answer a condition the runtime cannot decide with unknown #426 answers the other features in a boolean context.
  • The compiler lowercases an ident value, in a media query and a container query alike, and a custom media type, with an asciiLowerCase helper in src/utilities.
  • The API gains reducedMotion, beside colorScheme: get() returns the preference, set("reduce" | "no-preference") overrides it until the device's setting next changes, as colorScheme.set() does, and set(null) returns to the device's answer at once. On web the browser evaluates the query in the stylesheet, so get() reads matchMedia and set() changes nothing.
  • The react-native-css/jest helper clears a script's override before each test, beside colorScheme's reset, so a suite starts from the device's answer, no-preference with React Native's mock, and sets another with reducedMotion.set(). In a consumer's suite the helper resolves to the package's source and the components it renders to the build, so it resets the preference, the scheme and the window size through the package's entries, which resolve as the components do.

Tests

  • src/__tests__/native/media-query.test.tsx
    • Before AccessibilityInfo answers, no reduced-motion query matches.
    • A mounted view follows the preference from no-preference to reduce and back.
    • reducedMotion.set() overrides the device's preference, and set(null) returns to its answer in the same tick; one test sets reduce and the next starts from no-preference, which holds only if the jest reset ran.
    • A table of 14 queries under each motion preference in each colour scheme, as Chromium 153, Firefox 155 and WebKit 26.6 evaluate them with the preference emulated.
  • src/__tests__/native/reduce-motion-init.test.ts: the preference before the answer, the answer and then a change event, a change event before the answer, a rejecting getter, a missing getter and a missing change event, and an override standing until the device's setting changes; each case waits for every pending callback rather than a fixed number of ticks.
  • src/__tests__/compiler/compiler.test.tsx: the conditions the reduced-motion queries, an uppercase keyword, an uppercase media type and boolean, integer and number values compile to, and an uppercase container query keyword.
  • src/__tests__/web/reduced-motion.test.ts: get() reads matchMedia, set() leaves the preference to the browser, and without matchMedia there is none to read.

On main, 77 of the 110 cases in the four files fail; all pass here.

Verification

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

No existing issue tracks this. I searched for reduced motion, prefers-reduced-motion, motion-reduce and motion-safe.

The device pair, and the reducedMotion snippet on main and here, are in this comment.

Known limits

  • Until fix: evaluate range pairs, and answer a condition the runtime cannot decide with unknown #426's three-valued logic, a negated reduced-motion query matches when its operand is unknown: not (prefers-reduced-motion: <unknown keyword>) always, and before AccessibilityInfo answers both not (prefers-reduced-motion: reduce), which Tailwind's not-motion-reduce: compiles to, and not (prefers-reduced-motion: no-preference).
  • The opt-out form, animate-spin motion-reduce:animate-none, runs its animation until AccessibilityInfo answers and stops it then. On Android that stop can leave the view at its last transform (docs/known-issues.md, Reanimated #10507).

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.

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
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") 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.

@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.

A keyword is ASCII case-insensitive (css-values-4 §2.5), and lightningcss
hands an unknown feature's ident through as authored, so
`(prefers-color-scheme: DARK)` and `(prefers-reduced-motion: REDUCE)`
matched nothing. The compiler lowercases an ident value. The native
suite checks every row of a browser table: twelve queries under each
motion preference and colour scheme, as Chromium, Firefox and WebKit
evaluate them.
A feature in a boolean context is true unless its value is zero, `none`
or one the feature defines as false (MQ4 §2.4.2), so `(prefers-color-scheme)`,
`(orientation)`, `(width)`, `(height)`, `(resolution)` and `(display-mode)`
match in browsers. The runtime answered every one of them false. The
browser table in the native suite gains a row for each.
…vewind#426

nativewind#426 answers every media feature in a boolean context from its current
value. This branch keeps the one arm its own feature needs.
…bilityInfo answers

The preference holds the prefers-reduced-motion keyword, or nothing before
the platform answers, so neither motion-safe nor motion-reduce applies to a
first paint that guessed. A change event that arrives before the getter's
answer is kept, and the jest helper resets the preference between tests.
Media types are ASCII case-insensitive (MQ4 §3), and lightningcss lowercases
only all, print and screen, so `@media IOS` never matched iOS.
…ets colorScheme

`reducedMotion.get()` returns the preference, `reducedMotion.set()` overrides
it until the device's setting next changes, as `colorScheme.set()` does, and
`set(null)` follows the device again. On web the browser evaluates the query
in the stylesheet, so `get()` reads `matchMedia` and `set()` changes nothing.
The jest helper's reset follows the device too, so a suite reaches the
preference its AccessibilityInfo mock reports, or the one it sets.

The device's preference is read through one path: a getter that throws, as a
test double without one does, ends where a rejection does, at no-preference,
and one guard keeps a change event that arrived first. A container query's
keyword compiles in lowercase, as a media query's does.
…ipt's override

`reducedMotion.set(null)` emptied the preference and read the device again,
so a running app was unknown, and matched every negated query, for the whole
native round trip. The device's answer and a script's override are now two
values: the override stands over the answer until the device's setting
changes, and clearing it returns to the answer at once. The getter and the
change event are subscribed in one async step, so a test double without
either ends at no-preference, as a rejection does.

The jest helper clears the override, and colorScheme's, through the
package's own entry, which is what a consumer's components read; it no
longer waits on the device before each test.
…the components read it

The jest helper resolves to the package's source in a consumer's suite
while the components it renders resolve to the build, so a reset through
a relative import missed the window size they read.

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