Angular Animations — Triggers, Transitions, Keyframes, and Staggered Lists

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
Note: Angular animations require 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.
Tip: The 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.
Warning: Animations using 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

🧠 Test Yourself

A task list uses [@listAnimation]="tasks().length" on the <ul> with query(':enter', stagger('60ms', [...])). When 3 new tasks are added at once, how do they animate?