Reusable, Accessible Angular Animations: Reduced Motion, SSR and Performance [2026]

Link copied
Reusable, Accessible Angular Animations: Reduced Motion, SSR and Performance [2026]

Reusable, Accessible Angular Animations: Reduced Motion, SSR and Performance [2026]

The first animation in an app is usually written inline in one component. By the tenth, you have ten slightly different fade durations, three copies of the same slide-up keyframes, a test suite that waits for animations nobody asked it to wait for, and at least one user with vestibular sensitivity who now finds the app uncomfortable to use. Animations need the same treatment as any other shared concern: a small set of reusable pieces, a global switch, and rules that keep them fast and accessible.

This is lesson 13.4 of the Angular Tutorial, and the last lesson of Module 13. It builds on lesson 13.3 on route transitions, and on animate.enter and animate.leave from 13.1 and 13.2. You'll learn how to build reusable CSS animation utilities and motion tokens, how to respect prefers-reduced-motion everywhere, how to disable animations in tests and on user request, how animations interact with OnPush and zoneless change detection, how to avoid flashes with SSR and hydration, and which properties keep animations smooth.

The concerns at a glance #

Concern Technique Where it lives
Consistency Motion tokens (custom properties) + utility classes Global stylesheet
Reuse in components animate.enter="anim-fade-up" with shared classes, or host bindings on reusable components Templates, component metadata
Reduced motion prefers-reduced-motion media query overriding tokens Global stylesheet, plus a signal for JS animations
User setting A class on <html> that applies the same overrides Global stylesheet + a small service
Tests TestBed disables animate.enter/animate.leave by default; reduced-motion emulation in E2E Test config
SSR and hydration Don't hide server-rendered content behind animations; gate entry animations until after first render Templates, CSS
Performance Animate transform and opacity CSS

Motion tokens and utility classes #

Start with a few custom properties that every animation uses. Changing a token later, or overriding it for reduced motion, then updates the whole app:

/* src/styles/motion.css, imported from src/styles.css */
:root {
  --motion-fast: 150ms;
  --motion-medium: 250ms;
  --motion-slow: 400ms;
  --motion-ease-out: cubic-bezier(0.2, 0, 0, 1);
  --motion-ease-in: cubic-bezier(0.4, 0, 1, 1);
  --motion-distance: 12px;
}

@keyframes anim-fade-up {
  from { opacity: 0; transform: translateY(var(--motion-distance)); }
}
@keyframes anim-fade-down-out {
  to { opacity: 0; transform: translateY(var(--motion-distance)); }
}
@keyframes anim-fade {
  from { opacity: 0; }
}

.anim-fade-up {
  animation: anim-fade-up var(--motion-medium) var(--motion-ease-out) both;
  animation-delay: calc(min(var(--index, 0), 10) * 40ms);
}
.anim-fade-out {
  animation: anim-fade-down-out var(--motion-fast) var(--motion-ease-in) forwards;
}
.anim-fade {
  animation: anim-fade var(--motion-fast) linear both;
}

Because these are global styles, any component can use the classes with animate.enter and animate.leave, without redefining keyframes:

@for (item of items(); track item.id; let i = $index) {
  <li animate.enter="anim-fade-up" animate.leave="anim-fade-out" [style.--index]="i">{{ item.name }}</li>
}

The --index fallback of 0 means the same class works for single elements and staggered lists alike. Keep both the keyframes and the classes that use them in the global file; it avoids any question of whether a component's encapsulated styles can see a keyframe defined elsewhere.

Reusable components that animate themselves #

animate.enter and animate.leave also work as host bindings. A reusable component can declare its own entry and exit, so every place that uses it gets the same behaviour without repeating classes:

@Component({
  selector: 'app-toast',
  host: {
    role: 'status',
    'animate.enter': 'anim-fade-up',
    'animate.leave': 'anim-fade-out',
  },
  template: `<ng-content />`,
})
export class Toast {}
@for (toast of toasts(); track toast.id) {
  <app-toast>{{ toast.text }}</app-toast>
}

Respecting prefers-reduced-motion #

Operating systems let users ask for less motion, and browsers expose it as the prefers-reduced-motion media query. Reduced motion doesn't have to mean no animation: fades help users follow what changed, while movement, zooming, parallax and large slides are what cause discomfort. With tokens, the override is a few lines:

@media (prefers-reduced-motion: reduce) {
  :root {
    --motion-distance: 0px;      /* slides become fades */
    --motion-medium: 120ms;
    --motion-slow: 150ms;
  }
  .anim-fade-up {
    animation-delay: 0ms;        /* no cascades */
  }
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;  /* no sliding pages (lesson 13.3) */
  }
}

Keeping a short fade rather than removing animations entirely has a practical benefit too: every animate.leave class still has a real animation to complete, so leave behaviour stays the same in both modes.

For animations written in JavaScript, which CSS media queries can't reach, expose the preference as a signal:

@Injectable({ providedIn: 'root' })
export class MotionPreference {
  private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
  private readonly query = this.isBrowser ? matchMedia('(prefers-reduced-motion: reduce)') : null;

  readonly systemReduced = signal(this.query?.matches ?? false);
  readonly userReduced = signal(false);              // in-app setting
  readonly reduced = computed(() => this.systemReduced() || this.userReduced());

  constructor() {
    this.query?.addEventListener('change', e => this.systemReduced.set(e.matches));

    const root = inject(DOCUMENT).documentElement;
    effect(() => root.classList.toggle('reduce-motion', this.userReduced()));
  }
}

The isPlatformBrowser guard matters with SSR, where matchMedia doesn't exist on the server. Use reduced() to skip a Web Animations call, to pass a shorter duration to a third-party library inside an (animate.leave) handler, or to call transition.skipTransition() in the router's onViewTransitionCreated.

An in-app "reduce animations" setting #

Some users want less motion without changing an OS-wide setting, and some products need an "animations off" switch for kiosks or demos. The service above toggles a reduce-motion class on <html>; reuse the same overrides for it:

:root.reduce-motion {
  --motion-distance: 0px;
  --motion-medium: 120ms;
  --motion-slow: 150ms;
}
:root.reduce-motion .anim-fade-up {
  animation-delay: 0ms;
}

Persist the choice (for example in localStorage, read in the browser only) and set userReduced on startup.

Disabling animations in tests #

Unit and component tests. TestBed disables animate.enter and animate.leave by default, so tests don't wait for animations and elements removed by @if disappear immediately. If a test specifically needs animations to run, typically in Vitest browser mode, enable them for that test module:

TestBed.configureTestingModule({
  imports: [ToastList],
  animationsEnabled: true,
});

Code still on the legacy package uses provideNoopAnimations() in tests instead, which replaces the animation engine with one that completes immediately.

End-to-end tests. E2E tools wait for elements to be stable, so animations mostly slow tests down. Emulating reduced motion both speeds them up and exercises your accessibility path:

// Playwright
test.beforeEach(async ({ page }) => {
  await page.emulateMedia({ reducedMotion: 'reduce' });
});

Keep at least one E2E run with motion enabled if your animations carry behaviour, such as a leave animation that must finish before focus moves.

Animations with OnPush and zoneless change detection #

Modern animations fit naturally with OnPush and zoneless apps (lesson 3.5) because the browser runs them, not Angular:

  • A class bound to a signal marks only that component for refresh when the signal changes. The transition then runs without any further change detection.
  • animate.leave keeps the element in the DOM until its animation completes; you don't need to trigger change detection to finish it.
  • Template listeners for (transitionend) or (animationend) do mark the component for refresh, like any template event. They fire once per animation, which is fine.

The pattern to avoid is driving animations frame by frame through state: a requestAnimationFrame loop that sets a signal every frame causes a change detection pass sixty times a second. Use CSS, or the Web Animations API (element.animate()), which runs without involving Angular at all.

SSR and hydration without a flash #

With server-side rendering, the HTML is visible before JavaScript loads and before hydration completes. Two rules prevent flashes and double animations:

  1. Never hide server-rendered content until JavaScript reveals it. A rule like .card { opacity: 0 } that relies on a class added by Angular leaves the page blank until hydration, which hurts LCP and looks broken on slow connections. Content should be visible in its final state in the server HTML.
  2. Animate changes, not the initial render. Entry animations exist to show that something new appeared. Content that was already in the server HTML didn't "appear", so it shouldn't animate when the app hydrates. A simple way to guarantee that is to enable entry animations only after the first render in the browser:
@Component({
  selector: 'app-feed',
  host: { '[class.motion-ready]': 'ready()' },
  template: `
    @for (post of posts(); track post.id) {
      <article animate.enter="feed-in">…</article>
    }
  `,
  styles: `
    :host(.motion-ready) .feed-in {
      animation: feed-in var(--motion-medium) var(--motion-ease-out) both;
    }
    @keyframes feed-in {
      from { opacity: 0; transform: translateY(var(--motion-distance)); }
    }
  `,
})
export class Feed {
  posts = input.required<Post[]>();
  ready = signal(false);

  constructor() {
    afterNextRender(() => this.ready.set(true));
  }
}

afterNextRender only runs in the browser, after the first render, so posts present at hydration get the feed-in class with no animation attached, while posts added later animate. Verify it by loading the page with network and CPU throttling in DevTools: nothing should blink, and nothing should replay when hydration finishes. Lesson 9.3 on hydration pitfalls covers the broader rules.

Performance: what to animate #

Browsers can animate some properties on the compositor without recalculating layout or repainting; others force work on every frame:

Property Cost per frame Use it for
transform (translate, scale, rotate) Composite only Movement, zoom, rotation
opacity Composite only Fades
filter, backdrop-filter Paint, can be expensive on large areas Use sparingly, on small elements
background-color, color, box-shadow Paint Short state changes; avoid on large lists
width, height, top, left, margin Layout + paint Avoid; use transform or the grid technique from lesson 13.2

Some further rules:

  • will-change is a hint, not a fix. Apply it only to elements about to animate, and remove it after; applied everywhere, it wastes memory.
  • Keep durations short. 150–300 ms for most UI transitions. Longer animations make an app feel slower, regardless of frame rate.
  • Profile on a throttled CPU. The DevTools Performance panel shows layout and paint work per frame; the Animations panel lets you slow down and replay animations to check timing.

Gotchas #

Symptom Cause Fix
Users with reduced motion still see slides Hard-coded distances and durations Route all animations through tokens overridden by the media query
Page blank until JavaScript loads Content hidden in CSS until a class is added Render final visible state on the server
List animates in again after hydration Entry animation applies to server-rendered items Gate animation styles behind a class set in afterNextRender
matchMedia is not defined during SSR Browser API used on the server Guard with isPlatformBrowser
Janky animation on low-end phones Animating height, top or large box-shadow Animate transform and opacity
Animation timing test behaves oddly under jsdom DOM emulation has no real CSS animation engine Run animation-specific tests in Vitest browser mode with animationsEnabled: true

What's next #

That completes Module 13, the final module of the Angular Tutorial. You can now build enter, leave, state and route animations with native CSS and Angular's animate.enter and animate.leave, migrate code off the deprecated @angular/animations package, and keep motion consistent, fast and accessible. From here, revisit the Angular Tutorial hub for any module you skipped, or go back to Module 11 and add tests for the animations you just built.

Try it yourself #

Turn on "reduce motion" in your operating system and click through your app's busiest flows. Note every movement that still happens. Then add motion tokens to your global stylesheet, move the worst offender onto them, and confirm the movement turns into a short fade while the reduced-motion setting is on.

YouOur SSR product grid fades in twice: once when the HTML arrives and again a moment later. The cards use animate.enter with a fade-up class.
Claude · used get_best_practicesThe cards were already in the server HTML, so they shouldn’t get an entry animation at all; an entry animation is for items that appear later. In ProductGrid, add a ready = signal(false) that you set in afterNextRender(), bind it to a motion-ready host class, and scope the animation rule to :host(.motion-ready) .fade-up. Cards present at hydration then get the class without any animation, while cards added later, for example by “Load more”, still fade in. Also check product-grid.css for an opacity: 0 starting style on .card itself: if it’s there, the server-rendered grid stays invisible until JavaScript runs, which is the first “fade” you’re seeing and will also hurt LCP.

Up next in Angular

More from this topic

View all Angular articles →

Enjoyed this article?

Get new Angular tutorials delivered. No spam — just code-first articles when they ship.

1 comment

1 thought on “Reusable, Accessible Angular Animations: Reduced Motion, SSR and Performance [2026]”

  1. Pingback: Angular Route Animations with View Transitions [2026]

Leave a Comment

Your email stays private. Required fields are marked *

1 thought on “Reusable, Accessible Angular Animations: Reduced Motion, SSR and Performance [2026]”

  1. Pingback: Angular Route Animations with View Transitions [2026]

Leave a Comment

Your email stays private. Required fields are marked *