Good vs Great Animations

Accessibility

The interactive examples and code snippets in this article are crucial to understanding the concepts like in the last lesson, so this one is text-based as well, but there is an exercise with a video for the solution.

Good vs Great Animations Accessibility

Overview

The interactive examples and code snippets in this article are crucial to understanding the concepts like in the last lesson, so this one is text-based as well, but there is an exercise with a video for the solution.

Animations are used to strategically improve an experience. To some people, animations actually degrade the experience. Animations can make people feel sick or get distracted. That’s not the experience we want to build. To prevent degrading the experience, our animations need to account for people who don’t want animations.

Most devices today allow users to convey their preference for animations.

You can read this preference in browsers using the prefers-reduced-motion CSS media query.

  • prefers-reduced-motion: no-preference means the user has not set a preference. No changes to your animation needed.
  • prefers-reduced-motion: reduce means the user has set a preference. Your animation should be altered.

When that preference is present, we should consider removing, reducing or replacing animations.

Workflow

Here’s a workflow for you to use when building animations:

  • Build the animation.
  • Make changes to account for prefers-reduced-motion. You can test prefers-reduced-motion being on with the browser’s DevTools.
  • Ship the animation with 2 variants: 1 for prefers-reduced-motion: no-preference and 1 for prefers-reduced-motion: reduce.

How should animations be changed?

To prevent people from feeling sick or getting distracted, our animations need to be changed based on these general guidelines:

  • Disable autoplaying animations.
  • Only animate properties like opacity, color, background-color. Avoid animating properties like transform that adds motion or changes layout, ensure that no elements move.

reduced-motion does not mean no animations. Remember, animations are used to make our UIs easier to understand. Disabling animations all together would reduce the understandability of our UIs. Animations should help convey meaningful information to the user.

Here’s a modal that opens with a scale animation. Everytime you click the button, a fake prefers-reduced-motion media query is toggled and the animation is adjusted.

How to implement it in code?

We can use a media query in CSS to adjust the animation based on the user’s preference:

.element {
  animation: bounce 0.2s;
}

@media (prefers-reduced-motion: reduce) {
  .element {
    animation: fade 0.2s;
  }
}
.element {
  animation: bounce 0.2s;
}

@media (prefers-reduced-motion: reduce) {
  .element {
    animation: fade 0.2s;
  }
}

Tailwind provides a utility class for this media query:

<button type="button">

  <svg class="motion-safe:animate-bounce motion-reduce:animate-fade" viewBox="0 0 24 24">
    <!-- ... -->
  </svg>
  Loading
</button>
<button type="button">

  <svg class="motion-safe:animate-bounce motion-reduce:animate-fade" viewBox="0 0 24 24">
    <!-- ... -->
  </svg>
  Loading
</button>

Framer Motion gives us a useReducedMotion hook that returns true if the user prefers reduced motion. We can use this hook to adjust our animations.

You can read more about this implementation here.

import { useReducedMotion, motion } from "motion/react"

export function Sidebar({ isOpen }) {
  const shouldReduceMotion = useReducedMotion();
  const closedX = shouldReduceMotion ? 0 : "-100%";

  return (
    <motion.div animate={{
      opacity: isOpen ? 1 : 0,
      x: isOpen ? 0 : closedX
    }} />
  )
}
import { useReducedMotion, motion } from "motion/react"

export function Sidebar({ isOpen }) {
  const shouldReduceMotion = useReducedMotion();
  const closedX = shouldReduceMotion ? 0 : "-100%";

  return (
    <motion.div animate={{
      opacity: isOpen ? 1 : 0,
      x: isOpen ? 0 : closedX
    }} />
  )
}

You can also use this hook without Framer Motion, this is how a dependency-free version looks like:

import { useState, useRef, useEffect } from "react";

export function useReducedMotion(): boolean {
  const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);

  const { current: mediaQuery } = useRef(
    window ? window.matchMedia("(prefers-reduced-motion: reduce)") : null
  );

  useEffect(() => {
    const listener = () => {
      setPrefersReducedMotion(!!mediaQuery.matches);
    };
    mediaQuery.addEventListener("change", listener);
    return () => {
      mediaQuery.removeEventListener("change", listener);
    };
  }, [mediaQuery]);

  return prefersReducedMotion;
}
import { useState, useRef, useEffect } from "react";

export function useReducedMotion(): boolean {
  const [prefersReducedMotion, setPrefersReducedMotion] = useState(false);

  const { current: mediaQuery } = useRef(
    window ? window.matchMedia("(prefers-reduced-motion: reduce)") : null
  );

  useEffect(() => {
    const listener = () => {
      setPrefersReducedMotion(!!mediaQuery.matches);
    };
    mediaQuery.addEventListener("change", listener);
    return () => {
      mediaQuery.removeEventListener("change", listener);
    };
  }, [mediaQuery]);

  return prefersReducedMotion;
}

We’ve talked about MotionConfig before, it’s also useful for respecting the user’s preference for reduced motion. When the reducedMotion prop is set to user, Framer Motion will respect the user’s preference and animate opacity and backgroundColor only. The default here is never so you need to set it yourself.

import { MotionConfig } from "motion/react";

// ...

<MotionConfig reducedMotion="user">{children}</MotionConfig>
import { MotionConfig } from "motion/react";

// ...

<MotionConfig reducedMotion="user">{children}</MotionConfig>

This could be a wrapper around your whole application, that way you don’t have to remember to adjust the animation for reduced motion every time.

Mental model for Visuals

Anything that looks like an image or video. These are often used to add a visual metaphor for concepts. Like this visual we’ve seen before:

Here we could jump to each frame, instead of animating between them. We can’t remove the animation altogether, as it’s essential to understanding the concept.

Snippets

Here are a few snippets that can help you make your UI more accessible.

Disable smooth scrolling

When no preference, enables smooth scrolling on the document, when reduced-motion disables smooth scrolling.

@media (prefers-reduced-motion: no-preference) {
  html {
    scroll-behavior: smooth;
  }
}
@media (prefers-reduced-motion: no-preference) {
  html {
    scroll-behavior: smooth;
  }
}

Autoplaying images

When no preference, shows a looping animated image, when reduced-motion shows a static fallback.

<picture>
  <!-- Animated versions -->
  <source
    srcset="animated.avifs"
    type="image/avif"
    media="(prefers-reduced-motion: no-preference)"
  />
  <source
    srcset="animated.gif"
    type="image/gif"
    media="(prefers-reduced-motion: no-preference)"
  />
  <!-- Static versions -->
  <img src="static.png" />
</picture>
<picture>
  <!-- Animated versions -->
  <source
    srcset="animated.avifs"
    type="image/avif"
    media="(prefers-reduced-motion: no-preference)"
  />
  <source
    srcset="animated.gif"
    type="image/gif"
    media="(prefers-reduced-motion: no-preference)"
  />
  <!-- Static versions -->
  <img src="static.png" />
</picture>

Autoplaying videos

When no preference, autoplays the video, when reduced-motion shows the video paused and with controls to manually start the video. This is a vanilla version, but it can be easily adapted to any framework.

<figure>
  <div>
    <video controls muted loop>
      <source src="video.mp4" type="video/mp4">
    </video>
    <button type="button" data-btn aria-live="polite">Play</button>
  </div>
</figure
<figure>
  <div>
    <video controls muted loop>
      <source src="video.mp4" type="video/mp4">
    </video>
    <button type="button" data-btn aria-live="polite">Play</button>
  </div>
</figure
// Query the DOM for the button and video elements using their data attributes
const btn = document.querySelector('button');
const video = document.querySelector('video');

// Check if the user has a preference for reduced motion
const noMotionPreference = window.matchMedia('(prefers-reduced-motion: no-preference)');

/**
 * Initializes the video player by configuring UI elements and autoplay settings.
 */
const initVideo = () => {
  // Remove the default video controls to allow for custom control implementation
  video.removeAttribute('controls');

  // Make the custom play button visible
  btn.hidden = false;

  // Enable autoplay if the user has not expressed a preference for reduced motion
  if (noMotionPreference.matches) {
    video.setAttribute('autoplay', true);
    btn.innerText = 'Pause'; // Set the button text to "Pause" as the video will start playing
  }
};

// Set up an event listener for the play button to control video playback
btn.addEventListener('click', () => {
  // Check if the video is currently paused
  if (video.paused) {
    video.play();           // Play the video
    btn.innerText = 'Pause'; // Update the button text to "Pause"
  } else {
    video.pause();           // Pause the video
    btn.innerText = 'Play';  // Update the button text to "Play"
  }
});

// Call the function to initialize the video setup
initVideo();
// Query the DOM for the button and video elements using their data attributes
const btn = document.querySelector('button');
const video = document.querySelector('video');

// Check if the user has a preference for reduced motion
const noMotionPreference = window.matchMedia('(prefers-reduced-motion: no-preference)');

/**
 * Initializes the video player by configuring UI elements and autoplay settings.
 */
const initVideo = () => {
  // Remove the default video controls to allow for custom control implementation
  video.removeAttribute('controls');

  // Make the custom play button visible
  btn.hidden = false;

  // Enable autoplay if the user has not expressed a preference for reduced motion
  if (noMotionPreference.matches) {
    video.setAttribute('autoplay', true);
    btn.innerText = 'Pause'; // Set the button text to "Pause" as the video will start playing
  }
};

// Set up an event listener for the play button to control video playback
btn.addEventListener('click', () => {
  // Check if the video is currently paused
  if (video.paused) {
    video.play();           // Play the video
    btn.innerText = 'Pause'; // Update the button text to "Pause"
  } else {
    video.pause();           // Pause the video
    btn.innerText = 'Play';  // Update the button text to "Play"
  }
});

// Call the function to initialize the video setup
initVideo();

Providing a “hero” for looping animations

.animation {
  animation: shake 0.2s infinite;
}

@media (prefers-reduced-motion: reduce) {
	.animation {
    animation-play-state: paused;
  	/* Pauses the animation on the frame at 0.4s Try different values and see which frame looks the best. */
    animation-delay: -0.4s;
	}
}
.animation {
  animation: shake 0.2s infinite;
}

@media (prefers-reduced-motion: reduce) {
	.animation {
    animation-play-state: paused;
  	/* Pauses the animation on the frame at 0.4s Try different values and see which frame looks the best. */
    animation-delay: -0.4s;
	}
}

Vercel does that on their rendering page. When the user prefers reduced motion, the animation is paused on a frame from the animation.

Exercise

Our multi-step component that we built earlier doesn’t respect the user’s preference for reduced motion. Let’s fix that. When the user prefers reduced motion, we should only animate the opacity of the steps.

import { useMemo, useState } from "react";
import { AnimatePresence, motion, MotionConfig } from "motion/react";
import useMeasure from "react-use-measure";
import "./styles.css";

export default function MultiStepComponent() {
const [currentStep, setCurrentStep] = useState(0);
const [direction, setDirection] = useState();
const [ref, bounds] = useMeasure();

const content = useMemo(() => {
  switch (currentStep) {
    case 0:
      return (
        <>
          <h2 className="heading">This is step one</h2>
          <p>
            Usually in this step we would explain why this thing exists and
            what it does. Also, we would show a button to go to the next step.
          </p>
          <div className="skeletons">
            <div className="skeleton" style={{ width: 256 }} />
            <div className="skeleton" style={{ width: 192 }} />
            <div className="skeleton" />
            <div className="skeleton" style={{ width: 384 }} />
          </div>
        </>
      );
    case 1:
      return (
        <>
          <h2 className="heading">This is step two</h2>
          <p>
            Usually in this step we would explain why this thing exists and
            what it does. Also, we would show a button to go to the next step.
          </p>
          <div className="skeletons">
            <div className="skeleton" style={{ width: 256 }} />
            <div className="skeleton" style={{ width: 192 }} />
            <div className="skeleton" style={{ width: 384 }} />
          </div>
        </>
      );
    case 2:
      return (
        <>
          <h2 className="heading">This is step three</h2>
          <p>
            Usually in this step we would explain why this thing exists and
            what it does. Also, we would show a button to go to the next step.
          </p>
          <div className="skeletons">
            <div className="skeleton" style={{ width: 256 }} />
            <div className="skeleton" style={{ width: 192 }} />
            <div className="skeleton" style={{ width: 128 }} />
            <div className="skeleton" style={{ width: 224 }} />
            <div className="skeleton" style={{ width: 384 }} />
          </div>
        </>
      );
  }
}, [currentStep]);

return (
  <MotionConfig transition={{ duration: 0.5, type: "spring", bounce: 0 }}>
    <motion.div
      animate={{ height: bounds.height }}
      className="multi-step-wrapper"
    >
      <div className="multi-step-inner" ref={ref}>
        <AnimatePresence mode="popLayout" initial={false} custom={direction}>
          <motion.div
            key={currentStep}
            variants={variants}
            initial="initial"
            animate="active"
            exit="exit"
            custom={direction}
          >
            {content}
          </motion.div>
        </AnimatePresence>
        <motion.div layout className="actions">
          <button
            className="secondary-button"
            disabled={currentStep === 0}
            onClick={() => {
              if (currentStep === 0) {
                return;
              }
              setDirection(-1);
              setCurrentStep((prev) => prev - 1);
            }}
          >
            Back
          </button>
          <button
            className="primary-button"
            disabled={currentStep === 2}
            onClick={() => {
              if (currentStep === 2) {
                setCurrentStep(0);
                setDirection(-1);
                return;
              }
              setDirection(1);
              setCurrentStep((prev) => prev + 1);
            }}
          >
            Continue
          </button>
        </motion.div>
      </div>
    </motion.div>
  </MotionConfig>
);
}

const variants = {
initial: (direction) => {
  return { x: `${110 * direction}%`, opacity: 0 };
},
active: { x: "0%", opacity: 1 },
exit: (direction) => {
  return { x: `${-110 * direction}%`, opacity: 0 };
},
};