skip to content

How did older Angular apps animate route changes with @angular/animations, and how does that compare with the router's withViewTransitions()?

level: seniorimportance: should knowfreq 28%

answer

  1. trigger around the outlet
  2. route data names the page
  3. both views absolutely positioned
  4. snapshots versus live DOM

basics

~20 s

Legacy apps wrapped <router-outlet> in an element with a route-animation trigger bound to a key from route data, then used query(':enter, :leave'), group() and animateChild(). withViewTransitions() instead lets the browser animate snapshots with CSS, with no animation engine.

solid answer

~50 s

In the legacy approach each route gets `data: {animation: 'GalleryPage'}`, and a wrapper around `<router-outlet>` binds `[@routeAnimations]` to that key, read from the outlet's `activatedRouteData` or from `ChildrenOutletContexts`. Transitions such as `'GalleryPage <=> PhotoPage'` absolutely position the entering and leaving views with `query(':enter, :leave', style(...))`, animate both in a `group()`, and use `animateChild()` so nested triggers are not blocked. Both components are live in the DOM during the animation, which is flexible but costs JavaScript and layout work, and the package is deprecated. With `withViewTransitions()` the browser captures the old page as a snapshot, the router swaps the components once, and CSS on `::view-transition-*` pseudo-elements animates the difference. There is no engine to ship and unsupported browsers just skip the animation, but the integration is still developer preview and the choreography lives in global CSS.

code

ts · 36 lines
ts
import {ChangeDetectionStrategy, Component, inject} from '@angular/core';
import {ChildrenOutletContexts, RouterOutlet} from '@angular/router';
import {animate, group, query, style, transition, trigger} from '@angular/animations';

// Legacy (deprecated since v20.2): routes declare data: {animation: 'GalleryPage' | 'PhotoPage'}
@Component({
  selector: 'app-root',
  // the binding reads non-signal router state, so this legacy component keeps Eager checking
  changeDetection: ChangeDetectionStrategy.Eager,
  imports: [RouterOutlet],
  template: `
    <div class="page" [@routeAnimations]="animationKey()">
      <router-outlet />
    </div>
  `,
  animations: [
    trigger('routeAnimations', [
      transition('GalleryPage <=> PhotoPage', [
        style({position: 'relative'}),
        query(':enter, :leave', style({position: 'absolute', top: 0, left: 0, width: '100%'}), {optional: true}),
        query(':enter', style({opacity: 0}), {optional: true}),
        group([
          query(':leave', animate('200ms ease-out', style({opacity: 0})), {optional: true}),
          query(':enter', animate('300ms ease-out', style({opacity: 1})), {optional: true}),
        ]),
      ]),
    ]),
  ],
})
export class App {
  private readonly contexts = inject(ChildrenOutletContexts);

  protected animationKey(): string | undefined {
    return this.contexts.getContext('primary')?.route?.snapshot?.data?.['animation'];
  }
}

go deeper

for a junior

Recognise the legacy pattern, a trigger wrapped around router-outlet keyed by route data, and know that withViewTransitions() is its modern replacement.

for a middle

Explain the legacy recipe (route data key, overlapping views, group, animateChild) and how view transitions animate snapshots instead.

for a senior

Compare runtime cost, robustness, interactivity and browser support, and plan the removal of legacy route triggers during an animations migration.

for a principal

Judge whether a developer-preview browser integration is acceptable for the product and how much bespoke choreography is worth keeping.

## Two generations of route animation Angular applications have animated navigation in two ways: 1. **Legacy route animations** built with the `@angular/animations` DSL, attached to a wrapper around `<router-outlet>`. This was the documented approach for years and is still in many codebases. 2. **Router view transitions**, enabled with `withViewTransitions()`, which delegate the animation to the browser's View Transitions API. This is the current approach. Interviewers ask candidates to compare them because a migration from the first to the second is a common task. ## How the legacy approach works 1. Each route declares a label in its `data`, for example `{path: 'photos', component: Gallery, data: {animation: 'GalleryPage'}}`. 2. The root component wraps the outlet: `<div [@routeAnimations]="animationKey()"><router-outlet /></div>`. The key is read from the outlet's `activatedRouteData['animation']`, or from `inject(ChildrenOutletContexts).getContext('primary')?.route?.snapshot?.data?.['animation']`. 3. When the key changes, a transition such as `'GalleryPage <=> PhotoPage'` runs: - `style({position: 'relative'})` on the wrapper; - `query(':enter, :leave', style({position: 'absolute', top: 0, left: 0, width: '100%'}))` so the two pages overlap; - `query(':enter', style({left: '-100%'}))` to place the new page off-screen; - `query(':leave', animateChild())` to let nested triggers in the old page run, since parent animations otherwise block them; - `group([...])` to slide the old page out and the new page in at the same time. 4. The leaving component stays in the DOM until its `:leave` animation finishes; then the engine removes it. Queries are marked `{optional: true}` because on some navigations there is no entering or leaving view to find. ## How view transitions work With `provideRouter(routes, withViewTransitions())`, the router runs guards, resolvers and lazy loading first, then calls `document.startViewTransition`. The browser captures the old page, the router activates the new routes, and after Angular's next render the browser captures the new page and animates between the two captures using `::view-transition-old` and `::view-transition-new` pseudo-elements. Custom motion is plain CSS in the global stylesheet, and shared elements morph when they carry the same `view-transition-name`. ## Side-by-side comparison | Aspect | Legacy route animations | `withViewTransitions()` | |---|---|---| | Status | deprecated since v20.2 | developer preview (added in v17) | | Runtime cost | animation engine in the bundle, keyframes built in JavaScript | browser feature, no engine | | What animates | two live component trees overlapping in the DOM | a static snapshot of the old page and a live rendering of the new one | | Waiting for content | the leaving page stays live while the new one animates | the router cannot delay the transition; the page is frozen while the update is pending | | Shared-element morphs | hand-written with queries and positioning | built in via `view-transition-name` | | Browser support | wherever Angular runs | depends on View Transitions support; otherwise no animation | | Where the code lives | TypeScript in the root component | global CSS plus an optional `onViewTransitionCreated` hook | | Nested child animations | `animateChild()` | CSS on named elements; no parent/child priority | ## Trade-offs to discuss - **Control versus simplicity.** The DSL could run arbitrary choreography across two live views, including child animations. View transitions cover the common page-level patterns with far less code, but the outgoing page is a static snapshot, so live content in it, such as a playing video, freezes during the animation. - **Robustness.** Legacy route animations were a frequent source of layout bugs: forgotten absolute positioning, scroll jumps, and animations blocked by nested triggers. View transitions avoid most of these because the new page renders in its normal layout. - **Risk.** Relying on a developer-preview API is a real consideration for conservative teams; the fallback, a normal navigation, keeps that risk low. ## What interviewers are really checking - That you know the legacy recipe well enough to **read and remove** it: the route-data key, the wrapper trigger, overlapping views and `animateChild()`. - That you can explain **why** the platform approach is preferred now: less code, no engine, graceful fallback. - That you are honest about its **limits**: developer-preview status, uneven browser support and the static snapshot of the outgoing page. ## Migrating 1. Remove the `[@routeAnimations]` binding, the trigger and the `data.animation` labels. 2. Add `withViewTransitions()` to `provideRouter`, or `enableViewTransitions: true` for `RouterModule.forRoot`. 3. Recreate direction-specific motion with global CSS, using `onViewTransitionCreated` to set a class or type per navigation when forward and back need different animations. 4. Once no other trigger remains, drop the animations provider and the `@angular/animations` dependency.

  • Why did legacy route animations set the entering and leaving views to position: absolute?
    During the transition both routed components are in the DOM at once, one after the other in the wrapper. Without absolute positioning the new page would render below the old one and jump up when the old one is removed. Positioning both at the top of a relatively positioned wrapper overlaps them so they can cross-fade or slide.
  • What does animateChild() add in a legacy route transition?
    Parent animations block animations on descendants. `query(':leave', animateChild())` lets triggers inside the leaving page, such as a list fading out, run as part of the route animation instead of being skipped when the route changes.

saying these in an interview costs you the question

  • Route animations in the legacy DSL are configured on the Route object's animation property.
  • withViewTransitions() keeps both routed component trees alive during the animation.
  • View transitions require the @angular/animations provider.
  • Legacy route animations are the recommended approach for new Angular 22 apps.
  • Browsers without View Transitions fall back to the legacy DSL automatically.