diff --git a/src/__tests__/compiler/compiler.test.tsx b/src/__tests__/compiler/compiler.test.tsx
index b08e24c3..e4c62545 100644
--- a/src/__tests__/compiler/compiler.test.tsx
+++ b/src/__tests__/compiler/compiler.test.tsx
@@ -232,6 +232,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..9f5841ae 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,224 @@ describe("max-resolution", () => {
expect(component.props.style).toStrictEqual(undefined);
});
});
+
+// Outside the describe below, whose beforeEach overwrites the value before any
+// assertion can see it. The documented cold-start default is motion ENABLED: the
+// getter is async with no synchronous counterpart, so the first paint answers from
+// this, and seeding true would suppress motion-safe: styling for every user.
+test("reduceMotion defaults to false before AccessibilityInfo answers", () => {
+ expect(reduceMotion.get()).toBe(false);
+});
+
+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" });
+ });
+
+ test("an unrecognised value never matches", () => {
+ // The compiler emits ["=", name, value] for any value, with no allowlist, so
+ // this condition is reachable. MQ5 makes an unknown value false — a two-way
+ // branch on `no-preference` would alias everything else to `reduce`.
+ registerCSS(`
+.my-class { color: blue; }
+
+@media (prefers-reduced-motion: bogus-value) {
+ .my-class { color: red; }
+}`);
+
+ render();
+ const component = screen.getByTestId(testID);
+
+ expect(component.props.style).toStrictEqual({ color: "#00f" });
+
+ act(() => {
+ reduceMotion.set(true);
+ });
+ expect(component.props.style).toStrictEqual({ color: "#00f" });
+ });
+});
+
+// Chromium 153, Firefox 155 and WebKit 26.6 agree on every row: [query, reduce/light, reduce/dark, no-preference/light, no-preference/dark]
+const BROWSER_MEDIA_ROWS = [
+ ["(prefers-reduced-motion: reduce)", true, true, false, false],
+ ["(prefers-reduced-motion: no-preference)", false, false, true, true],
+ ["(prefers-reduced-motion)", true, true, false, false],
+ ["not (prefers-reduced-motion)", false, false, true, true],
+ ["(prefers-reduced-motion: bogus)", false, false, false, false],
+ ["not all and (prefers-reduced-motion: reduce)", false, false, true, true],
+ [
+ "(prefers-reduced-motion: reduce) and (min-width: 1px)",
+ true,
+ true,
+ false,
+ false,
+ ],
+ [
+ "(prefers-reduced-motion: reduce), (prefers-color-scheme: dark)",
+ true,
+ true,
+ false,
+ true,
+ ],
+ ["not (prefers-reduced-motion: reduce)", false, false, true, true],
+ ["(prefers-reduced-motion: REDUCE)", true, true, false, false],
+ ["(prefers-color-scheme: DARK)", false, true, false, true],
+ ["(prefers-color-scheme: dark)", false, true, false, true],
+ ["(unknown-feature)", false, false, false, false],
+] as const;
+
+const MEDIA_STATES = [
+ ["reduce", "light"],
+ ["reduce", "dark"],
+ ["no-preference", "light"],
+ ["no-preference", "dark"],
+] as const;
+
+describe.each(BROWSER_MEDIA_ROWS)(
+ "@media %s matches as browsers match it",
+ (query, ...matches) => {
+ afterEach(() => {
+ act(() => {
+ reduceMotion.set(false);
+ colorScheme.set("light");
+ });
+ });
+
+ test.each(
+ MEDIA_STATES.map(
+ ([motion, scheme], index) => [motion, scheme, matches[index]] as const,
+ ),
+ )("under %s motion in the %s scheme: %s", (motion, scheme, expected) => {
+ act(() => {
+ reduceMotion.set(motion === "reduce");
+ colorScheme.set(scheme);
+ });
+ registerCSS(
+ `.my-class { color: #000000; } @media ${query} { .my-class { color: #010101; } }`,
+ );
+
+ render();
+
+ expect(screen.getByTestId(testID).props.style).toStrictEqual({
+ color: expected ? "#010101" : "#000",
+ });
+ });
+ },
+);
diff --git a/src/__tests__/native/reduce-motion-init.test.ts b/src/__tests__/native/reduce-motion-init.test.ts
new file mode 100644
index 00000000..17f1e8e4
--- /dev/null
+++ b/src/__tests__/native/reduce-motion-init.test.ts
@@ -0,0 +1,89 @@
+// src/native/reactivity.ts is the root of every media feature — colorScheme, vw/vh,
+// containers and reduceMotion all live there. Seeding reduceMotion reaches out to
+// AccessibilityInfo at module scope, so a host without a native AccessibilityInfo
+// must not take the whole module down with it.
+//
+// A Proxy over requireActual rather than a spread: spreading react-native eagerly
+// triggers its lazy DevMenu getter and throws.
+const noopSubscription = { remove: () => undefined };
+
+const withAccessibilityInfo =
+ (accessibilityInfo: unknown) => (): Record => {
+ const actual = jest.requireActual>("react-native");
+
+ return new Proxy(actual, {
+ get: (target, property): unknown =>
+ property === "AccessibilityInfo"
+ ? accessibilityInfo
+ : Reflect.get(target, property),
+ });
+ };
+
+describe("an AccessibilityInfo without isReduceMotionEnabled", () => {
+ test("does not stop the reactivity module importing", () => {
+ jest.resetModules();
+ jest.doMock(
+ "react-native",
+ withAccessibilityInfo({ addEventListener: () => noopSubscription }),
+ );
+
+ const reactivity = jest.requireActual<
+ typeof import("../../native/reactivity")
+ >("../../native/reactivity");
+
+ expect(reactivity.reduceMotion.get()).toBe(false);
+ expect(reactivity.colorScheme).toBeDefined();
+ expect(reactivity.vw).toBeDefined();
+ });
+});
+
+describe("an AccessibilityInfo whose getter rejects", () => {
+ test("leaves the safe default in place and does not reject unhandled", async () => {
+ jest.resetModules();
+ jest.doMock(
+ "react-native",
+ withAccessibilityInfo({
+ isReduceMotionEnabled: () =>
+ Promise.reject(new Error("NativeAccessibilityManager unavailable")),
+ addEventListener: () => noopSubscription,
+ }),
+ );
+
+ const reactivity = jest.requireActual<
+ typeof import("../../native/reactivity")
+ >("../../native/reactivity");
+ await Promise.resolve();
+
+ expect(reactivity.reduceMotion.get()).toBe(false);
+ });
+});
+
+describe("a working AccessibilityInfo", () => {
+ test("seeds reduceMotion from it and stays live on the change event", async () => {
+ jest.resetModules();
+ let changed: ((enabled: boolean) => void) | undefined;
+ jest.doMock(
+ "react-native",
+ withAccessibilityInfo({
+ isReduceMotionEnabled: () => Promise.resolve(true),
+ addEventListener: (event: string, listener: (v: boolean) => void) => {
+ if (event === "reduceMotionChanged") changed = listener;
+ return noopSubscription;
+ },
+ }),
+ );
+
+ const reactivity = jest.requireActual<
+ typeof import("../../native/reactivity")
+ >("../../native/reactivity");
+ await Promise.resolve();
+
+ // The seed is what connects the observable to the OS at all; without it the
+ // flag is a value the library only ever writes to itself
+ expect(reactivity.reduceMotion.get()).toBe(true);
+
+ expect(changed).toBeDefined();
+ changed?.(false);
+ expect(reactivity.reduceMotion.get()).toBe(false);
+ });
+});
diff --git a/src/compiler/media-query.ts b/src/compiler/media-query.ts
index 1408adc0..858a22e3 100644
--- a/src/compiler/media-query.ts
+++ b/src/compiler/media-query.ts
@@ -145,8 +145,10 @@ export function parseMediaFeatureValue(
builder: StylesheetBuilder,
): StyleDescriptor {
switch (value.type) {
- case "boolean":
case "ident":
+ // A keyword is ASCII case-insensitive (css-values-4 §4.1), so `DARK` names the scheme `dark` does
+ return value.value.replace(/[A-Z]/g, (letter) => letter.toLowerCase());
+ case "boolean":
case "integer":
case "number":
return value.value;
diff --git a/src/native/conditions/media-query.ts b/src/native/conditions/media-query.ts
index afd51250..d29a422e 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,12 @@ export function testMediaQuery(mediaQueries: MediaCondition[], get: Getter) {
function test(mediaQuery: MediaCondition, get: Getter): Boolean {
switch (mediaQuery[0]) {
case "[]":
- case "!!":
return false;
+ case "!!":
+ // MQ5: a feature in a boolean context is true unless its value is `no-preference`
+ return mediaQuery[1] === "prefers-reduced-motion"
+ ? get(reduceMotion)
+ : false;
case "!":
return !test(mediaQuery[1], get);
case "&":
@@ -47,6 +51,10 @@ function testComparison(mediaQuery: MediaCondition, get: Getter): Boolean {
case "prefers-color-scheme": {
return value === get(colorScheme);
}
+ case "prefers-reduced-motion": {
+ // An unknown value matches nothing (MQ5), so this is an equality, not a two-way branch
+ return value === (get(reduceMotion) ? "reduce" : "no-preference");
+ }
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 296988a6..5d2b6715 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,
@@ -233,6 +234,28 @@ export const colorScheme = observable(
);
Appearance.addChangeListener((event) => colorScheme.set(event.colorScheme));
+/** Reduce Motion ************************************************************/
+
+// AccessibilityInfo answers asynchronously, so motion counts as enabled until it does; Android reports its animator scale (react-native#31221)
+export const reduceMotion = observable(false);
+
+// Every media feature imports this module, so a platform without AccessibilityInfo must not throw here
+try {
+ AccessibilityInfo.isReduceMotionEnabled()
+ .then((enabled) => reduceMotion.set(enabled))
+ .catch(() => undefined);
+} catch {
+ // Motion stays enabled
+}
+
+try {
+ AccessibilityInfo.addEventListener("reduceMotionChanged", (enabled) =>
+ reduceMotion.set(enabled),
+ );
+} catch {
+ // The flag keeps the value the seed resolved to
+}
+
/** Containers ****************************************************************/
export type ContainerContextValue = Record<