Skip to content

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

Open
YevheniiKotyrlo wants to merge 3 commits into
nativewind:mainfrom
YevheniiKotyrlo:feat/prefers-reduced-motion
Open

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

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor

Problem

react-native-css evaluates prefers-color-scheme on native (via Appearance) but has no wiring for prefers-reduced-motion. So Tailwind's motion-reduce: / motion-safe: variants — and any authored @media (prefers-reduced-motion) rule — compile correctly and then resolve to false on every device, silently. Web works because the browser evaluates the real CSS.

This adds the missing feature, mirroring the existing prefers-color-scheme wiring exactly:

  • src/native/reactivity.ts — a reduceMotion observable seeded from AccessibilityInfo.isReduceMotionEnabled() and kept live via the reduceMotionChanged event — the mirror of colorScheme ← Appearance.
  • src/native/conditions/media-query.ts — a prefers-reduced-motion case in testComparison, handling both reduce (motion-reduce:) and no-preference (motion-safe:), plus the bare @media (prefers-reduced-motion) boolean form (per CSS, bare (prefers-reduced-motion) ≡ reduce).

get(reduceMotion) subscribes the effect, so a runtime OS toggle re-renders exactly as a colorScheme change re-renders dark:.

Why it matters

motion-reduce: / motion-safe: are the documented Tailwind way to honour WCAG 2.3.3 (reduced motion). Verified against Tailwind v4.3.2 — motion-reduce:* emits @media (prefers-reduced-motion: reduce) and motion-safe:* emits @media (prefers-reduced-motion: no-preference). Today both are dead on native: every reduced-motion affordance a consumer writes is a no-op off the web.

Normatively this is MQ5 §12.1 — prefers-reduced-motion is a user-preference media feature the page is expected to answer, not a value a build can fold away.

Solution

Three commits. The media feature is evaluated on native rather than compiled away, seeded once at cold start from AccessibilityInfo.isReduceMotionEnabled() and kept current from its change event.

The seed is guarded and the value compared exactly: AccessibilityInfo resolves asynchronously and can answer before the observable exists, so an unguarded seed writes into nothing, and a loose comparison treats undefined as false — which reports "motion is fine" for a device that never answered.

Tests

  • New describe("prefers-reduced-motion") block in src/__tests__/native/media-query.test.tsx (5 tests), mirroring the existing "color scheme" test:
    • reduce (motion-reduce:) — base style by default, reduced style after reduceMotion.set(true), and back (reactive both ways).
    • no-preference (motion-safe:) — the inverse.
    • composition with prefers-color-scheme via and.
    • negation — not (prefers-reduced-motion: reduce).
    • bare boolean — @media (prefers-reduced-motion).
  • New prefers-reduced-motion test in src/__tests__/compiler/compiler.test.tsx (mirrors light-dark()) asserting the emitted media condition: ["=", "prefers-reduced-motion", "reduce" | "no-preference"] and the bare ["!!", "prefers-reduced-motion"].
  • Existing media-query / color-scheme tests unaffected.
  • yarn test, yarn typecheck, eslint, prettier --check all pass. (The three babel suites fail identically on clean main in this environment — missing --experimental-vm-modules — and are unrelated to this change.)

Mutation-proved: reverting this branch's src/ diff and re-running these files alone turns 10 of 34 red. The rest stay green, so the set is not simply coupled to the change.

Verification

tsc --noEmit 0 errors · eslint 0 problems · jest 1059 passed, 3 failed, 21 skipped.

The 3 are the Windows-only babel-plugin-tester cases over an unrewritten relative require("../View"); they fail identically at every ref on this machine and this branch touches no babel file. #390 is the branch that fixes them.

Known limits

  • AccessibilityInfo.isReduceMotionEnabled() is async-only (no synchronous getter, unlike Appearance.getColorScheme()), so the observable seeds false and flips when it resolves — a one-frame cold-start window, with false (motion on) as the safe default.
  • On Android the OS surface for this flag is the animation duration scale (see [Android] "Remove animations" setting isn't reflected when using AccessibilityInfo react/react-native#31221); iOS drives it directly. This mirrors React Native's own AccessibilityInfo behaviour and is out of scope here.

Base

Branched off f70c402. main has since taken #451 (a5002c5). 3 of the 5 files this changes also moved there (src/__tests__/compiler/compiler.test.tsx, src/native/conditions/media-query.ts, src/native/reactivity.ts), and it still merges cleanly onto current main. Every measurement above was taken on f70c402. Say the word and I will re-apply it onto current main.

Only prefers-color-scheme was wired into the native media-query
evaluator (via Appearance). prefers-reduced-motion had no observable
and no evaluator case, so `motion-reduce:` / `motion-safe:` (and any
@media (prefers-reduced-motion) rule) compiled cleanly and then
resolved to false on every device -- a silent no-op off the web.

Mirror the colorScheme wiring: a reduceMotion observable seeded from
AccessibilityInfo.isReduceMotionEnabled() and kept live via the
reduceMotionChanged event, plus a prefers-reduced-motion case in
testComparison handling reduce and no-preference. Also handle the
bare `@media (prefers-reduced-motion)` boolean form (per CSS, bare is
equivalent to reduce).

Adds runtime tests (reduce, no-preference, composition, negation,
bare) mirroring the existing color-scheme test, plus a compiler test
asserting the emitted media condition.
Note that AccessibilityInfo has no synchronous getter (unlike
Appearance.getColorScheme()), so the observable seeds `false` and
flips when the async read resolves -- a brief, inherent cold-start
window. Comment-only; no behaviour change.
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review July 23, 2026 12:11
…xactly

Two defects in the original, both measured.

The seed reached out to AccessibilityInfo unguarded at module scope. This module
is the root of every media feature, so on a host without a native
AccessibilityInfo the missing getter took colorScheme, vw/vh and containers down
with it at import — and a rejecting getter killed the process outright, since
nothing attached a catch. Both now guarded.

The comparison arm was a two-way branch on `no-preference`, so it answered
`get(reduceMotion)` for every other value — `(prefers-reduced-motion: anything)`
matched whenever the flag was on. The compiler emits `["=", name, value]` with no
allowlist, so that condition is reachable. An equality test makes an unrecognised
value false, as MQ5 requires.

Three tests cover what nothing did: that the observable is wired to
AccessibilityInfo at all — deleting either the seed or the listener was green
before — that the cold-start default survives its own beforeEach, and that an
absent or rejecting getter leaves the module importable.

The Android comment named the wrong setting. AccessibilityInfoModule reads
Settings.Global.TRANSITION_ANIMATION_SCALE, not the animator duration scale; the
cited react-native#31221 is precisely about the two disagreeing.
@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 — stock 3.0.7 after — with this PR

Both frames come from the same device in the same run (Android 36 emulator, 1140×2400 @ 480dpi). before is stock 3.0.7 rather than "this build minus this PR", so one unrelated difference is visible and worth naming rather than leaving you to spot it: the compiler's inlineRem defaults to 14 and our build sets it to 16, so every rem-derived length in the before frame renders at 87.5% of the after one — smaller type, tighter spacing, a shorter probe box. That is a different fix, not this one. Read the pair as the subject moving against the control the scene renders beside it, which this PR does not change.

Each frame carries a build-probe width=<dp> line — a rem-derived box that resolves differently on a patched build. The capture harness reads it off the device and refuses to save a frame whose probe disagrees with the variant it claims, so a before image cannot silently be a second after.

@YevheniiKotyrlo

YevheniiKotyrlo commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor Author

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

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