Exploring prefers-reduced-motion

August 13th, 2019. Tagged: CSS, JavaScript

Animations and transitions on the web are cool and all, they can make the UI feel snappier and responsive (if used judiciously). However there are problems with motion like this. A whole lot of people are sensitive to motion and you don't want your site to cause motion sickness and dizziness, right?

Luckily, most modern browsers now support prefers-reduced-motion CSS media query. Which means you can skip animations for people who don't want them.

An example of supporting opt-out in CSS:

@media (prefers-reduced-motion: reduce) {
  .widget {
    animation: none;
  }
}

Or opt-in:

@media (prefers-reduced-motion: no-preference) {
  .widget {
    animation: 3s slidein;
  }
}

To test on a Mac, search for "accessibility" to find the preference panel, click Display and check the Reduce motion box (see here for other operating systems).

Well, how about taking care of accessibility and performance? How about putting all the animations and transitions and keyframes CSS code in a separate file and loading it only if people don't mind animations? Wouldn't it be nice to save some bytes? And how? Easy.

You can detect the preference with JavaScript too, using:

const pref = 
  window.matchMedia(
    '(prefers-reduced-motion: no-preference)'
  );

Now pref.matches is true if it's ok to use motion and you can load that extra CSS file that has all the animations and transitions.

So what about legacy browsers without this media query? My vote is: no animations. But if yours is: yes, animations, you can detect if the browser even understands the media query. matchMedia returns an object that contains the media too. Try this in your console:

>> window.matchMedia('(prefers-reduced-motion: no-preference)')
MediaQueryList { 
  media: "(prefers-reduced-motion: no-preference)", 
  matches: false, 
  onchange: null }

If the browser doesn't understand the query it will return "not all" in the media property.

>> window.matchMedia('(omg: bacon)')
MediaQueryList { 
  media: "not all", 
  matches: false, 
  onchange: null }

To wrap it up, I'd do something like:

<link href="css.css" type="text/css" rel="stylesheet">
<script>
if (window.matchMedia('(prefers-reduced-motion: no-preference)').matches) {
  const link = document.createElement('link'); 
  link.type = "text/css";
  link.rel = "stylesheet"
  link.href = "animations.css";
  document.head.appendChild(link);  
}
</script>

But if you want to be sure that old browsers do get animations, you can do:

<link href="css.css" type="text/css" rel="stylesheet">
<script>
const media = "(prefers-reduced-motion: reduce)";
const pref = window.matchMedia(media);
if (pref.media !== media && !pref.matches) {
  const link = document.createElement('link'); 
  link.type = "text/css";
  link.rel = "stylesheet"
  link.href = "animations.css";
  document.head.appendChild(link);  
}
</script>

C'est tout! See you next time!

Update

Thanks to Thomas Steiner's tweet, here's a no-js solution:

<link 
  rel="stylesheet" 
  href="animations.css" 
  media="(prefers-reduced-motion: no-preference)">

Browsers that do not understand the media query should still load the stylesheet.

Tell your friends about this post on Facebook and Twitter

Sorry, comments disabled and hidden due to excessive spam.

Meanwhile, hit me up on twitter @stoyanstefanov