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 };
},
};