Angular deprecated @angular/animations in v20.2; how would you migrate a legacy app's triggers, including a staggered list reveal, to native CSS?
answer
- removal intended for v23
- states become classes
- :enter and :leave map to new API
- stagger becomes a delay variable
- no mixing inside one component
basics
~20 sMigrate component by component: states become CSS classes, timings become transition or animation properties, :enter/:leave become animate.enter/animate.leave, a stagger becomes an index-based delay. Never mix both systems in one component; drop the provider once the last trigger is gone.
solid answer
~40 sThe package and its providers are deprecated since v20.2 with removal intended in v23, so I would plan a component-by-component migration. `state()` styles become CSS classes toggled with class bindings, and `animate()` timings become `transition` or `animation` properties. `:enter` and `:leave` become `animate.enter` and `animate.leave`, with `@starting-style` for transition-based entrances. A `query()`/`stagger()` reveal becomes a per-row custom property such as `--i: {{ $index }}` driving `animation-delay: calc(var(--i) * 60ms)`; `group()` becomes several animations in one declaration, `animation()`/`useAnimation()` becomes a shared stylesheet, and `AnimationPlayer` becomes `element.getAnimations()`. The trap is that the two systems cannot share a component, or a component and the content projected into it. Once no trigger remains, I remove `provideAnimationsAsync()` and the package, which shrinks the bundle.
code
ts · 28 linesimport {Component, input} from '@angular/core';
interface OrderRow {
id: string;
label: string;
}
@Component({
selector: 'app-order-list',
template: `
<ul>
@for (row of rows(); track row.id) {
<li class="row" [style.--i]="$index" animate.enter="row-in" animate.leave="row-out">
{{ row.label }}
</li>
}
</ul>
`,
styles: `
.row-in { animation: row-in 300ms ease-out both; animation-delay: calc(var(--i) * 60ms); }
.row-out { animation: row-out 150ms ease-in both; }
@keyframes row-in { from { opacity: 0; transform: translateY(-8px); } }
@keyframes row-out { to { opacity: 0; } }
`,
})
export class OrderList {
readonly rows = input.required<OrderRow[]>();
}go deeper
Know that @angular/animations is deprecated and that new code uses CSS with animate.enter and animate.leave.
Map each DSL function to its CSS equivalent: classes for states, transition properties for timing, animate.enter/leave for :enter/:leave, delays for stagger.
Plan the migration per component, respect the no-mixing and projection rules, rework animateChild() orchestration, update tests and remove the provider at the end.
Sequence the migration against the v23 removal intent and the release calendar, and set the regression checks, such as auto-height and reduced motion, that gate each batch.
## Why migrate now Since Angular 20.2 every export of `@angular/animations`, the `animations` field on `@Component`, and the providers `provideAnimationsAsync()`, `provideAnimations()` and `BrowserAnimationsModule` carry `@deprecated 20.2 Use animate.enter or animate.leave instead. Intent to remove in v23`. In Angular 22 the DSL still works, but it is a runtime engine the team no longer recommends. The official guide gives two benefits of leaving it: removing the package **can significantly reduce the bundle**, and native CSS animations generally **perform better** because they can benefit from hardware acceleration. ## The mapping | Legacy DSL | Native replacement | |---|---| | `state('open', style(...))` | a CSS class (`.open`) toggled with `[class.open]` | | `animate('250ms ease-out')` | `transition` or `animation` shorthand with the same duration, delay and easing | | `style({height: '*'})` to animate to auto height | a CSS grid rows trick, or `calc-size()` where browsers support it | | `transition(':enter')` / `transition(':leave')` | `animate.enter` / `animate.leave` classes, or functions for JavaScript motion | | `keyframes([...])` | a CSS `@keyframes` rule | | `query('.child', ...)` | ordinary CSS selectors, plus class bindings on the children | | `stagger(60, ...)` | `animation-delay` or `transition-delay` computed from an index custom property | | `group([...])` | several animations in one declaration, which start together | | `animation()` + `useAnimation()` | a shared CSS file of reusable classes | | `(@trigger.done)` callbacks | `animationend` / `transitionend` listeners, or `AnimationCallbackEvent` for leaves | | `AnimationPlayer` | `element.getAnimations()` and the Web Animations `Animation` API | | `:increment` / `:decrement` | classes applied from code when a value goes up or down | ## Migrating the staggered list reveal The legacy recipe is a trigger on the list container running `query(':enter', [style(...), stagger(60, [animate(...)])])`. The native version moves everything into CSS: 1. In the `@for`, expose the position: `<li class="row" [style.--i]="$index">`. 2. In CSS, give `.row` a transition or animation and a delay of `calc(var(--i) * 60ms)`. 3. For the entrance, either use `@starting-style` with a transition, or add `animate.enter="row-in"` with a keyframe class. 4. For exits, put `animate.leave="row-out"` on the row; Angular keeps each row until its animation ends. 5. Delete the trigger, the `animations` array entry and the `[@listReveal]` binding. ## A safe sequence for a real codebase 1. **Inventory** every `trigger(` and every `@` binding, and note which components project content into each other. 2. **Migrate whole components.** The guide is explicit: mixing legacy animations with `animate.enter`/`animate.leave` in the **same component** is unsupported and leaves enter classes stuck or leaving nodes in the DOM. The same applies when content from a component using one system is **projected** into a component using the other. 3. **Replace orchestration deliberately.** `animateChild()` and the parent-blocks-child rule have no native equivalent: with CSS no animation takes priority over another, and native leave animations only reach nested elements in the same component template. Sequencing across components becomes delays and end events in your code. 4. **Update tests.** `NoopAnimationsModule`/`provideNoopAnimations()` disable the old engine; for the new API `TestBed` disables animations by default and `animationsEnabled: true` turns them on. 5. **Remove the engine** once no trigger is left: drop the provider from the bootstrap config and the package from the dependencies, then compare bundle sizes. 6. **Route animations** built on the old DSL are migrated separately, typically to the router's view-transition integration. ## Estimating the work - **Simple enter and leave triggers** are the cheapest: usually a few lines of CSS plus `animate.enter`/`animate.leave` per component. - **State triggers** need the states turned into classes and the template bindings rewritten from `[@trigger]` to class bindings. - **Choreography** built from `query()`, `stagger()`, `group()` and `animateChild()` needs the most design time, because some of it has to be expressed as CSS delays or as code. - **Shared libraries** of `animation()` references translate into one shared stylesheet, which then speeds up every remaining component. ## Risks worth flagging in review - **Auto-height animations** are the most common regression; test them first. - **Behaviour under reduced motion** changes: the old `@.disabled` switch disappears, so the CSS must handle it. - **Event bubbling:** native `animationend` and `transitionend` bubble from children to parents, unlike the per-trigger `(@trigger.done)` callbacks, so code that replaced a callback must check the event's target or animation name. A migration planned this way can ship incrementally: each merged component is fully native, the application keeps working throughout, and the provider disappears in the last change.
- Why can you not migrate one trigger at a time inside a component that has three?The legacy engine and the native `animate.enter`/`animate.leave` instructions both take over element insertion and removal. In the same component they interfere, leaving enter classes on elements or leaving nodes that are never removed, so the documentation calls the combination unsupported. The unit of migration is the component, not the trigger.
- A legacy parent used query('@child', animateChild()) to sequence a child's exit. What do you do natively?There is no `animateChild()` equivalent. Put the child's `animate.leave` on its host element in the parent template, which the parent's removal does collect, or coordinate in code: let the child animate, listen for the end, and only then remove the parent. For simple timing, CSS delays on the two elements may be enough.
saying these in an interview costs you the question
- Legacy triggers and animate.enter can be mixed freely inside one component.
- The legacy package was removed in Angular 22.
- stagger() has no CSS equivalent, so list reveals must keep the old package.
- Once migrated, animateChild()-style parent control works the same with CSS.
- Removing the provider is safe while any component still has a trigger.