cover of topic

Advanced animation in Angular: Part 2. Disable any animations by prefers-reduced-motion

Using prefers-reduced-motion to manage effectively CSS animations and Angular animations like a Pro

Maksim Dolgikh
ITNEXT
Published in
7 min readJan 5, 2024

--

Previous part

Introduction

Animations today are an integral part of modern web applications. Animations help us to improve the perception of our application to the users by giving them feedback about their actions, which helps to make the user experience pleasant and memorable.

But there are cases when we should not use animations:

  • Users with restrictions. For them, animation is not an embellishment, but a distracting part that may be less accessible and interfere with the perception
  • E2E tests. It takes time to wait for each animation and transition. The more tests there are, the more time is spent waiting for them to be completed

For these 2 cases, you can’t build a separate application without animations, you need to have an opportunity to disable animations.

And there is an easy way to do this.

Prefers-reduced-motion

The prefers-reduced-motion CSS media feature is used to detect if a user has enabled a setting on their device to minimize the amount of non-essential motion. The setting is used to convey to the browser on the device that the user prefers an interface that removes, reduces, or replaces motion-based animations. ( You can find ways to activate it here )

Since this is an @media setting, all code can be handled in the @media block

@media (prefers-reduced-motion) {
transition: all 0 linear;
/* others styles */
}

Knowing about this customization, we just need to learn how to handle it properly in the Angular application.

Example application

First, I suggest implementing 2 elements with fade animation in two ways:

  • Angular animation
const leavedStyle = style({opacity: 0});
const enteredStyle = style({opacity: 1});

export const fade = trigger('fade', [
transition(
':enter',
[leavedStyle, animate('300ms linear', enteredStyle)],
),
transition(
':leave',
[enteredStyle, animate('300ms linear', leavedStyle)],
),
]);
<app-item @fade *ngIf="state" class="item">
Angular animation item
</app-item>
  • CSS animation
  .show {
opacity: 1;
}

.hide {
opacity: 0;
}

.css-animation {
transition: opacity 300ms linear;
}
<app-item class="css-animation item" [ngClass]="state ? 'show' : 'hide'">
Css animation item
</app-item>

And let’s also add a style indicator for each element. This would help us visually tell if the prefers-reduced-motion setting is turned on on the device

.item {
background: #ffa6a6; // red

@media (prefers-reduced-motion: reduce) {
background: #8eff8a; // green
}
}

Let’s run it and check it out

current showcase for Angular animation and CSS animation
The default behaviour for Angular and CSS animations

With prefers-reduced-motion enabled, the display works, but so far this option does not affect the animation

current showcase for Angular animation and CSS animation with prefer-reduce-motion
The default behaviour for Angular and CSS animations with prefers-reduced-motion

CSS animation и prefers-reduced-motion

Let’s start with the easy part.

As it’s shown in the example above, style management with prefers-reduced-motion is done via the standard @media block.

We can combine styles and selectors with this block in various ways to get a solution. But in my opinion, the most elegant and simple solution is to control it via a global CSS variable.

To do this, we need to add a CSS variable with the animation time to styles.scss, and also specify a new animation time for prefers-reduced-motion:reduce

:root {
--animation-duration: 300ms;
}

@media (prefers-reduced-motion: reduce) {
:root {
--animation-duration: 0ms;
}
}

Also, let’s change our past animation implementation, which now takes the CSS variable --animation-duration into account

.css-animation {
- transition: opacity 300ms linear;
+ transition: opacity var(--animation-duration) linear;
}

Let’s check

prefer-reduced-motion has affected CSS animation
prefers-reduced-motion has affected CSS animation

As you can see from this example, with prefers-reduced-motion enabled, we already affect CSS animation with just one global parameter.

The only thing left to do is to add support for Angular animation.

Angular animation и prefers-reduced-motion

In the previous part, I told about how you can make reusable Angular animations customizable with parameters. And the current case is no exception.

Let’s make the animation customizable

const leavedStyle = style({ opacity: 0 });
const enteredStyle = style({ opacity: 1 });
const animateTimings = '{{duration}}ms linear';
const defaultOptions = {params: {duration: 300}};

export const fade = trigger('fade', [
transition(
':enter',
[leavedStyle, animate(animateTimings, enteredStyle)],
defaultOptions,
),
transition(
':leave',
[enteredStyle, animate(animateTimings, leavedStyle)],
defaultOptions,
),
]);

There are several options for how you can indicate to Angular whether the prefers-reduced-motion setting is enabled, but the best solution, in my opinion, is to use the DI mechanism, which will provide parameters for animations.

Besides the fact that the parameters will be global, it will also provide consistency for current and new Angular animations. After all, all customized animations for transitions should now use duration as the time parameter.

Let’s implement a token with parameters for Angular animation

function isReducedMotion(): boolean {
const windowRef = inject(DOCUMENT).defaultView;
const isReduce = windowRef?.matchMedia?.('(prefers-reduced-motion: reduce)').matches;

return isReduce ?? false;
}

export type TAnimationParams = AnimationOptions['params'] & {
duration: number;
}

export const ANIMATION_PARAMS_TOKEN = new InjectionToken<TAnimationParams>(
'ANIMATION_PARAMS_TOKEN', {
factory: () => ({
duration: isReducedMotion() ? 0 : 300
}),
providedIn: 'root'
})

Implement a token with parameters and declare settings for animation with them in mind

constructor(
@Inject(ANIMATION_PARAMS_TOKEN)
private animationParams: TAnimationParams
) {}

public animationOptions: ReusableAnimationOptions<string> = {
value: '_', // or animation state name
params: {
// current params
...this.animationParams
}
}

Passing the animation settings in the template

- <app-item @fade *ngIf="state" class="item">
+ <app-item [@fade]="animationOptions" *ngIf="state" class="item">
Angular animation item
</app-item>

Let’s check the result

prefer-reduced-motion has affected Angular animation
prefers-reduced-motion has affected Angular animation

As you can see, prefers-reduced-motion now affects Angular animation, and it works similarly to CSS animation when disabled.

If prefers-reduced-motion is disabled on the device, the animations will work as normal

prefer-reduced-motion hasn’t activated
prefers-reduced-motion hasn’t been activated

Great 🎉

Conclusion

prefers-reduced-motion helps us create an app with disabled animations that is useful not only for us in e2e tests, but also for users with restrictions.

The examples shown here can be used to correctly react to other global user settings that should affect the app, preventing the creation of an alternative app for every case.

Q&A

  1. Could we not create tokens DI to provide parameters for the Angular animation, but go straight to window to create the animation with the parameters already ready?
    Yes, but then there would be quite a few errors:
  • Accessing a global window object. Working with global functions is considered a bad practice in Angular, and if any Window API may be tokenized and handled via DI.
    Especially if you are working with SSR where there is no browser environment.
  • Code overload. The point of this approach is that the animation itself doesn’t know if there is a prefers-reduced-motion setting and whether to respond to it. This is especially true when Angular animation is reused in multiple applications with no e2e-tests or users with restrictions accounting. It is up to the application team to decide whether the animation should be disabled or not.

2. Why isn’t NoopAnimationsModule used to turn off Angular animations?
There are several reasons for this:

  • NoopAnimationsModule is primarily used in unit and integration tests, where the test environment can be created via TestBed. In the case of e2e-tests, the test is performed over the build of the application, not as a part of the application
  • Alternatively building the application with NoopAnimationsModule for e2e-tests breaks the integrity of the test, because now the tests are performed on the “other” application
  • NoopAnimationsModule disables all Angular animations, not specific ones at our request

3. Why couldn’t we use the withConfig function call of BrowserAnimationsModule to disable animations?

The current implementation helps us to control specific animations rather than disabling them altogether. If your application does not have any special animation requirements for this mode, you can use this method as well.

Resources

Links

--

--