Add Support for Reduced Motion in Angular Animations

Animations are good, but it can be overwhelming sometimes. As developers, we need to allow users to take control of animations.

Add Support for Reduced Motion in Angular Animations

Subscribe to my newsletter and never miss my upcoming articles

Listen to this article

In this article, we will understand why reduced motion support is needed. We will also understand the media query and it’s various usages. And at last, we will see how to disable animations in Angular.

If you’re simply interested in the main code, head over to Disable Animations. There we’re going to create a service, which will help us identify user’s preference for reduced motion. Or take a look at code directly.

Animation

Animations can be used in all kinds of web pages. Oftentimes they’re used to provide feedback to the user to indicate that an action is received and being processed. It can be a small animation that happens when a user scrolls or a bouncy animation to showcase a new product. We generally see a slide-from-top animation for banners, and announcements on most of the web pages.

But not everyone likes animations and there are folks with seasickness or vestibular motion disorder. Disabling or reducing animations is an important Accessibility feature. Fortunately, CSS media query prefers-reduced-motion helps developers to serve the users who fall in that category.

prefers-reduced-motion

This query detects whether the user has requested the operating system to minimize the amount of animation or motion it uses.

It can take two values:

no-preference - Indicates that the user has made no preference known to the system. This keyword value evaluates as false in the boolean context.

reduce - Indicates that user has notified the system that they prefer an interface that minimizes the amount of movement or animation, preferably to the point where all non-essential movement is removed.

This media query is still in draft of Media Queries Level 5, but the majority of latest browsers support it today.

Usage with CSS

With CSS, you can simply use it like below:

/*
  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;
  }
}

Another way is to have all of your animations in separate file and load it conditionally via media attribute with link element:

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

Usage with JavaScript

Browsers will handle CSS rules dynamically when preference is changed. But with JavaScript, we will have to listen for changes and programmatically handle the animations.

const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
mediaQuery.addEventListener('change', () => {
  // Stop JavaScript-based animations.
});

Let’s see how the above approach can help us with Angular animations.

Angular Animations

Angular's animation system is built on CSS functionality, which means you can animate any property that the browser considers animatable. If you’re working with Angular animation for the first time, I would recommend you to read the article by @williamjuan27 : In-Depth guide into animations in Angular - Angular inDepth.

A Service to Disable Animations

We are going to create a service, which will have an observable of prefers-reduced-motion media query. And then, we can use it in components to disable the animations.

MediaMatchService

If you’re using Angular CLI, you can quickly create the service using below command:

ng g s core/services/media-match

Let’s modify the content 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 what’s going on with above code:

export const MEDIA_QUERIES: MediaQueriesMap[] = [
  {
    _type: 'prefers-reduced-motion',
    _query: '(prefers-reduced-motion: reduce)',
  },
];

First, we’re defining a constant, which holds an array of media query types and their actual queries.

private _mediaQueryListeners!: {
    [key in MediaQueryType]: BehaviorSubject<boolean>;
  };

Second, we’re creating a JSON object, which will contain a subject for each media query type. It will emit true or false based on MediaQueryList.matches.

public mediaQueryListeners$!: {
    [key in MediaQueryType]: Observable<boolean>;
  };

Third, we are creating JSON of observables, which will be based on subjects we created earlier. The consumers of this service will use this property.

constructor() {
    MEDIA_QUERIES.forEach((mq) => this.matchMedia(mq));
  }

Fourth, we’re building previous two JSON properties by calling a method matchMedia for each set of MEDIA_QUERIES constant.

Now, let’s look at the method matchMedia:

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 takes one argument, mq: MediaQueriesMap, which is a set of media query type (_type) and actual query (_query). First it's updating the existing JSON of subjects by adding a subject for the new _type. And then using the same subject, it is updating JSON of observables for the same _type.

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 updating the subjects and observables, it is time to get the query result. Above code is pretty clear and simple.

Note that we are emitting the result of mediaQueryList.matches even before listening to the change event. Reason behind that is to have the initial value emitted from the service. If we don’t do that and simply listen for changes, it might happen that users have already set reduced as their motion preference when they visit the app, but our app will still continue showing animations.

One advantage of keeping JSON of subjects/observables is that you can add many more media queries to it. For instance, you want to create a media query to check for landscape orientation, you would do like below:

1: Add type in MediaQueryType, so it would become:

export type MediaQueryType = 'prefers-reduced-motion' | 'orientation-landscape';

2: Add actual query in MEDIA_QUERIES:

export const MEDIA_QUERIES: MediaQueriesMap[] = [
  {
    _type: 'prefers-reduced-motion',
    _query: '(prefers-reduced-motion: reduce)',
  },
  {
    _type: 'orientation-landscape',
    _query: '(orientation:landscape)',
  },
];

3: And consume it in your component:

public orientationLandscape$ = this.mediaMatch.mediaQueryListeners$['orientation-landscape'];

App with animations

To enable animations in angular, we need to import BrowserAnimationsModule in the root module.

I have created an app, which has a basic UI, and setup with animations and services. You can checkout the code on GitHub Repo.

Let’s look at the output of app with animations:

output of animated app

App with disabled animations

To disable the animations, first we’re going to consume the service 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
  ) {}

// ...
}

And then, a special animation control binding called @.disabled can be placed on an HTML element to disable animations on that element, as well as any nested elements. When true, the @.disabled binding prevents all animations from rendering.

<!-- src/app/app.component.html -->

<div class="container" [@.disabled]="disableAnimations$ | async">
  <!-- rest remains same -->
</div>

Let’s see now how our app looks when rendered with reduced motion:

output of app with disabled animations

As you would see, now it doesn’t render the app with animations.

NoopAnimationsModule

We can also use NoopAnimationsModule to disable angular animations for particular modules. Simply import it instead of BrowserAnimationsModule.

But prefers-reduced-motion provides much more flexibility, because it allows to load animations based on user preferences very easily.

Controlling animations from UI

If you don’t want to use prefers-reduced-motion query, you could integrate some controls, like switch or checkbox, which will allow users to disable or enable animations runtime.

Netlify’s Netlify Reaches One Million Devs! Website is a nice example of this approach, they’ve added a switch on the top-left side to control animations.

A control switch to disable animations on Netlify Reaches One Million Devs!

But, disabling animations through media query is recommended over this, because it’s more accessible and saves users from performing extra actions.

Angular v12

Angular team is working on bringing a feature to add support for disabling animations through BrowserAnimationsModule.withConfig. This is already available 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 {}

Conclusion

We saw the usages of prefers-reduced-motion media query, it’s importance and how it helped us to disable animations in angular. We also saw a couple of other approaches to disable them, but media query is recommended over them.

And in v12, you can simply use BrowserAnimationsModule.withConfig to disable animations.

I have created a GitHub repo for all of the code we did above.

Further Reading


Originally published on indepth.dev

Interested in reading more such articles from Dharmen Shah?

Support the author by donating an amount of your choice.

 
Share this