Repository navigation
feat(native): evaluate prefers-reduced-motion so motion-reduce / motion-safe work on native - #388
Draft
YevheniiKotyrlo wants to merge 12 commits into
Draft
YevheniiKotyrlo wants to merge 12 commits into
YevheniiKotyrlo wants to merge 12 commits into
Conversation
YevheniiKotyrlo
marked this pull request as ready for review
July 23, 2026 12:11
Contributor
Author
Contributor
Author
|
Outdated: the pairs are re-shot on 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
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.
YevheniiKotyrlo
force-pushed
the
feat/prefers-reduced-motion
branch
from
October 11, 2026 12:35
0757e09 to
5987763
Compare
This was referenced Oct 11, 2026
YevheniiKotyrlo
marked this pull request as draft
October 19, 2026 21:53
…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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Problem
The native runtime evaluates
prefers-color-schemebut notprefers-reduced-motion. Tailwind compilesmotion-reduce:*to@media (prefers-reduced-motion: reduce)andmotion-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 IOSnever match. Both are ASCII case-insensitive (css-values-4 §4.1, MQ4 §3), and lightningcss lowercases onlyall,printandscreen.Solution
reactivity.tskeeps the device's answer, read fromAccessibilityInfo.isReduceMotionEnabled()and kept live fromreduceMotionChangedascolorSchemeis fromAppearance, apart from a script's override, and theprefers-reduced-motionkeyword is the override or else the device's answer. UntilAccessibilityInfoanswers there is none, and a reduced-motion query does not match, so a first paint never starts amotion-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 isno-preference.prefers-reduced-motion: <value>compares the keyword, asprefers-color-schemedoes, so an unrecognised value never matches.(prefers-reduced-motion)on its own matches underreduceonly (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.asciiLowerCasehelper insrc/utilities.reducedMotion, besidecolorScheme:get()returns the preference,set("reduce" | "no-preference")overrides it until the device's setting next changes, ascolorScheme.set()does, andset(null)returns to the device's answer at once. On web the browser evaluates the query in the stylesheet, soget()readsmatchMediaandset()changes nothing.react-native-css/jesthelper clears a script's override before each test, besidecolorScheme's reset, so a suite starts from the device's answer,no-preferencewith React Native's mock, and sets another withreducedMotion.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.tsxAccessibilityInfoanswers, no reduced-motion query matches.no-preferencetoreduceand back.reducedMotion.set()overrides the device's preference, andset(null)returns to its answer in the same tick; one test setsreduceand the next starts fromno-preference, which holds only if the jest reset ran.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()readsmatchMedia,set()leaves the preference to the browser, and withoutmatchMediathere is none to read.On
main, 77 of the 110 cases in the four files fail; all pass here.Verification
Every
ci.ymljob passes on this head merged intomainwith #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-reduceandmotion-safe.The device pair, and the
reducedMotionsnippet onmainand here, are in this comment.Known limits
not (prefers-reduced-motion: <unknown keyword>)always, and beforeAccessibilityInfoanswers bothnot (prefers-reduced-motion: reduce), which Tailwind'snot-motion-reduce:compiles to, andnot (prefers-reduced-motion: no-preference).animate-spin motion-reduce:animate-none, runs its animation untilAccessibilityInfoanswers and stops it then. On Android that stop can leave the view at its last transform (docs/known-issues.md, Reanimated #10507).Merge order
no-preferencefalse in a boolean context. Whichever lands second reportsprefers-reduced-motionfrom that lookup.prefers-color-schemecase beside this one. Whichever lands second keeps both.src/utilities/ascii-case.ts, byte for byte.