Quick reference guide for implementing reduced-motion accessibility patterns in React components.
import { useReducedMotion } from "@/hooks/useReducedMotion";
export function MyComponent() {
const reducedMotion = useReducedMotion();
return (
<div className={`my-component ${reducedMotion ? "" : "animate-fade-in"}`}>
Content
</div>
);
}Best for: Simple animations, CSS transitions, Tailwind utilities
// ✅ Good
<div className={`base-styles ${reducedMotion ? "" : "animate-bounce transition-all"}`}>
// ❌ Avoid
<div className={reducedMotion ? "base-styles" : "base-styles animate-bounce"}>Best for: Complex framer-motion animations, multi-element choreography
export function AnimatedComponent({ reducedMotion: reducedMotionProp }) {
const reducedMotion = reducedMotionProp ?? useReducedMotion();
if (reducedMotion) {
return <StaticFallback />;
}
return <AnimatedVersion />;
}Best for: Time-dependent animations, loading states, auto-advancing content
useEffect(() => {
if (reducedMotion) {
// Skip to end state immediately
setData(finalData);
return;
}
// Animated loading sequence
const timer = setTimeout(() => setData(finalData), 1500);
return () => clearTimeout(timer);
}, [reducedMotion]);- Renders different structures for motion vs. reduced-motion
- Static fallback contains all essential content
- No animation classes in reduced-motion mode
- Accessibility attributes identical in both modes
- Prop override works correctly
import { useReducedMotion } from "@/hooks/useReducedMotion";
jest.mock("@/hooks/useReducedMotion");
const mockUseReducedMotion = useReducedMotion as jest.MockedFunction<typeof useReducedMotion>;
describe("MyComponent — reduced-motion", () => {
beforeEach(() => {
mockUseReducedMotion.mockReset();
});
it("applies animations when motion is enabled", () => {
mockUseReducedMotion.mockReturnValue(false);
// Test animated version
});
it("removes animations when motion is reduced", () => {
mockUseReducedMotion.mockReturnValue(true);
// Test static version
});
});/* Always wrap these in reducedMotion checks */
.animate-fade-in
.animate-slide-up
.animate-bounce
.animate-pulse
.animate-spin
.animate-ping/* These are handled by global CSS but can be conditionally applied */
.transition-all
.transition-colors
.transition-transform
.duration-200
.duration-300/* Especially important for motion sensitivity */
.hover:scale-105
.active:scale-95
.transform- Maintain identical
role,aria-label,aria-livein both motion states - Add
role="status"to reduced-motion notification banners - Use
aria-live="polite"for non-urgent status updates
- Preserve visual importance without relying on motion
- Use color, size, and position to maintain emphasis
- Ensure content remains scannable when static
- Test with actual
prefers-reduced-motionsetting enabled - Verify with screen readers in both motion states
- Check keyboard navigation works in static mode
- Use CSS media queries for global animation disabling
- Implement early returns to skip expensive animation logic
- Preserve hover states even when transitions are disabled
- Use identical component structure between motion states
- Loading animation libraries when motion is reduced
- Complex JavaScript animations that bypass CSS media queries
- Different DOM structures that cause layout shifts
- Removing all visual feedback (keep hover states)
Chrome/Edge: DevTools → Rendering → Emulate CSS media feature → prefers-reduced-motion: reduce
Firefox: DevTools → Settings → Accessibility → Reduce motion
# Run reduced-motion tests specifically
pnpm test -- --testPathPattern="reduced-motion"
# Test with coverage
pnpm test:coverage -- --testPathPattern="reduced-motion"// Add temporary logging to verify hook behavior
const reducedMotion = useReducedMotion();
console.log('Reduced motion enabled:', reducedMotion);// ❌ Will cause hydration mismatch
const [reducedMotion, setReducedMotion] = useState(false);
// ✅ SSR-safe initialization
const [reducedMotion, setReducedMotion] = useState(() => {
if (typeof window === "undefined") return false;
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
});// ❌ Still renders motion components
{reducedMotion ? <div>Static</div> : <motion.div>Animated</motion.div>}
// ✅ Completely separate render paths
if (reducedMotion) return <StaticComponent />;
return <AnimatedComponent />;/* ❌ May be overridden by component styles */
@media (prefers-reduced-motion: reduce) {
.animate-bounce { animation: none; }
}
/* ✅ Use !important for reliable override */
@media (prefers-reduced-motion: reduce) {
.animate-bounce { animation: none !important; }
}- Enable
prefers-reduced-motion: reducein OS settings - Refresh the page
- Verify no animations play automatically
- Check that hover effects still provide visual feedback
- Ensure all content remains accessible
- Component renders without errors in both motion states
- No animation CSS classes present when motion is reduced
- All interactive elements remain functional
- Screen reader announcements work correctly
- Full Documentation:
docs/REDUCED_MOTION_PATTERNS.md - Hook Implementation:
hooks/useReducedMotion.ts - Global CSS Rules:
app/globals.css(line ~270) - Example Components:
components/leaderboard/LeaderboardPodium.tsx