Accessibility & Performance

10 min

Accessibility & Performance

Animation exists to communicate. But for some users, motion isn't communication—it's a trigger for vestibular discomfort, nausea, or seizures. And for all users, animation that drops frames or causes layout recalculation feels worse than no animation at all.

This lesson covers two non-negotiable requirements: respecting the user's motion preference and keeping animations performant. Neither is optional. Neither is hard to implement. Both are routinely ignored.

prefers-reduced-motion

Every operating system provides a user-level setting to reduce motion. macOS calls it "Reduce motion." Windows calls it "Show animations." iOS and Android have equivalent settings. The CSS media query prefers-reduced-motion: reduce detects this preference.

If a user has enabled reduced motion, your animation is not a design choice—it's an accessibility violation. Respect the setting.

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

This nuclear approach—setting all animation durations to near-zero—is the simplest implementation and a reasonable starting point. It preserves the end state of every animation (the element still reaches its destination) while removing the motion (it gets there instantly).

A more nuanced approach reduces motion rather than eliminating it:

@media (prefers-reduced-motion: reduce) {
  .modal {
    /* Remove scale and translate; keep a fast opacity fade */
    animation: none;
    transition: opacity 100ms ease;
  }
}

This preserves feedback (the user still sees the modal appear) while eliminating spatial motion (the scale and translate that can trigger discomfort). The distinction matters—users who set reduced motion generally don't want zero visual feedback. They want animation that doesn't move across the screen.

In JavaScript

For imperative animations (Framer Motion, GSAP, Web Animations API), check the preference programmatically:

const prefersReducedMotion = window.matchMedia(
  "(prefers-reduced-motion: reduce)"
).matches;

// Use instant transitions when reduced motion is preferred
const transition = prefersReducedMotion
  ? { duration: 0 }
  : { type: "spring", stiffness: 500, damping: 40 };

Framer Motion supports this natively with the useReducedMotion hook:

import { useReducedMotion } from "motion/react";

function Modal() {
  const shouldReduce = useReducedMotion();

  return (
    <motion.div
      initial={shouldReduce ? { opacity: 0 } : { opacity: 0, scale: 0.95, y: 12 }}
      animate={shouldReduce ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }}
      transition={shouldReduce ? { duration: 0.1 } : { duration: 0.25 }}
    />
  );
}

Transform and opacity only

The browser's rendering pipeline has three stages: layout, paint, and composite. transform and opacity are the only CSS properties that can be animated entirely in the composite stage—meaning the GPU handles them without triggering layout recalculation or pixel repaint.

Everything else—width, height, top, left, margin, padding, border-radius, box-shadow, background-color—triggers layout or paint (or both) on every frame. This means the browser must recalculate the position of every element on the page, every 16ms, for the duration of the animation.

The practical rule: animate transform and opacity. Animate nothing else.

This isn't a guideline. It's a performance boundary. On a modern desktop, animating width might run at 60fps if nothing else is happening. On a mid-range mobile device with a complex DOM, it will stutter. On any device, a transform animation will run smoothly because it's handled by dedicated GPU hardware.

| Property | Triggers | Performance | |---|---|---| | transform (translate, scale, rotate) | Composite only | Smooth | | opacity | Composite only | Smooth | | filter (blur, brightness) | Paint | Usually fine, test on mobile | | box-shadow | Paint | Can stutter with large spreads | | background-color | Paint | Fine for discrete changes, avoid during drag | | width, height, top, left | Layout + Paint | Never animate | | border-radius | Paint | Never animate | | clip-path (inset, circle) | Composite (modern browsers) | Smooth with will-change |

will-change: use sparingly

will-change tells the browser to promote an element to its own compositor layer before the animation starts. This eliminates the one-time cost of layer promotion that can cause a single-frame stutter at the start of an animation.

.about-to-animate {
  will-change: transform;
}

But will-change is not free. Each promoted layer consumes GPU memory. Applying will-change: transform to every element in a list of 200 items creates 200 compositor layers, which can exhaust GPU memory on mobile devices and actually make performance worse.

The correct usage pattern:

  1. Apply will-change immediately before the animation starts (e.g., on mouseenter for a hover animation, or when a modal is about to open).
  2. Remove will-change after the animation completes (on transitionend or animationend).
  3. Never apply will-change in a static stylesheet to elements that "might" animate. If the animation doesn't happen, the GPU memory is wasted.
// Apply on hover, remove on animation end
<div
  onMouseEnter={() => el.style.willChange = "transform"}
  onTransitionEnd={() => el.style.willChange = "auto"}
/>

Off-screen pausing

Animations that run while off-screen waste CPU and GPU cycles. A continuously rotating spinner below the fold, an auto-playing carousel above the viewport, a looping background animation on a hidden tab—all consume resources the user can't see.

Use the Intersection Observer API to pause animations when their element leaves the viewport:

useEffect(() => {
  const observer = new IntersectionObserver(
    ([entry]) => {
      if (entry.isIntersecting) {
        controls.start("visible");
      } else {
        controls.stop();
      }
    },
    { threshold: 0.1 }
  );

  if (ref.current) observer.observe(ref.current);
  return () => observer.disconnect();
}, [controls]);

For CSS animations, toggle a class that sets animation-play-state: paused when the element is not intersecting.

This is especially important for page-transition animations that play on mount. If a user navigates to a long page, elements at the bottom should not animate until they scroll into view—both for performance (don't animate what can't be seen) and for user experience (the animation is meaningless if the user wasn't looking).

Layout animation gotchas

Framer Motion's layout prop enables automatic layout animations—elements smoothly transition between different positions and sizes in the DOM. This is powerful and dangerous.

Layout animations trigger browser layout recalculation on every frame. For a single element, this is fine. For a list of 50 elements with layout props, the browser recalculates the position of all 50 elements 60 times per second. This will stutter on any device.

Mitigations:

  • Use layout on individual hero elements, not on every item in a list.
  • Use layoutId for shared-element transitions between two specific elements.
  • For list reordering, use layout="position" (skips size animation, which is the most expensive part).
  • Set layoutScroll on scrolling containers that contain layout-animated elements—without it, the layout calculation ignores scroll position and produces incorrect transitions.

The performance test

Before shipping any animation, test on a low-end device. Not your M-series MacBook—a two-year-old Android phone with 4GB of RAM. Enable paint flashing in DevTools. If green rectangles appear during your animation, you're triggering paint. If the animation drops below 50fps on that device, simplify it.

The user who needs your animation to be performant is never the user with the fastest hardware. Build for the floor, not the ceiling.