How to Implement the Prefers-Reduced-Motion Accessibility Pattern in CSS and JavaScript

The prefers-reduced-motion accessibility pattern combines a CSS media query with a JavaScript hook to conditionally disable motion-heavy animations while preserving essential visual feedback.

The emilkowalski/skills repository establishes a battle-tested approach for respecting users' motion preferences. This pattern splits responsibility between declarative CSS (disabling problematic animations) and imperative JavaScript (conditionally adjusting animation values), ensuring all animation sources stay synchronized with the OS-level setting.

CSS Implementation: The Media Query Foundation

The core of the pattern lives in skills/review-animations/STANDARDS.md, where the canonical rule strips transform-based motion while keeping opacity and color transitions intact.

The Base Pattern

/* Full-motion experience */
.element {
  animation: slideIn 300ms ease-out forwards;
  transform: translateX(0);
  transition: transform 300ms ease;
}

/* Reduced-motion fallback */
@media (prefers-reduced-motion: reduce) {
  .element {
    animation: fade 0.2s ease;  /* keep opacity/color feedback */
    transform: none;            /* remove position/shape changes */
    transition: none;
  }
}

According to the repository standards at lines 163-165, this approach avoids motion sickness triggers (rapid position changes, scaling, rotation) without creating a "jarring" instant state change. The fade animation provides context that something happened—critical for comprehension—without the vestibular disruption.

Picker Component Example

The skills/prototype/PICKER.md file (lines 70-72) demonstrates component-level application:

.proto-picker-highlight {
  transition: transform 250ms cubic-bezier(0.23, 1, 0.32, 1),
              width 250ms cubic-bezier(0.23, 1, 0.32, 1);
}

@media (prefers-reduced-motion: reduce) {
  .proto-picker-highlight {
    transition: none;
  }
}

This disables the slide transition for the picker's highlight indicator when reduced motion is requested.

JavaScript Implementation: The useReducedMotion Hook

For JavaScript-driven animations—tweens, spring physics, or library-controlled motion—the repository provides a useReducedMotion hook. This ensures imperative code respects the same preference as CSS.

Hook Implementation

import { useEffect, useState } from 'react';

export function useReducedMotion() {
  const [reduce, setReduce] = useState(
    typeof window !== 'undefined' &&
      window.matchMedia('(prefers-reduced-motion: reduce)').matches
  );

  useEffect(() => {
    const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
    const handler = (e) => setReduce(e.matches);
    mq.addEventListener('change', handler);
    return () => mq.removeEventListener('change', handler);
  }, []);

  return reduce;
}

The hook uses window.matchMedia for initial detection and attaches a listener for runtime changes—critical because users can toggle this setting without reloading the page.

Component Usage Pattern

From STANDARDS.md lines 71-74, the canonical usage:

const reduce = useReducedMotion();
const closedX = reduce ? 0 : '-100%';

This pattern—ternary fallback to static values—appears throughout the codebase as the standard way to adapt JS-driven motion.

Full Component Example: Picker Highlight

import { useReducedMotion } from './useReducedMotion';

function PickerHighlight({ dataReady, targetRect }) {
  const reduce = useReducedMotion();

  // Skip animated transitions when reduced motion is preferred
  const style = {
    transform: `translateX(${targetRect.left}px)`,
    width: targetRect.width,
    transition:
      dataReady && !reduce
        ? 'transform 250ms cubic-bezier(0.23,1,0.32,1), width 250ms cubic-bezier(0.23,1,0.32,1)'
        : 'none',
  };

  return <div className="proto-picker-highlight" style={style} />;
}

Here, !reduce gates the transition string—when true, the highlight snaps instantly to its new position rather than sliding.

Additional Accessibility Guards: Hover Gating

The repository extends the pattern to input modality at STANDARDS.md lines 66-68:

@media (hover: hover) and (pointer: fine) {
  .element:hover {
    transform: scale(1.05);
  }
}

This prevents hover-triggered motion on touch devices where hover is unreliable. Combined with prefers-reduced-motion, this creates defense in depth against unwanted animation.

Why This Architecture Works

Concern Solution
User preference @media (prefers-reduced-motion: reduce) automatically syncs with OS settings—no configuration needed.
Graceful degradation Keeps opacity/color feedback for comprehension; removes transform motion that causes vestibular issues.
Cross-source consistency CSS and JS consult the same media query, eliminating sync bugs between declarative and imperative animation.
Performance Disabling transform and transition reduces layout-thrashing repaints on low-end devices.
Runtime adaptability The JS hook listens for media query changes, responding instantly to setting toggles.

Implementation Checklist

  1. Audit existing animations in skills/improve-animations/AUDIT.md to identify transform-heavy elements.
  2. Add CSS media queries wherever animation is defined—preserve only opacity/color in the reduced block.
  3. Implement useReducedMotion in your utilities layer (copy the hook above).
  4. Replace hardcoded values with conditional logic: const value = reduce ? staticValue : animatedValue.
  5. Test via Chrome DevTools Rendering panel → "Emulate CSS media feature prefers-reduced-motion: reduce".

Summary

  • prefers-reduced-motion is an OS-level accessibility setting that CSS and JavaScript must both respect.
  • The @media (prefers-reduced-motion: reduce) query in skills/review-animations/STANDARDS.md disables transform motion while preserving opacity/color feedback.
  • The useReducedMotion hook provides JavaScript access to the same preference, enabling conditional animation values.
  • The Picker prototype in skills/prototype/PICKER.md demonstrates production-ready integration of both CSS and JS patterns.
  • Hover gating with @media (hover: hover) prevents false-positive animations on touch devices.

Frequently Asked Questions

What exactly does the prefers-reduced-motion media query detect?

The @media (prefers-reduced-motion: reduce) query evaluates to true when the user has enabled reduced motion in their operating system settings. On macOS, this is System Preferences → Accessibility → Display → Reduce motion. On iOS, it's Settings → Accessibility → Motion → Reduce Motion. Windows and Android have equivalent settings. The browser surfaces this preference to CSS and JavaScript automatically.

Should I remove all animations when prefers-reduced-motion is enabled?

No. According to the STANDARDS.md guidance, you should preserve opacity fades and color transitions that provide context without causing motion sickness. Remove or disable position changes, scaling, rotation, and parallax effects. The goal is maintaining comprehension cues while eliminating vestibular triggers.

How do I test prefers-reduced-motion without changing system settings?

Chrome DevTools provides emulation: open the Rendering panel (More tools → Rendering), then enable "Emulate CSS media feature prefers-reduced-motion: reduce". Firefox and Safari also support this emulation in their developer tools. For automated testing, Puppeteer and Playwright can override the media feature programmatically via page.emulateMediaFeatures().

Can third-party animation libraries respect this pattern?

Yes. Pass the useReducedMotion boolean to library configuration objects. For Framer Motion, use <MotionConfig reducedMotion="user">. For React Spring, conditionally set { immediate: reduce } on your springs. For GSAP, wrap timeline calls in if (!reduce) blocks or set gsap.globalTimeline.timeScale(reduce ? 0 : 1).

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →