From f036503c212fbe0bf4a4c839c6490555e63783b4 Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 23 Jul 2026 14:53:58 +0300 Subject: [PATCH 1/2] feat(native): evaluate prefers-reduced-motion on native 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. --- src/__tests__/compiler/compiler.test.tsx | 63 +++++++++++ src/__tests__/native/media-query.test.tsx | 125 +++++++++++++++++++++- src/native/conditions/media-query.ts | 15 ++- src/native/reactivity.ts | 17 +++ 4 files changed, 217 insertions(+), 3 deletions(-) diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx index 7a9fbea8..59d3b1f6 100644 --- a/src/__tests__/compiler/compiler.test.tsx +++ b/src/__tests__/compiler/compiler.test.tsx @@ -324,6 +324,69 @@ test("light-dark()", () => { }); }); +test("prefers-reduced-motion", () => { + // `motion-reduce:` compiles to `reduce`, `motion-safe:` to `no-preference`, + // and a bare `@media (prefers-reduced-motion)` to the boolean (`!!`) form. + // The native runtime evaluates these against the reduceMotion observable + // (see native/media-query.test.tsx). + expect( + compile( + `@media (prefers-reduced-motion: reduce) { .my-class { opacity: 0 } }`, + ).stylesheet(), + ).toStrictEqual({ + s: [ + [ + "my-class", + [ + { + s: [2, 1], + m: [["=", "prefers-reduced-motion", "reduce"]], + d: [{ opacity: 0 }], + }, + ], + ], + ], + }); + + expect( + compile( + `@media (prefers-reduced-motion: no-preference) { .my-class { opacity: 0 } }`, + ).stylesheet(), + ).toStrictEqual({ + s: [ + [ + "my-class", + [ + { + s: [2, 1], + m: [["=", "prefers-reduced-motion", "no-preference"]], + d: [{ opacity: 0 }], + }, + ], + ], + ], + }); + + expect( + compile( + `@media (prefers-reduced-motion) { .my-class { opacity: 0 } }`, + ).stylesheet(), + ).toStrictEqual({ + s: [ + [ + "my-class", + [ + { + s: [2, 1], + m: [["!!", "prefers-reduced-motion"]], + d: [{ opacity: 0 }], + }, + ], + ], + ], + }); +}); + test("media query nested in rules", () => { const compiled = compile(` .my-class { diff --git a/src/__tests__/native/media-query.test.tsx b/src/__tests__/native/media-query.test.tsx index 020b4aad..c79177e1 100644 --- a/src/__tests__/native/media-query.test.tsx +++ b/src/__tests__/native/media-query.test.tsx @@ -5,7 +5,7 @@ import { View } from "react-native-css/components/View"; import { registerCSS, testID } from "react-native-css/jest"; import { colorScheme } from "react-native-css/runtime"; -import { dimensions } from "../../native/reactivity"; +import { dimensions, reduceMotion } from "../../native/reactivity"; jest.mock("react-native", () => { const RN = jest.requireActual("react-native"); @@ -283,3 +283,126 @@ describe("max-resolution", () => { expect(component.props.style).toStrictEqual(undefined); }); }); + +describe("prefers-reduced-motion", () => { + // reduceMotion and colorScheme are module-global observables; reset them so + // each test starts from a known state (motion enabled, light scheme). + beforeEach(() => { + act(() => { + reduceMotion.set(false); + colorScheme.set("light"); + }); + }); + + test("reduce (motion-reduce:) — applies only when reduce motion is enabled", () => { + registerCSS(` +.my-class { color: blue; } + +@media (prefers-reduced-motion: reduce) { + .my-class { color: red; } +}`); + + render(); + const component = screen.getByTestId(testID); + + // Default: motion enabled → the reduce rule does not apply. + expect(component.props.style).toStrictEqual({ color: "#00f" }); + + act(() => { + reduceMotion.set(true); + }); + expect(component.props.style).toStrictEqual({ color: "#f00" }); + + // Reactive both ways — toggling the OS flag off restores the base style. + act(() => { + reduceMotion.set(false); + }); + expect(component.props.style).toStrictEqual({ color: "#00f" }); + }); + + test("no-preference (motion-safe:) — applies only when reduce motion is disabled", () => { + registerCSS(` +.my-class { color: blue; } + +@media (prefers-reduced-motion: no-preference) { + .my-class { color: red; } +}`); + + render(); + const component = screen.getByTestId(testID); + + // Default: motion enabled → no-preference matches. + expect(component.props.style).toStrictEqual({ color: "#f00" }); + + act(() => { + reduceMotion.set(true); + }); + expect(component.props.style).toStrictEqual({ color: "#00f" }); + }); + + test("composes with prefers-color-scheme via `and`", () => { + registerCSS(` +.my-class { color: blue; } + +@media (prefers-reduced-motion: reduce) and (prefers-color-scheme: dark) { + .my-class { color: red; } +}`); + + render(); + const component = screen.getByTestId(testID); + + expect(component.props.style).toStrictEqual({ color: "#00f" }); + + // Only reduce motion — the rule still needs dark. + act(() => { + reduceMotion.set(true); + }); + expect(component.props.style).toStrictEqual({ color: "#00f" }); + + // Both conditions now hold. + act(() => { + colorScheme.set("dark"); + }); + expect(component.props.style).toStrictEqual({ color: "#f00" }); + }); + + test("negation — not (prefers-reduced-motion: reduce)", () => { + registerCSS(` +.my-class { color: blue; } + +@media not all and (prefers-reduced-motion: reduce) { + .my-class { color: red; } +}`); + + render(); + const component = screen.getByTestId(testID); + + // Motion enabled → not(reduce) is true → the rule applies. + expect(component.props.style).toStrictEqual({ color: "#f00" }); + + act(() => { + reduceMotion.set(true); + }); + expect(component.props.style).toStrictEqual({ color: "#00f" }); + }); + + test("bare boolean — @media (prefers-reduced-motion) is equivalent to reduce", () => { + registerCSS(` +.my-class { color: blue; } + +@media (prefers-reduced-motion) { + .my-class { color: red; } +}`); + + render(); + const component = screen.getByTestId(testID); + + // Bare boolean form matches when reduce motion is enabled (CSS: bare ≡ reduce). + expect(component.props.style).toStrictEqual({ color: "#00f" }); + + act(() => { + reduceMotion.set(true); + }); + expect(component.props.style).toStrictEqual({ color: "#f00" }); + }); +}); diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts index 75cd9006..c34a5910 100644 --- a/src/native/conditions/media-query.ts +++ b/src/native/conditions/media-query.ts @@ -3,7 +3,7 @@ import { I18nManager, PixelRatio, Platform } from "react-native"; import type { MediaCondition } from "react-native-css/compiler"; -import { colorScheme, vh, vw, type Getter } from "../reactivity"; +import { colorScheme, reduceMotion, vh, vw, type Getter } from "../reactivity"; export function testMediaQuery(mediaQueries: MediaCondition[], get: Getter) { return mediaQueries.every((query) => test(query, get)); @@ -12,8 +12,13 @@ export function testMediaQuery(mediaQueries: MediaCondition[], get: Getter) { function test(mediaQuery: MediaCondition, get: Getter): Boolean { switch (mediaQuery[0]) { case "[]": - case "!!": return false; + case "!!": + // A bare boolean media feature, e.g. `@media (prefers-reduced-motion)`. + // Per CSS, bare `(prefers-reduced-motion)` is equivalent to `reduce`. + return mediaQuery[1] === "prefers-reduced-motion" + ? get(reduceMotion) + : false; case "!": return !test(mediaQuery[1], get); case "&": @@ -47,6 +52,12 @@ function testComparison(mediaQuery: MediaCondition, get: Getter): Boolean { case "prefers-color-scheme": { return value === get(colorScheme); } + case "prefers-reduced-motion": { + // `motion-reduce:` compiles to `reduce`, `motion-safe:` to + // `no-preference`. Mirror the prefers-color-scheme wiring, reading the + // live OS flag from the AccessibilityInfo-backed reduceMotion observable. + return value === "no-preference" ? !get(reduceMotion) : get(reduceMotion); + } case "display-mode": return value === "native" || Platform.OS === value; case "min-width": diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index 0824edeb..5fada05e 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -1,6 +1,7 @@ /* eslint-disable */ import { createContext } from "react"; import { + AccessibilityInfo, Appearance, Dimensions, type ColorSchemeName, @@ -221,6 +222,22 @@ export const colorScheme = observable( ); Appearance.addChangeListener((event) => colorScheme.set(event.colorScheme)); +/** Reduce Motion ************************************************************/ + +// Mirror the Color Scheme wiring above. Appearance.getColorScheme() is +// synchronous, but AccessibilityInfo has no synchronous getter, so the +// observable seeds `false` (motion enabled — the safe default) and flips +// when isReduceMotionEnabled() resolves, then stays live via the +// reduceMotionChanged event. iOS drives this directly; on Android the OS +// surface is the animation duration scale (react-native #31221). +export const reduceMotion = observable(false); +AccessibilityInfo.isReduceMotionEnabled().then((enabled) => + reduceMotion.set(enabled), +); +AccessibilityInfo.addEventListener("reduceMotionChanged", (enabled) => + reduceMotion.set(enabled), +); + /** Containers ****************************************************************/ export type ContainerContextValue = Record; From 06d3ead687560147b1da6974275f80220953fdda Mon Sep 17 00:00:00 2001 From: Yevhenii Date: Thu, 23 Jul 2026 15:11:28 +0300 Subject: [PATCH 2/2] docs(native): clarify the reduce-motion cold-start seed 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. --- src/native/reactivity.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/src/native/reactivity.ts b/src/native/reactivity.ts index 5fada05e..d129c032 100644 --- a/src/native/reactivity.ts +++ b/src/native/reactivity.ts @@ -224,12 +224,13 @@ Appearance.addChangeListener((event) => colorScheme.set(event.colorScheme)); /** Reduce Motion ************************************************************/ -// Mirror the Color Scheme wiring above. Appearance.getColorScheme() is -// synchronous, but AccessibilityInfo has no synchronous getter, so the -// observable seeds `false` (motion enabled — the safe default) and flips -// when isReduceMotionEnabled() resolves, then stays live via the -// reduceMotionChanged event. iOS drives this directly; on Android the OS -// surface is the animation duration scale (react-native #31221). +// Mirror the Color Scheme wiring above — but AccessibilityInfo has no +// synchronous getter (Appearance.getColorScheme() does), so this can't be +// seeded synchronously. It starts `false` (motion enabled — the safe default), +// flips when isReduceMotionEnabled() resolves (a brief, unavoidable cold-start +// window), and stays live via reduceMotionChanged. iOS drives this directly; +// on Android the OS surface is the animation duration scale (react-native +// #31221). The one-shot seed read is intentionally fire-and-forget. export const reduceMotion = observable(false); AccessibilityInfo.isReduceMotionEnabled().then((enabled) => reduceMotion.set(enabled),