Angular’s animation system provides a declarative way to define complex animations deeply integrated with change detection and component lifecycle. Unlike raw CSS animations, Angular animations respond to component state, route changes, and data-driven conditions. The syntax — triggers, transitions, states, and keyframes — enables everything from simple fade-ins to choreographed multi-element transitions with staggered list effects.
Animation API Building Blocks
| Function | Purpose | Example |
|---|---|---|
trigger(name, [...]) |
Named trigger — binds to template with [@name] |
trigger('fade', [...]) |
state(name, style) |
Define styles for a named state | state('visible', style({ opacity: 1 })) |
transition(expr, [...]) |
Define what happens between states | transition(':enter', [...]) |
style(styles) |
CSS styles at a point in the animation | style({ opacity: 0, transform: 'translateY(-20px)' }) |
animate(timing, style) |
Animate to target styles over time | animate('300ms ease-out', style({ opacity: 1 })) |
keyframes([...]) |
Multiple waypoints within a single animate() | animate('600ms', keyframes([...])) |
group([...]) |
Run multiple animations simultaneously | group([query(':enter', [...]), query(':leave', [...])]) |
query(selector, [...]) |
Apply animations to child elements | query('.task-card', [...]) |
stagger(time, [...]) |
Apply delay between animations of matched elements | stagger('50ms', [animate(...)]) |
Common Transition Expressions
| Expression | Fires When |
|---|---|
':enter' |
Element added to DOM (alias for void => *) |
':leave' |
Element removed from DOM (alias for * => void) |
'* => *' |
Any state to any state |
'open => closed' |
Named state transitions |
':increment' |
Numeric value increases |
provideAnimations() in app.config.ts. Without it, animation triggers are silently ignored. For applications that need animations only on specific pages, use provideAnimationsAsync() — it lazily loads the animations library only when an animated component is first rendered, reducing the initial bundle. Import it from @angular/platform-browser/animations/async.stagger() function creates the popular “waterfall” effect where items animate in one after another. Combine it with query(): query(':enter', stagger('60ms', [animate('300ms ease-out', style({ opacity: 1 }))])). Bind the trigger to the parent container, not each item — the trigger fires when *ngFor adds items, and query(':enter') finds the newly added children.display: none or visibility: hidden do not work with :enter and :leave transitions — those transitions are triggered by *ngIf or @if adding/removing elements from the DOM. If you toggle visibility without removing from the DOM, use named states ('visible' vs 'hidden') bound to a boolean property.Complete Animation Examples
import {
trigger, state, style, animate, transition,
keyframes, group, query, stagger,
} from '@angular/animations';
import { provideAnimations } from '@angular/platform-browser/animations';
// ── 1. Simple fade in/out ─────────────────────────────────────────────────
export const fadeAnimation = trigger('fade', [
transition(':enter', [
style({ opacity: 0 }),
animate('300ms ease-out', style({ opacity: 1 })),
]),
transition(':leave', [
animate('200ms ease-in', style({ opacity: 0 })),
]),
]);
// Usage: <div *ngIf="show" [@fade]>Content</div>
// ── 2. Slide down from top ────────────────────────────────────────────────
export const slideDown = trigger('slideDown', [
transition(':enter', [
style({ transform: 'translateY(-100%)', opacity: 0 }),
animate('300ms cubic-bezier(0.4, 0, 0.2, 1)',
style({ transform: 'translateY(0)', opacity: 1 })
),
]),
transition(':leave', [
animate('250ms ease-in', style({ transform: 'translateY(-100%)', opacity: 0 })),
]),
]);
// ── 3. Expand/collapse ───────────────────────────────────────────────────
export const expandCollapse = trigger('expandCollapse', [
state('collapsed', style({ height: '0px', overflow: 'hidden', opacity: 0 })),
state('expanded', style({ height: '*', overflow: 'visible', opacity: 1 })),
transition('collapsed <=> expanded', [animate('300ms ease-in-out')]),
]);
// Usage: <div [@expandCollapse]="isExpanded ? 'expanded' : 'collapsed'">
// ── 4. Keyframe bounce ────────────────────────────────────────────────────
export const bounce = trigger('bounce', [
transition(':enter', [
animate('600ms ease', keyframes([
style({ transform: 'scale(0)', opacity: 0, offset: 0 }),
style({ transform: 'scale(1.2)', opacity: 0.8, offset: 0.6 }),
style({ transform: 'scale(0.9)', opacity: 0.9, offset: 0.8 }),
style({ transform: 'scale(1)', opacity: 1, offset: 1 }),
])),
]),
]);
// ── 5. Staggered list entry ───────────────────────────────────────────────
export const listAnimation = trigger('listAnimation', [
transition('* => *', [
query(':enter', [
style({ opacity: 0, transform: 'translateX(-20px)' }),
stagger('60ms', [
animate('300ms ease-out', style({ opacity: 1, transform: 'translateX(0)' })),
]),
], { optional: true }),
query(':leave', [
stagger('30ms', [
animate('200ms ease-in', style({ opacity: 0, transform: 'translateX(20px)' })),
]),
], { optional: true }),
]),
]);
// Usage on parent container:
// <ul [@listAnimation]="tasks().length">
// <li *ngFor="...">...</li>
// </ul>
// ── Component with animations ──────────────────────────────────────────────
@Component({
selector: 'app-task-list',
standalone: true,
animations: [listAnimation, fadeAnimation],
template: `
<div [@fade]>
<ul [@listAnimation]="tasks().length">
<li *ngFor="let task of tasks(); trackBy: trackById">
<app-task-card [task]="task"></app-task-card>
</li>
</ul>
</div>
`,
})
export class TaskListComponent {
tasks = input.required<Task[]>();
trackById = (_: number, t: Task) => t._id;
}
How It Works
Step 1 — Triggers Are Registered on Components
Animations are declared in the animations array of the @Component decorator. When Angular renders the template, elements with [@triggerName] bindings are tracked. When the bound value changes or an element enters/leaves the DOM, Angular fires the matching transition.
Step 2 — :enter and :leave Track DOM Presence
:enter fires when Angular adds an element to the DOM via *ngIf or *ngFor. :leave fires when Angular removes it. Angular delays the actual DOM removal for the duration of the leave animation — the element remains visible during its exit, then is removed when the animation completes.
Step 3 — query() Targets Descendant Elements
query(':enter') selects all newly added elements within the current animated element. This enables animating children from a parent trigger. The { optional: true } option prevents errors when no elements match the query (e.g. empty list).
Step 4 — stagger() Creates Sequential Delays
stagger('60ms', animations) applies the given animations to each queried element with an increasing delay — first at 0ms, second at 60ms, third at 120ms. For 10 items with 60ms stagger and 300ms animation, the last item finishes at 60×9 + 300 = 840ms.
Step 5 — Animation Callbacks Enable Post-Animation Logic
(@triggerName.done) fires after an animation completes. Use this for cleanup after leave animations — remove data from a list, navigate to a different route, or emit an event. The callback receives an AnimationEvent with fromState, toState, totalTime, and triggerName.
Common Mistakes
Mistake 1 — Forgetting { optional: true } on query with dynamic lists
❌ Wrong — error when list is empty:
query(':enter', stagger('50ms', animate('300ms', style({ opacity: 1 }))))
// Error: "query() returned zero elements!" on empty list
✅ Correct:
query(':enter', stagger('50ms', animate('300ms', style({ opacity: 1 }))),
{ optional: true })
Mistake 2 — Binding stagger trigger to each list item instead of parent
❌ Wrong — all items animate simultaneously:
<li *ngFor="let task of tasks()" [@listAnimation]>
✅ Correct — trigger on parent, query items inside:
<ul [@listAnimation]="tasks().length">
<li *ngFor="let task of tasks()">
Mistake 3 — Using CSS display:none instead of *ngIf for enter/leave
❌ Wrong — :enter/:leave don’t fire for CSS visibility changes:
<div [style.display]="show ? 'block' : 'none'" [@fade]>
✅ Correct — use *ngIf for DOM add/remove:
<div *ngIf="show" [@fade]>
Quick Reference
| Task | Code |
|---|---|
| Register animation | @Component({ animations: [myTrigger] }) |
| Bind trigger | [@triggerName]="state" |
| Enter/leave | transition(':enter', [...]) / transition(':leave', [...]) |
| Named states | state('open', style({...})), transition('open => closed', ...) |
| Staggered list | query(':enter', stagger('50ms', [animate(...)]), { optional: true }) |
| After animation | (@trigger.done)="onDone($event)" |
| Parallel animations | group([animate('300ms', ...), animate('200ms', ...)]) |
| Enable animations | provideAnimations() in app.config.ts |