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..d129c032 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,23 @@ 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 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),
+);
+AccessibilityInfo.addEventListener("reduceMotionChanged", (enabled) =>
+ reduceMotion.set(enabled),
+);
+
/** Containers ****************************************************************/
export type ContainerContextValue = Record;