Since v20.2, Angular has two new instructions in @angular/core: animate.enter and animate.leave. You write the animation in CSS, like any other animation. Angular does the part CSS can’t do on its own: it adds a class when the element enters the DOM and, on exit, delays removing the element until the animation finishes. In the same version, the @angular/animations package (trigger, state, transition) was deprecated.
For transitions between pages, or between two states of the same screen, there is the View Transitions API. It is a browser API, integrated into the router with withViewTransitions().
provideAnimations() or a DSL in TypeScript. Animations are regular CSS, accelerated by the browser, and animate.leave solves the hard part, which is animating an element that is about to disappear. The v22 documentation explicitly recommends animate.enter / animate.leave for all new code.animate.enter and animate.leave
animate.enter="enter" adds the class enter when the element is inserted (an @if becomes true, a new element appears in an @for) and removes it when the CSS animation ends. animate.leave="leave" sets the class on removal and keeps the element in the DOM until the animation ends. They are not directives: they are compiler instructions, so they don’t need to be imported and can also be used as host bindings.
@for (item of items(); track item.id) {
<li animate.enter="enter" animate.leave="leave">{{ item.label }}</li>
}- Apple
- Pear
- Plum
- Cherry
Keyframes or transition
With @keyframes it is simple: the class contains the animation, and Angular waits for its end event. With transition, the documentation points to a detail: the enter class represents the final state. You give the starting state with @starting-style, otherwise there is no “from” for the transition to start from.
/* With transition instead of keyframes: the class is the FINAL state, and @starting-style is the starting state */
.panel { transition: opacity 250ms, transform 250ms; }
.panel-enter {
opacity: 1;
transform: none;
@starting-style {
opacity: 0;
transform: translateY(12px);
}
}The function form: full control
(animate.enter)="fn($event)" and (animate.leave)="fn($event)" call a method with an AnimationCallbackEvent: target (the element) and animationComplete(). This is how you integrate the Web Animations API or libraries such as GSAP. On leave, the call to animationComplete() is mandatory. If it is missing, Angular removes the element only after MAX_ANIMATION_TIMEOUT, which is 4 seconds by default.
import { AnimationCallbackEvent, Component, signal } from '@angular/core';
@Component({
selector: 'app-toasts',
template: `
@for (t of toasts(); track t.id) {
<div class="toast" (animate.enter)="slideIn($event)" (animate.leave)="slideOut($event)">{{ t.text }}</div>
}
`,
})
export class Toasts {
readonly toasts = signal<Toast[]>([]);
slideIn(event: AnimationCallbackEvent): void {
event.target.animate([{ opacity: 0, transform: 'translateX(40px)' }, { opacity: 1, transform: 'none' }], 250);
}
slideOut(event: AnimationCallbackEvent): void {
const animation = event.target.animate([{ opacity: 1 }, { opacity: 0, transform: 'translateX(40px)' }], 300);
// REQUIRED on leave: otherwise Angular removes the element only after MAX_ANIMATION_TIMEOUT (4 s by default)
animation.finished.then(() => event.animationComplete());
}
}- The hook log appears here.
Migrating from @angular/animations
Most animations written with trigger('fade', [transition(':enter', …)]) translate directly into two CSS classes and animate.enter / animate.leave. States (state('open')) become regular CSS classes, bound with [class.open]. The two systems can coexist in an application, but not in the same component: the documentation says the result would be classes that stay on elements, or nodes that are never removed. The same applies to content projection between components that use different systems.
// BEFORE — @angular/animations (deprecated since v20.2)
@Component({
animations: [
trigger('fade', [
transition(':enter', [style({ opacity: 0 }), animate('200ms', style({ opacity: 1 }))]),
transition(':leave', [animate('200ms', style({ opacity: 0 }))]),
]),
],
template: `@if (open()) { <div @fade>…</div> }`,
})
// AFTER — CSS + animate.enter / animate.leave, with no package and no provideAnimations()
@Component({
template: `@if (open()) { <div animate.enter="fade-in" animate.leave="fade-out">…</div> }`,
styles: `
.fade-in { animation: fade 200ms; }
.fade-out { animation: fade 200ms reverse forwards; }
@keyframes fade { from { opacity: 0; } }
`,
})View Transitions
The browser takes a snapshot of the page, you change the DOM, then the browser animates the difference: by default a cross-fade, and elements with the same view-transition-name “transform” into one another (position, size). In the router you enable this with withViewTransitions(). The function is marked developer preview in the router’s types and has the options skipInitialTransition and onViewTransitionCreated. Outside the router you call document.startViewTransition() directly. In a zoneless application you must update the DOM synchronously, in the callback.
import { provideRouter, withViewTransitions } from '@angular/router';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(
routes,
withViewTransitions({
skipInitialTransition: true, // no animation on the first load
onViewTransitionCreated: ({ transition }) => {
// runs in an injection context; you can skip certain navigations:
// if (...) transition.skipTransition();
},
}),
),
],
};Your browser doesn’t support View Transitions: the change happens instantly (fallback).
Performance and accessibility
- Animate
transformandopacity: the compositor handles them, without layout.width,heightandtoprecalculate the layout on every frame. - Short durations for UI (150–300 ms). Long animations make the interface feel slow.
- Respect
prefers-reduced-motion. This site does it globally; that is why, with “reduce motion” enabled in the system, the demos above are almost instant.
@media (prefers-reduced-motion: reduce) {
.enter, .leave { animation-duration: 1ms; }
::view-transition-group(*) { animation: none; }
}Items entering and leaving a list are animated with animate.enter on each @for row (see Control flow: @if, @for, @switch). On very long lists, animate only what is on screen: Huge lists: virtual scroll. What an animation costs and how to measure it, in Performance — a checklist.
When YES, when NO
animate.enter/animate.leavefor elements that appear and disappear: lists, toasts, panels, dialogs.- The function form when you need JavaScript: computed animations, GSAP, sequences.
- View Transitions for navigations and layout changes (list → detail), with an instant fallback.
- Plain CSS (
:hover,transition) for everything that doesn’t involve insertion or removal.
@angular/animationsin new code: it is deprecated. Keep it only where it already exists, until you migrate.- Animations on layout properties in large lists: they stutter.
- Animations that block interaction: the user waits for them to finish before being able to click.
- View Transitions on every small state change: it becomes tiring and uses resources.
Common pitfalls
animationComplete()forgotten in(animate.leave): the element stays visible for another 4 seconds.transitionwithout@starting-styleon enter: nothing animates, because there is no starting state.animate.leavein a child component when the parent is removed. The documentation specifies that the leave animations of children only run for elements in the same template. Putanimate.leaveon the child’s host element, in the parent’s template.- The two systems in the same component (or through content projection): undefined behavior.
startViewTransitionwithout a synchronous update in a zoneless application: the browser takes the snapshot of the old state twice and nothing animates.
Cheat sheet
ENTER/LEAVE (since v20.2, in @angular/core — no separate package)
animate.enter="class" the class is added on insertion and removed at the end of the animation
animate.leave="class" the element stays in the DOM until the animation ends
[animate.enter]="expr" string, array of classes, signal
(animate.leave)="fn($event)" AnimationCallbackEvent: target + animationComplete() — REQUIRED on leave
MAX_ANIMATION_TIMEOUT how long Angular waits for an unfinished leave (default 4 s)
CSS
keyframes: the class contains the animation · transition: class = final state + @starting-style = start
animate transform and opacity (compositor), not width/height/top (layout)
prefers-reduced-motion — respect it
VIEW TRANSITIONS
provideRouter(routes, withViewTransitions({ skipInitialTransition, onViewTransitionCreated })) — developer preview
view-transition-name: x · ::view-transition-old/new(x) · document.startViewTransition(cb)
@angular/animations deprecated since v20.2 — only for existing code; don't mix it with animate.* in the same component