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..e4a4ab99 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,156 @@ 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" });
+ });
+});
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..1de0a74a
--- /dev/null
+++ b/src/__tests__/native/reduce-motion-init.test.ts
@@ -0,0 +1,83 @@
+// 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", async () => {
+ jest.resetModules();
+ jest.doMock(
+ "react-native",
+ withAccessibilityInfo({ addEventListener: () => noopSubscription }),
+ );
+
+ const reactivity = await import("../../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 = await import("../../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 = await import("../../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/native/conditions/media-query.ts b/src/native/conditions/media-query.ts
index 75cd9006..2685602a 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,13 @@ 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`. An equality test rather than a two-way branch, so an
+ // unrecognised value is false as MQ5 requires, instead of aliasing to
+ // `reduce`.
+ 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 0824edeb..df7d535c 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,39 @@ export const colorScheme = observable(
);
Appearance.addChangeListener((event) => colorScheme.set(event.colorScheme));
+/** Reduce Motion ************************************************************/
+
+// 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 there is no distinct setting, so AccessibilityInfoModule reads
+// Settings.Global.TRANSITION_ANIMATION_SCALE and reports true when it is 0
+// (react-native #31221 — which is about that reading disagreeing with the
+// "Remove animations" toggle).
+export const reduceMotion = observable(false);
+
+// Guarded because this module is the root of every media feature. Without a
+// native AccessibilityInfo the getter is absent or rejects, and an unguarded
+// call throws at module scope — taking colorScheme, vw/vh and containers down
+// with it, none of which have anything to do with motion.
+try {
+ AccessibilityInfo.isReduceMotionEnabled()
+ .then((enabled) => reduceMotion.set(enabled))
+ .catch(() => undefined);
+} catch {
+ // Leave the safe default in place.
+}
+
+try {
+ AccessibilityInfo.addEventListener("reduceMotionChanged", (enabled) =>
+ reduceMotion.set(enabled),
+ );
+} catch {
+ // Without the listener the flag stays at whatever the seed resolved to.
+}
+
/** Containers ****************************************************************/
export type ContainerContextValue = Record;