No Reduced Motion Fallback
Your animations have no off switch. Some visitors get dizzy, nauseous, or worse. Here's how to respect their preferences.
What is this?
Think of a theme park ride. Most people enjoy it, but some people feel nauseous on spinning rides, and the park has clear signs and an easy way to opt out. Now imagine a theme park where every ride is mandatory, there are no warnings, and you cannot skip anything. That is what your website is like when it has animations without a reduced motion fallback — visitors who experience motion sickness, vertigo, or seizure disorders have no way to opt out.
Modern operating systems let users enable a "reduce motion" preference. Your site should respect it.
Why it matters
- For your visitors: Vestibular disorders affect roughly 35% of adults over 40 in the US. Parallax scrolling, sliding transitions, auto-playing carousels, and bouncing elements can trigger vertigo, nausea, migraines, or seizures. This is not a comfort preference — it is a health issue.
- For your business: If your landing page makes someone physically uncomfortable, they will leave and never come back. Respecting motion preferences shows you care about all your visitors and builds trust.
- The standard: WCAG 2.3.3 (Animation from Interactions) recommends that motion animation triggered by interaction can be disabled. The
prefers-reduced-motionCSS media query is the standard mechanism for this.
.card {
transition: transform 0.3s ease;
}
.card:hover {
transform: translateY(-4px);
}
/* Disable motion for users who prefer it */
@media (prefers-reduced-motion: reduce) {
.card {
transition: none;
}
.card:hover {
transform: none;
}
}.card {
transition: transform 0.3s ease;
}
.card:hover {
transform: translateY(-4px);
}
/* No reduced motion media query — everyone gets the ride */How to fix it
React / Next.js
The approach works at two levels: CSS for transitions and animations, and JavaScript for controlling animation libraries.
// src/hooks/use-reduced-motion.ts
import { useEffect, useState } from "react";
export function useReducedMotion() {
const [prefersReduced, setPrefersReduced] = useState(false);
useEffect(() => {
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
setPrefersReduced(mq.matches);
const handler = (e: MediaQueryListEvent) => setPrefersReduced(e.matches);
mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler);
}, []);
return prefersReduced;
}Use the hook to conditionally apply animations:
// src/components/animated-card.tsx
import { useReducedMotion } from "@/hooks/use-reduced-motion";
export function AnimatedCard({ children }: { children: React.ReactNode }) {
const prefersReduced = useReducedMotion();
return (
<div
className="border p-6"
style={{
// Skip animation entirely if user prefers reduced motion
transition: prefersReduced ? "none" : "transform 0.3s ease, opacity 0.3s ease",
animationDuration: prefersReduced ? "0s" : "0.5s",
}}
>
{children}
</div>
);
}For CSS-only animations in your global styles:
/* src/app/globals.css */
/* Define your animations normally */
@keyframes fade-in-up {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
animation: fade-in-up 0.5s ease-out;
}
/* Respect the user's preference */
@media (prefers-reduced-motion: reduce) {
/* Option 1: Remove all animations globally */
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}Plain HTML
<style>
/* Your normal animations */
.hero-image {
animation: float 3s ease-in-out infinite;
}
@keyframes float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
}
.slide-in {
animation: slide-in 0.6s ease-out;
}
@keyframes slide-in {
from {
transform: translateX(-100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* Kill animations for users who asked for it */
@media (prefers-reduced-motion: reduce) {
.hero-image {
animation: none;
}
.slide-in {
animation: none;
/* Still show the element — just skip the entrance */
transform: translateX(0);
opacity: 1;
}
/* Be thorough: catch everything */
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}
</style>
<div class="hero-image">
<img src="/floating-device.png" alt="Product showcase" />
</div>Important notes:
- Use
0.01msduration instead of0s— some browsers need a nonzero value to fire animation events - Always ensure the final visual state is still correct with motion disabled (elements should appear, just not animate in)
- Auto-playing carousels and video should pause, not just freeze mid-frame
This is a health issue, not a style preference. The cat is especially unimpressed by sites with elaborate animations and no reduced motion support. Build the off-switch first.
How the cat scores this
The cat checks your CSS for animations and transitions, then looks for a prefers-reduced-motion: reduce media query. If your stylesheets contain @keyframes, animation, or transition properties with no reduced motion fallback, the cat flags it. The cat also checks for auto-playing animations (infinite loops, carousels) that have no pause mechanism.
Further reading
- MDN: prefers-reduced-motion — the media query reference
- web.dev: prefers-reduced-motion — comprehensive guide with examples
- A11y Project: Reduced Motion — understanding vestibular disorders and the web
Decorative SVG Issues
Your decorative icons and graphics are being narrated by screen readers like unnecessary commentary. Here's how to silence them.
Stateful Controls Missing ARIA States
Your toggles and tabs don't announce whether they're on or off. Screen reader users are flipping switches in the dark.