📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

~7 min de lecture

prefers-reduced-motion en Angular : au-delà du reset CSS

Tu colles le snippet qui traîne dans tous les resets modernes : une media query prefers-reduced-motion, un sélecteur universel, toutes les durées d'animation écrasées à 0.01ms. Tu te dis que l'accessibilité motion est réglée, tu passes à autre chose. Sauf que non. Ce filet est un bon backstop, mais il rate deux choses, et les deux comptent : la motion qui portait du sens disparaît sans alternative, et tout ce qui est animé hors CSS (JavaScript, animations Angular, smooth scroll programmatique) ne le voit jamais passer.

Ce n'est pas un détail cosmétique. Pour une personne sujette aux troubles vestibulaires, un parallax ou un carrousel qui bouge tout seul provoque nausée et vertige bien réels ; c'est l'objet du critère WCAG 2.3.3 (Animation from Interactions). La préférence système existe pour ça, et l'honorer à moitié revient à ne pas l'honorer.

C'est le même angle mort que le reste de cette série sur le CSS : ton garde-fou ne protège que ce qu'il voit. Pour prefers-reduced-motion, ce qu'il voit, c'est le CSS, et rien d'autre. On regarde le filet, ses deux trous, et comment les combler proprement, sur le vrai code de ce repo (la landing Angular 21 qui sert ce blog). C'est le prolongement direct de l'angle accessibilité de l'article sur Stylelint et les rem : une convention ne tient que si l'outillage la fait respecter partout.

Le filet global : bon backstop, à garder

Voici la règle telle qu'elle vit dans le styles.css de ce repo :

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

Trois détails de craft méritent qu'on s'arrête, parce qu'ils ne sont pas cosmétiques.

0.01ms, pas 0. Une durée à zéro peut empêcher les événements animationend / transitionend de se déclencher sur certains navigateurs. Si un bout de JS attend cet événement (await animation.finished, un listener qui nettoie une classe), il resterait bloqué. 0.01ms rend l'animation imperceptible tout en garantissant que l'événement part quand même. C'est un choix, pas une coquille.

scroll-behavior: auto. Le smooth scroll est de la motion, lui aussi. Si tu déclares scroll-behavior: smooth sur html, la préférence doit le désactiver, sinon l'utilisateur qui a demandé moins de mouvement se prend quand même des défilements animés.

animation-iteration-count: 1. Ça coupe net les animations infinies (un float en boucle, un shimmer), qui sont exactement celles qui déclenchent le plus de gêne vestibulaire.

Ce filet est utile et tu dois le garder. Le problème n'est pas qu'il existe, c'est de croire qu'il suffit.

Trou n°1 : "reduce" ne veut pas dire "tout geler"

La spec dit reduce, pas remove. Un sélecteur universel qui écrase toutes les durées traite de la même façon un parallax gratuit et une transition qui aide l'utilisateur à comprendre qu'un panneau s'est ouvert. Le premier doit disparaître, le second devrait juste devenir instantané ou se replier sur un simple fondu, pas s'évaporer en laissant l'interface incompréhensible.

Autre subtilité que le filet générique laisse passer : il neutralise les durées, pas les transforms. Un hover qui déplace une carte reste un déplacement, il devient juste instantané au lieu d'être animé. Si ce saut n'a pas de sens sans son animation, il faut le retirer pour de bon. C'est précisément ce que fait le repo, juste après la règle globale :

@media (prefers-reduced-motion: reduce) {
  /* la règle universelle ci-dessus, plus ce cas ciblé : */
  .glass-card:hover {
    transform: none;
  }
}

La leçon : le filet global est un point de départ, et les interactions qui comptent méritent chacune une dégradation pensée. Un fondu d'opacité à la place d'un slide reste une transition douce et accessible ; c'est souvent le bon compromis, et prefers-reduced-motion n'interdit pas l'opacité.

Trou n°2 : le filet CSS ne voit que le CSS

C'est le trou le plus dangereux, parce qu'il est invisible. La media query CSS agit sur les animation et transition CSS. Elle n'a aucune prise sur :

  • les animations via la Web Animations API (element.animate(...)),
  • les animations du package @angular/animations (triggers, transitions de route), qui s'exécutent en JS,
  • le smooth scroll programmatique (el.scrollIntoView({ behavior: 'smooth' }), window.scrollTo({ behavior: 'smooth' })), que scroll-behavior: auto ne touche pas,
  • l'autoplay d'une vidéo, un parallax piloté par un listener de scroll.

Tout ça continue de bouger, préférence système ou non. Pour ces cas, il faut lire la préférence en TypeScript. La bonne façon en Angular 21 : un helper basé signal, SSR-safe, avec nettoyage du listener.

// reduced-motion.ts
import { DOCUMENT, isPlatformBrowser } from '@angular/common';
import { DestroyRef, inject, PLATFORM_ID, signal, Signal } from '@angular/core';

export function injectPrefersReducedMotion(): Signal<boolean> {
  const reduced = signal(false);

  // matchMedia n'existe pas côté serveur : on court-circuite au rendu SSR.
  if (!isPlatformBrowser(inject(PLATFORM_ID))) {
    return reduced.asReadonly();
  }

  const mql = inject(DOCUMENT).defaultView!.matchMedia('(prefers-reduced-motion: reduce)');
  reduced.set(mql.matches);

  const onChange = (e: MediaQueryListEvent) => reduced.set(e.matches);
  mql.addEventListener('change', onChange);
  inject(DestroyRef).onDestroy(() => mql.removeEventListener('change', onChange));

  return reduced.asReadonly();
}

Le signal réagit en direct si l'utilisateur change sa préférence système sans recharger. Ensuite, tu le lis avant toute animation pilotée en JS :

@Component({
  selector: 'app-hero',
  changeDetection: ChangeDetectionStrategy.OnPush,
  templateUrl: './hero.html',
})
export class Hero {
  protected readonly reduceMotion = injectPrefersReducedMotion();

  reveal(el: HTMLElement): void {
    if (this.reduceMotion()) {
      return; // pas de fade-in animé, l'élément est simplement là
    }
    el.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 700, easing: 'ease-out' });
  }
}

Même logique pour un scroll : if (this.reduceMotion())behavior: 'auto', sinon 'smooth'. Le point n'est pas de tout couper, c'est que la décision passe par la préférence, au lieu de l'ignorer parce qu'elle vit en dehors du CSS.

Cas particulier utile si tu utilises @angular/animations : le framework ne consulte pas prefers-reduced-motion tout seul, mais il expose un interrupteur natif, le binding spécial @.disabled, qui coupe toutes les animations d'un sous-arbre. Tu le pilotes avec le même signal :

<!-- désactive toutes les animations @angular/animations sous ce noeud -->
<section [@.disabled]="reduceMotion()">
  <article @fadeIn>...</article>
</section>

Un seul binding, et tes triggers de route ou d'état se taisent dès que la préférence est active, sans que tu ailles modifier chaque animation une par une.

La bonne posture : motion-safe par défaut

Le snippet global suit une logique "j'ajoute de la motion partout, puis je la tue quand on me le demande". L'inverse est plus sain : pas de motion par défaut, tu l'ajoutes quand elle est explicitement autorisée. C'est l'approche motion-safe, avec la media query complémentaire :

/* la motion ne s'active que si l'utilisateur ne l'a pas refusée */
@media (prefers-reduced-motion: no-preference) {
  .card {
    transition: transform 0.3s ease;
  }
}

En Tailwind, tu as les variantes motion-safe: et motion-reduce: qui encodent exactement ça (motion-safe:transition-transform). L'intérêt : une animation oubliée n'a pas besoin d'être rattrapée par le filet, puisqu'elle n'existe pas tant que la motion n'est pas autorisée. Le défaut penche du bon côté. Le filet global reste, en backstop, pour tout ce qui aurait été écrit sans passer par motion-safe.

Before / after

  • Before : le reset universel prefers-reduced-motion collé une fois, et le sentiment que c'est réglé. En vrai, les animations Angular et Web Animations tournent toujours, le smooth scroll JS aussi, et les transitions qui portaient du sens ont disparu sans alternative.
  • After : le filet CSS reste en backstop (avec ses 0.01ms et son scroll-behavior: auto), les interactions qui comptent dégradent proprement vers un fondu ou l'instantané, la motion pilotée en JS lit un signal prefersReducedMotion, et les nouvelles animations naissent en motion-safe. La préférence est respectée partout, pas seulement là où le CSS peut l'atteindre.

Récap actionnable

  1. Garde le filet global, mais écris-le bien : 0.01ms (pas 0, pour ne pas bloquer les events), animation-iteration-count: 1, et scroll-behavior: auto.
  2. Reduce n'est pas remove. Les transitions porteuses de sens dégradent vers un fondu ou l'instantané ; supprime pour de bon les transforms qui n'ont plus de sens figées (transform: none ciblé).
  3. Le filet CSS ne voit que le CSS. Web Animations API, @angular/animations, smooth scroll programmatique, autoplay : ils passent au travers. Lis la préférence en TS.
  4. Un signal SSR-safe (matchMedia derrière isPlatformBrowser, listener nettoyé par DestroyRef) rend la préférence disponible partout dans ton code, et réactif si elle change.
  5. Motion-safe par défaut. Active la motion via @media (prefers-reduced-motion: no-preference) ou les variantes Tailwind motion-safe:, plutôt que d'en mettre partout pour la tuer ensuite.

Respecter prefers-reduced-motion, ce n'est pas coller un snippet, c'est décider, à chaque animation, ce qu'elle devient quand quelqu'un demande moins de mouvement. Le filet global t'évite le pire ; le reste, c'est du soin, et il commence là où le CSS s'arrête.

📧 Reste informé(e) !

Reçois les derniers articles et conseils EasyAngularKit directement dans ta boîte mail.

S'inscrire gratuitement

AngularKit

Suite d'outils pour développeurs Angular francophones. Apprends, modernise tes réflexes, audite ta codebase.

Produits

Contact

Légal

© 2026 AngularKit. Tous droits réservés.