Skip to main content

Accessible Motion Design for the Web

00:03:00:00

Motion design is a critical component of user experience, but not all enjoy animations equally.

It is estimated that up to 35% of adults 40+ in the US have suffered from some form of vestibular dysfunction (source). For these people, motion can trigger physical symptoms like nausea, dizziness, and malaise.

Operating systems offer a solution to this: users can opt-out of animations. The setting is meant primarily for the operating system, but websites and web applications can take advantage of it.

For a few years now, various operating systems have been letting users opt-out of animations, typically within accessibility settings:

Windows settings showing the option to disable animation.

When animations are disabled, all motion is reduced across the operating system. This setting is exposed to our browsers using a media query, prefers-reduced-motion. This way, websites can read and respect the same settings to disable animations.

CSS Implementation

Animations can be disabled in CSS using a media query:

*,
*::before,
*::after {
  @media (prefers-reduced-motion: reduce) {
    animation-duration: 0s !important;
    animation-delay: 0s !important;
    transition-duration: 0s !important;
    transition-delay: 0s !important;
  }
}

By default, no-preference is the default value for those who opt-in animations and reduce for those who opt-out.

Although animations go from animated to still this way, a better mindset is to start from a still experience and enable motion conditionally:

.example {
  transform: translate3d(0, 0, 0);
}

.example:hover {
  transform: translate3d(20px, 0, 0);
}

@media (prefers-reduced-motion: no-preference) {
  .example {
    transition: transform: 200ms!important;
  }
}

By ensuring that the transition is set from within a media query, the animation is disabled by default for users on browsers that don’t support this property. Browsers ignore CSS inside unrecognized media queries, so this transition doesn’t apply for them.

JavaScript Hook

The media queries work great for CSS animations, but we’ll need a solution for JavaScript animations and transitions. Because this feature is implemented as a media query, it can be accessed the same way we access any media query values in JavaScript by using window.matchMedia:

import { useEffect, useState } from 'react';

function usePrefersReducedMotion() {
  const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
  const [reduceMotion, setReduceMotion] = useState(mediaQuery.matches);

  useEffect(() => {
    const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');

    const handleMediaChange = () => {
      if (mediaQuery.matches) {
        setReduceMotion(true);
      }
    };

    mediaQuery.addListener(handleMediaChange);

    return () => {
      mediaQuery.removeListener(handleMediaChange);
    };
  }, []);

  return reduceMotion;
}

export default usePrefersReducedMotion;

This listener will fire when the user toggles the reduce motion settings in their operating system.

We want to listen for this event because we want to immediately halt animations if the user toggles this setting, even if the page has already loaded / the animation is in progress.

JavaScript Implementation

Here’s how we would use the hook with ThreeJS, a 3D rendering library that works great with React:

import React, { useRef, useEffect } from 'react';
import {
  PerspectiveCamera, Scene, BoxGeometry, MeshNormalMaterial, Mesh, WebGLRenderer
} from 'three';
import { usePrefersReducedMotion } from 'hooks';

function AccessibleCube() {
  const width = useRef(window.innerWidth);
  const height = useRef(window.innerHeight);
  const camera = useRef();
  const scene = useRef();
  const geometry = useRef();
  const material = useRef();
  const mesh = useRef();
  const renderer = useRef();
  const canvasRef = useRef();
  const prefersReducedMotion = usePrefersReducedMotion();

  useEffect(() => {
    camera.current = new PerspectiveCamera(70, width.current / height.current, 0.01, 10);
    camera.current.position.z = 1;

    scene.current = new Scene();

    geometry.current = new BoxGeometry(0.2, 0.2, 0.2);
    material.current = new MeshNormalMaterial();

    mesh.current = new Mesh(geometry.current, material.current);
    scene.current.add(mesh.current);

    renderer.current = new WebGLRenderer({
      canvas: canvasRef.current,
      powerPreference: 'high-performance',
    });
    renderer.current.setSize(width.current, height.current);

    return function cleanup() {
      scene.current.remove(mesh.current);
      mesh.current.geometry.dispose();
      mesh.current.material.dispose();
      geometry.current.dispose();
      material.current.dispose();
      renderer.current.dispose();
      scene.current.dispose();
      camera.current = null;
      mesh.current = null;
      renderer.current.domElement = null;
    };
  }, []);

  useEffect(() => {
    let animation;

    const animate = () => {
      animation = requestAnimationFrame(animate);

      mesh.current.rotation.x += 0.01;
      mesh.current.rotation.y += 0.02;

      renderer.current.render(scene.current, camera.current);
    };

    if (!prefersReducedMotion) {
      animate();
    } else {
      renderer.current.render(scene.current, camera.current);
    }

    return () => {
      cancelAnimationFrame(animation);
    };
  }, [prefersReducedMotion]);

  return (
    <canvas aria-hidden ref={canvasRef} />
  );
}

export default AccessibleCube;

We can control whether the scene is animated with the usePrefersReducedMotion hook.

This hook is plug-and-play, so you can connect this as a boolean to any component.

Conclusion

Not everyone experiences things the same way, and we need to be mindful of that in our work.

With the prefers-reduced-motion media query and usePrefersReducedMotion React hook, we can create truly exciting, mindful experiences.