Dec 19

Preference Queries

Some CSS media queries can detect user preferences. We'll review the two best-supported types for motion and color settings.

By Stephanie Eckles

You may be familiar with media queries to detect a user's viewport resolution, like the following:

@media (min-width: 60rem) {
/* Styles for viewports larger than 60rem */
}

But did you know that @media - also known as one of the CSS at-rules - contains 24 total features to query against? Review the whole list of @media features on MDN, and check the browser support table (about 19 have decent cross-browser support).

The two we'll be covering have to do with user preferences - as in, features that are determined by operating system settings.

About prefers-reduced-motion

As MDN states, this preference query "is used to detect if the user has requested that the system minimize the amount of non-essential motion it uses."

I've been a developer for nearly 15 years, and over that time I've seen a lot of trends. One with a significant amount of staying power was parallax animation, thanks to being boosted by big brands like Nike and Apple.

Parallax animation is a good example of a harmful type of motion for users with vestibular disorders. This is due to the foreground and background moving at different rates, which for some users can cause dizziness, nausea, and headaches. As Val Head notes in Designing Safer Web Animation for Motion Sensitivities, the negative effects for some users can last long after they've seen the animation.

With the use of prefers-reduced-motion, we can take steps towards reducing the impact of potentially harmful animations.

Using prefers-reduced-motion

There are two keywords for determining the state of a user's setting related to reduced motion that is surfaced via prefers-reduced-motion:

And here's an example of setting both types. You can adjust this setting via your OS, or in Chromium dev tools it can be emulated under the "Rendering" tab.

Demo of using prefers-reduced-motion
@media (prefers-reduced-motion: no-preference) {
.shake:hover {
animation: shake 400ms ease-in-out;
}
}

@media (prefers-reduced-motion: reduce) {
.shake {
/* !important due to overriding this site's rules to
remove transitions when meeting this condition */

transition: transform 180ms ease-in !important;
}

.shake:hover {
transform: translateX(.25rem);
}
}

@keyframes shake {
0%, 100% {
transform: translateX(0.5rem);
}
50% {
transform: translateX(-0.5rem);
}
}

.shake {
font-size: 1.35rem;
font-weight: bold;
}

Shake on hover if motion allowed

If you are using many animations, you can instead place them all in their own stylesheet and conditionally load it in via the media attribute on the link element:

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

Or, if you have only some minimal animations or transitions, you can borrow a technique popularized from Andy Bell's modern CSS reset. This fast-forwards animations and transitions when a user prefers reduced motion.

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

Why not completely remove them? Because there are times when a script may be listening for the transitionend or animationend events so it's safer to allow an imperceptible duration.

Handling prefers-reduced-motion with JavaScript

There is also a JavaScript way to detect the value of prefers-reduced-motion, which is beneficial if your animations are JS controlled.

In the following snippet, we're checking if prefers-reduced-motion: reduce matches. If it does match, then prefersReducedMotion will evaluate to true meaning the user prefers reduced motion.

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

We can then extend that to listen in case that preference changes so we can adapt the experience accordingly.

motionMQ.addEventListener('change', handleMotionChange);

In this demo, the text will be set according to your preference and update if you toggle your motion setting.

Detecting prefers-reduced-motion in JS
.mq-element { font-size: 1.35rem; }
const motionMQ = window.matchMedia('(prefers-reduced-motion: reduce)');

const updateMQ = (reduced) => {
const element = document.querySelector('.mq-element');
if (reduced) {
element.textContent = 'reduce';
} else {
element.textContent = 'no preference';
}
};

// Update on load
updateMQ(motionMQ.matches);

// Update on change
motionMQ.addEventListener('change', () => {
updateMQ(motionMQ.matches);
});

...

Additional resources on prefers-reduced-motion

Using prefers-color-scheme

The second preference query you may have heard of since it is associated with providing a dark mode version of your site.

The prefers-color-scheme media feature does in fact detect whether a user has requested a dark or light theme.

While this often gets bundled up by developers into a face-off similar to tabs vs. spaces, the matter of dark or light has a lot to do with accessibility. For example, I myself preferred a light theme for years until I learned I had astigmatism which was making dark themes uncomfortable due to the appearance of "halos" around the letters.

The options for this query are fairly self-explanatory:

Keep in mind that these are not expected to be as polar as white vs. black, but more about increasing the use of light or dark colors particularly for large areas of text.

A nifty way to manage your themes is by using CSS custom properties (covered in Day 3). By choosing general-purpose names for your custom property colors and using them carefully throughout your stylesheets, you can flip their values to retheme your site when either dark or light is requested.

:root {
--theme-background: white;
--theme-text: #222;
}

@media (prefers-color-scheme: dark) {
:root {
--theme-background: #222;
--theme-text: white;
}
}

body {
background-color: var(--theme-background);
color: var(--theme-text);
}

The following demo shows a minimal example of this idea in action. Once again you can change your user settings or test this via dev tools as noted for the previous section.

Demo of theming with prefers-color-scheme
.site {
--theme: "Light";
--theme-background: lightblue;
--theme-text: #222;
--theme-inverse: #f9f9f9;
--theme-primary: blue;

background-color: var(--theme-background);
color: var(--theme-text);
padding: 1rem;
}

@media (prefers-color-scheme: dark) {
.site {
--theme: "Dark";
--theme-background: #222;
--theme-text: #f9f9f9;
--theme-inverse: #444;
--theme-primary: cyan;
}
}

.site article {
width: min(40ch, 100%);
margin: 0 auto;
padding: 1rem;
background-color: var(--theme-inverse);
}

.site article * {
margin: 0;
}

.site article * + * {
margin-top: 1em;
}

.site article h2::after {
content: " " var(--theme);
}

.site a:not([class]) {
color: var(--theme-primary);
}

.site-btn {
display: inline-block;
text-decoration: none;
background-color: var(--theme-primary);
color: var(--theme-inverse);
padding: 0.5em 0.75em;
border-radius: 0.25em;
}

Theme Demo

Lorem ipsum dolor, sit amet consectetur adipisicing elit. Exercitationem amet magnam corrupti!

Magnam, a. Delectus consectetur hic at? Consequuntur maxime hic repellat numquam culpa.

Button

Handling prefers-color-scheme with JavaScript

We can detect and use the prefers-color-scheme value in a nearly identical format to prefers-reduced-motion.

This demo also includes a different option for managing color themes via CSS custom properties.

Detecting prefers-color-scheme in JS
.theme-element {
background: var(--background, white);
color: var(--color, black);
border: 1px solid;
padding: 2rem;
font-size: 1.35rem;
}

@media (prefers-color-scheme: dark) {
.theme-element {
--background: #222;
--color: white;
}
}
const themeMQ = window.matchMedia('(prefers-color-scheme: dark)');

const updateTheme = (dark) => {
const element = document.querySelector('.theme-element');
if (dark) {
element.textContent = 'dark theme';
} else {
element.textContent = 'light theme';
}
};

// Update on load
updateTheme(themeMQ.matches);

// Update on change
themeMQ.addEventListener('change', () => {
updateTheme(themeMQ.matches);
});

...

Additional resources on prefers-color-scheme

A few posts on setting up dark and light themes: