Why Motion Preferences Matter
Animations are a common sight across modern web interfaces. They serve purposes like confirming user actions, signaling processing, or simply adding visual flair during scrolling or content reveals. Many sites employ slide-from-top effects for banners and announcements. However, animations aren't universally appreciated. Some users experience discomfort, such as seasickness or vestibular motion disorders, when confronted with excessive movement. Reducing or eliminating animations is a crucial accessibility feature. The CSS media query prefers-reduced-motion is a valuable tool for developers aiming to accommodate users with such preferences.
Understanding prefers-reduced-motion
The prefers-reduced-motion media query is designed to detect whether a user has requested their operating system to minimize the amount of animation or motion used in interfaces.
This media query supports two primary values:
no-preference – This signifies that the user has not indicated any specific preference to the system. In a boolean context, this value evaluates to false.
reduce – This confirms that the user has informed the system of their desire for an interface with minimized movement or animation, ideally eliminating all non-essential motion.
While this media query is still within the working draft of Media Queries Level 5, the majority of modern browsers already provide support for it.
Integrating with CSS
Incorporating this preference in CSS is straightforward. You can wrap your animations within the media query as shown:
/*
If the user has expressed their preference for
reduced motion, then don't use animations on loader.
*/
@media (prefers-reduced-motion: reduce) {
.loader {
animation: none;
}
}
/*
If the browser understands the media query and the user
explicitly hasn't set a preference, then use animations on loaders.
*/
@media (prefers-reduced-motion: no-preference) {
.loader {
/* `spin` keyframes are defined elsewhere */
animation: spin 0.5s linear infinite both;
}
}
An alternative is to store all animations in a separate stylesheet and load it conditionally using the media attribute on a link element:
<link rel="stylesheet" href="animations.css" media="(prefers-reduced-motion: no-preference)">
Implementing with JavaScript
Browsers automatically update CSS rules when a user changes their motion preference. However, with JavaScript, you need to actively listen for these changes and manage animations programmatically.
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
mediaQuery.addEventListener('change', () => {
console.log(mediaQuery.media, mediaQuery.matches);
// Stop JavaScript-based animations.
});
Now, let's explore how this approach applies to Angular animations.
Working with Angular Animations
Angular's animation system leverages CSS's capabilities, allowing you to animate any property the browser deems animatable. For those new to Angular animations, a great starting point is the article by @williamjuan27: In-Depth guide into animations in Angular – Angular inDepth.
Creating a Service to Manage Animations
Our goal is to build a service that exposes an observable based on the prefers-reduced-motion media query. This observable can then be used in components to control animation behavior.
MediaMatchService
If you're using Angular CLI, you can quickly scaffold the service with this command:
ng g s core/services/media-match
Let's refine the contents of media-match.service.ts:
// src/app/core/services/media-match.service.ts
import { Injectable } from '@angular/core';
import { BehaviorSubject, Observable } from 'rxjs';
// types
export interface MediaQueriesMap {
_type: MediaQueryType;
_query: string;
}
export type MediaQueryType = 'prefers-reduced-motion';
// media queries list
export const MEDIA_QUERIES: MediaQueriesMap[] = [
{
_type: 'prefers-reduced-motion',
_query: '(prefers-reduced-motion: reduce)',
},
];
@Injectable({ providedIn: 'root' })
export class MediaMatchService {
private _mediaQueryListeners!: {
[key in MediaQueryType]: BehaviorSubject<boolean>;
};
public mediaQueryListeners$!: {
[key in MediaQueryType]: Observable<boolean>;
};
constructor() {
MEDIA_QUERIES.forEach((mq) => this.matchMedia(mq));
}
private matchMedia(mq: MediaQueriesMap) {
this._mediaQueryListeners = {
...this._mediaQueryListeners,
[mq._type]: new BehaviorSubject<boolean>(false),
};
this.mediaQueryListeners$ = {
...this.mediaQueryListeners$,
[mq._type]: this._mediaQueryListeners[mq._type].asObservable(),
};
const mediaQueryList = window.matchMedia(mq._query);
this._mediaQueryListeners[mq._type].next(mediaQueryList.matches);
mediaQueryList.addEventListener('change', (ev: MediaQueryListEvent) => {
this._mediaQueryListeners[mq._type].next(ev.matches);
});
}
}
Here’s a breakdown of the logic in the above code:
export const MEDIA_QUERIES: MediaQueriesMap[] = [
{
_type: 'prefers-reduced-motion',
_query: '(prefers-reduced-motion: reduce)',
},
];
First, we establish a constant that defines an array of media query types and their respective query strings.
private _mediaQueryListeners!: {
[key in MediaQueryType]: BehaviorSubject<boolean>;
};
Next, we initialize an object to hold a collection of subjects. Each subject corresponds to a media query type and will emit either true or false based on the MediaQueryList.matches property.
public mediaQueryListeners$!: {
[key in MediaQueryType]: Observable<boolean>;
};
We then create a map of observables derived from those subjects. This public property is what consumers of the service will subscribe to.
constructor() {
MEDIA_QUERIES.forEach((mq) => this.matchMedia(mq));
}
Finally, we populate these two JSON objects by invoking the matchMedia method for each entry in the MEDIA_QUERIES constant.
Let's examine the matchMedia method more closely:
private matchMedia(mq: MediaQueriesMap) {
this._mediaQueryListeners = {
...this._mediaQueryListeners,
[mq._type]: new BehaviorSubject<boolean>(false),
};
this.mediaQueryListeners$ = {
...this.mediaQueryListeners$,
[mq._type]: this._mediaQueryListeners[mq._type].asObservable(),
};
}
This method accepts one parameter, mq: MediaQueriesMap, which contains a media query type (_type) and the actual query string (_query). The process begins by updating our subjects collection with a new subject for the given _type. It then uses that subject to set up an observable for the same _type in our observables collection.
private matchMedia(mq: MediaQueriesMap) {
// ...
const mediaQueryList = window.matchMedia(mq._query);
this._mediaQueryListeners[mq._type].next(mediaQueryList.matches);
mediaQueryList.addEventListener('change', (ev: MediaQueryListEvent) => {
this._mediaQueryListeners[mq._type].next(ev.matches);
});
}
After setting up the necessary subjects and observables, we query the current state. The code here is clean and straightforward.
Note that we emit the value of mediaQueryList.matches before subscribing to the change event. This is essential to ensure an initial value is emitted. Without this, a user who has already set their preference to reduce before visiting the app might still see animations, since we'd only be listening for future changes.
The advantage of maintaining JSON objects for subjects and observables is the easy extensibility. For instance, to add a media query for landscape orientation, you would do the following:
- Update the
MediaQueryTypetype to include the new type:
export type MediaQueryType = 'prefers-reduced-motion' | 'orientation-landscape';
2. Add the actual query to the MEDIA_QUERIES constant:
export const MEDIA_QUERIES: MediaQueriesMap[] = [
{
_type: 'prefers-reduced-motion',
_query: '(prefers-reduced-motion: reduce)',
},
{
_type: 'orientation-landscape',
_query: '(orientation:landscape)',
},
];
3. Then, consume it within your component:
public orientationLandscape$ = this.mediaMatch.mediaQueryListeners$['orientation-landscape'];
An Example App with Animations
To leverage animations in Angular, you need to import the BrowserAnimationsModule into your root module.
I've assembled a sample app with a basic interface, pre-configured with animations and services. The source code is available on the GitHub repository.
Let's start by looking at the app's behavior with animations enabled:
App output with animations active
Disabling Animations in the App
To turn off animations, we first inject and use the MediaMatchService in src/app/app.component.ts:
// src/app/app.component.ts
// ...
export class AppComponent implements OnInit, OnDestroy {
//...
disableAnimations$ = this.mediaMatch.mediaQueryListeners$['prefers-reduced-motion'];
constructor(
private mediaMatch: MediaMatchService
) {}
// ...
}
Next, we employ the special animation control binding @.disabled on an HTML element. Placing this binding disables animations for that element and all its children. When set to true, the @.disabled binding blocks the rendering of all animations.
Let's add this to src/app/app.component.html:
<!-- src/app/app.component.html -->
<div class="container" [@.disabled]="disableAnimations$ | async">
<!-- rest remains same -->
</div>
Let's check how the app appears when the reduce motion preference is active:

App with animations disabled under reduced motion
As observed, the app no longer triggers animations in this scenario.
Alternative with NoopAnimationsModule
Another method to disable animations for specific modules is using the NoopAnimationsModule. You'd simply import this module in place of BrowserAnimationsModule.
However, the prefers-reduced-motion query offers more flexibility since it allows animations to be loaded based on user preferences with ease, rather than a blanket module switch.
User-Controlled Animations via UI
If the prefers-reduced-motion query isn't your preference, you can provide users with a UI control, such as a switch or checkbox, to toggle animations on and off at runtime.
A notable example of this is the Netlify Reaches One Million Devs! website. It features a switch in the top-left corner to let visitors control the animations.
Still, leveraging the media query is generally the better approach. It's inherently more accessible and spares users from taking manual steps to achieve a comfortable viewing experience.
Angular v12 Update
The Angular team is adding a feature to support disabling animations directly through BrowserAnimationsModule.withConfig. This capability is already present in v12.0.0-next.3:
import { NgModule } from "@angular/core";
import { BrowserAnimationsModule } from "@angular/platform-browser/animations";
import { AppComponent } from "./app.component";
export function prefersReducedMotion(): boolean {
const mediaQueryList = window.matchMedia("(prefers-reduced-motion)");
return mediaQueryList.matches;
}
@NgModule({
imports: [
BrowserAnimationsModule.withConfig({
disableAnimations: prefersReducedMotion()
})
],
declarations: [AppComponent],
bootstrap: [AppComponent]
})
export class AppModule {}
Wrapping Up
We've delved into the various uses of the prefers-reduced-motion media query, its significance, and its role in disabling animations within Angular applications. We also touched on alternative methods for deactivating animations, but the media query remains the recommended choice.
And with Angular v12, you can effortlessly disable animations using BrowserAnimationsModule.withConfig.
For reference, all the code discussed is available in the GitHub repository.
Further Reading
