Skip to main content

Documentation Index

Fetch the complete documentation index at: https://resources.devweekends.com/llms.txt

Use this file to discover all available pages before exploring further.

Angular Animations

Animations Overview

Estimated Time: 2 hours | Difficulty: Intermediate | Prerequisites: Components, Templates
Angular’s animation system is built on the Web Animations API, providing a powerful DSL (Domain-Specific Language) for creating complex, performant animations. Unlike CSS animations, Angular animations are state-aware — they know about your component’s data, can react to route changes, and can coordinate across multiple elements. Think of CSS animations as a record player (plays the same thing every time) versus Angular animations as a live DJ (responds to what is happening in real-time). When to use Angular animations vs CSS: Use CSS for simple hover effects, loading spinners, or animations that do not depend on component state. Use Angular animations when the animation needs to react to data changes, coordinate enter/leave transitions, stagger list items, or synchronize with route navigation.
┌─────────────────────────────────────────────────────────────────────────┐
│              Angular Animation Building Blocks                           │
├─────────────────────────────────────────────────────────────────────────┤
│                                                                          │
│   trigger('name', [...])                                                │
│       │                                                                  │
│       ├── state('stateName', style({...}))                              │
│       │       Define styles for a specific state                         │
│       │                                                                  │
│       ├── transition('stateA => stateB', [...])                         │
│       │       │                                                          │
│       │       ├── animate('duration easing', style({...}))              │
│       │       │       Animate to a style                                 │
│       │       │                                                          │
│       │       ├── group([...])                                          │
│       │       │       Run animations in parallel                         │
│       │       │                                                          │
│       │       ├── sequence([...])                                       │
│       │       │       Run animations in sequence                         │
│       │       │                                                          │
│       │       ├── query('selector', [...])                              │
│       │       │       Animate child elements                             │
│       │       │                                                          │
│       │       └── stagger('timing', [...])                              │
│       │               Stagger animations for multiple elements           │
│       │                                                                  │
│       └── transition(':enter/:leave', [...])                            │
│               Animate element entry/exit                                 │
│                                                                          │
└─────────────────────────────────────────────────────────────────────────┘

Setup

// app.config.ts
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';

export const appConfig: ApplicationConfig = {
  providers: [
    // provideAnimationsAsync() lazy-loads the animation engine (~60KB),
    // so users who never trigger an animation don't pay the cost.
    // Use provideAnimations() instead only if animations appear on the
    // initial render and you can't afford the flash of unstyled content.
    provideAnimationsAsync()
  ]
};
Common gotcha: If you forget to provide either provideAnimations() or provideAnimationsAsync(), your [@trigger] bindings will silently do nothing. No error, no animation — just a confusing lack of movement. This is the single most common “why isn’t my animation working?” issue.

Basic Animations

State-Based Animation

// expandable-panel.component.ts
import { Component, signal } from '@angular/core';
import { 
  trigger, 
  state, 
  style, 
  transition, 
  animate 
} from '@angular/animations';

@Component({
  selector: 'app-expandable-panel',
  standalone: true,
  animations: [
    trigger('expandCollapse', [
      state('collapsed', style({
        height: '0px',
        opacity: 0,
        overflow: 'hidden'
      })),
      state('expanded', style({
        height: '*',  // Auto height
        opacity: 1,
        overflow: 'visible'
      })),
      transition('collapsed <=> expanded', [
        animate('300ms ease-in-out')
      ])
    ])
  ],
  template: `
    <div class="panel">
      <div class="header" (click)="toggle()">
        <span>{{ title }}</span>
        <span class="icon" [@rotateIcon]="isExpanded() ? 'expanded' : 'collapsed'">

        </span>
      </div>
      <div class="content" [@expandCollapse]="isExpanded() ? 'expanded' : 'collapsed'">
        <ng-content />
      </div>
    </div>
  `
})
export class ExpandablePanelComponent {
  title = input('Panel');
  isExpanded = signal(false);
  
  toggle() {
    this.isExpanded.update(v => !v);
  }
}

Enter/Leave Animations

// notification.component.ts
@Component({
  selector: 'app-notification',
  standalone: true,
  animations: [
    trigger('slideInOut', [
      transition(':enter', [
        style({ 
          transform: 'translateX(100%)', 
          opacity: 0 
        }),
        animate('300ms ease-out', style({ 
          transform: 'translateX(0)', 
          opacity: 1 
        }))
      ]),
      transition(':leave', [
        animate('200ms ease-in', style({ 
          transform: 'translateX(100%)', 
          opacity: 0 
        }))
      ])
    ])
  ],
  template: `
    @if (visible()) {
      <div class="notification" [@slideInOut] [class]="type()">
        <span class="message">{{ message() }}</span>
        <button (click)="dismiss()">×</button>
      </div>
    }
  `
})
export class NotificationComponent {
  message = input.required<string>();
  type = input<'success' | 'error' | 'warning'>('success');
  visible = signal(true);
  dismissed = output<void>();
  
  dismiss() {
    this.visible.set(false);
    this.dismissed.emit();
  }
}

Advanced Animations

Staggered List Animation

Staggering creates a “cascade” effect where each item animates with a slight delay after the previous one. This is the animation pattern that makes list UIs feel polished — think of how iOS notification center items slide in one after another rather than all at once. The key is stagger(), which adds an incremental delay to each :enter or :leave element matched by query().
// task-list.component.ts
@Component({
  selector: 'app-task-list',
  standalone: true,
  animations: [
    trigger('listAnimation', [
      transition('* => *', [
        query(':enter', [
          style({ opacity: 0, transform: 'translateY(-20px)' }),
          stagger('50ms', [
            animate('300ms ease-out', style({ 
              opacity: 1, 
              transform: 'translateY(0)' 
            }))
          ])
        ], { optional: true }),
        query(':leave', [
          stagger('30ms', [
            animate('200ms ease-in', style({ 
              opacity: 0, 
              transform: 'translateX(-100%)' 
            }))
          ])
        ], { optional: true })
      ])
    ]),
    
    trigger('taskItem', [
      state('active', style({
        backgroundColor: '#e0f2fe',
        transform: 'scale(1.02)'
      })),
      state('completed', style({
        backgroundColor: '#dcfce7',
        opacity: 0.7
      })),
      transition('* => active', animate('200ms ease-out')),
      transition('active => completed', [
        animate('300ms ease-in', style({
          backgroundColor: '#dcfce7'
        })),
        animate('200ms', style({
          opacity: 0.7
        }))
      ])
    ])
  ],
  template: `
    <div class="task-list" [@listAnimation]="tasks().length">
      @for (task of tasks(); track task.id) {
        <div 
          class="task-item" 
          [@taskItem]="task.status"
          (click)="toggleTask(task)"
        >
          <input 
            type="checkbox" 
            [checked]="task.status === 'completed'"
            (change)="toggleTask(task)"
          />
          <span [class.completed]="task.status === 'completed'">
            {{ task.title }}
          </span>
        </div>
      }
    </div>
  `
})
export class TaskListComponent {
  tasks = input.required<Task[]>();
  taskToggled = output<Task>();
  
  toggleTask(task: Task) {
    this.taskToggled.emit(task);
  }
}

Page Transition Animation

// app.component.ts
@Component({
  selector: 'app-root',
  standalone: true,
  imports: [RouterOutlet],
  animations: [
    trigger('routeAnimations', [
      transition('* <=> *', [
        // Query outgoing page
        query(':leave', [
          style({ position: 'absolute', width: '100%' }),
          animate('300ms ease-in', style({
            opacity: 0,
            transform: 'translateX(-100%)'
          }))
        ], { optional: true }),
        
        // Query incoming page
        query(':enter', [
          style({
            position: 'absolute',
            width: '100%',
            opacity: 0,
            transform: 'translateX(100%)'
          }),
          animate('300ms ease-out', style({
            opacity: 1,
            transform: 'translateX(0)'
          }))
        ], { optional: true })
      ])
    ])
  ],
  template: `
    <div class="app-container" [@routeAnimations]="getRouteAnimationData()">
      <router-outlet />
    </div>
  `
})
export class AppComponent {
  private route = inject(ActivatedRoute);
  
  getRouteAnimationData() {
    return this.route.firstChild?.snapshot?.data?.['animation'] ?? 'default';
  }
}

// app.routes.ts
export const routes: Routes = [
  { 
    path: '', 
    loadComponent: () => import('./home.component'),
    data: { animation: 'HomePage' }
  },
  { 
    path: 'about', 
    loadComponent: () => import('./about.component'),
    data: { animation: 'AboutPage' }
  }
];

Complex Multi-Step Animation

// card-flip.component.ts
@Component({
  selector: 'app-card-flip',
  standalone: true,
  animations: [
    trigger('cardFlip', [
      state('front', style({
        transform: 'rotateY(0deg)'
      })),
      state('back', style({
        transform: 'rotateY(180deg)'
      })),
      transition('front => back', [
        group([
          query('.front', [
            animate('400ms ease-in', style({
              transform: 'rotateY(180deg)'
            }))
          ]),
          query('.back', [
            style({ transform: 'rotateY(-180deg)' }),
            animate('400ms ease-out', style({
              transform: 'rotateY(0deg)'
            }))
          ])
        ])
      ]),
      transition('back => front', [
        group([
          query('.front', [
            style({ transform: 'rotateY(180deg)' }),
            animate('400ms ease-out', style({
              transform: 'rotateY(0deg)'
            }))
          ]),
          query('.back', [
            animate('400ms ease-in', style({
              transform: 'rotateY(-180deg)'
            }))
          ])
        ])
      ])
    ])
  ],
  template: `
    <div 
      class="card-container" 
      [@cardFlip]="side()"
      (click)="flip()"
    >
      <div class="card front">
        <ng-content select="[front]" />
      </div>
      <div class="card back">
        <ng-content select="[back]" />
      </div>
    </div>
  `,
  styles: [`
    .card-container {
      perspective: 1000px;
      cursor: pointer;
    }
    .card {
      backface-visibility: hidden;
      position: absolute;
      width: 100%;
      height: 100%;
    }
    .back {
      transform: rotateY(180deg);
    }
  `]
})
export class CardFlipComponent {
  side = signal<'front' | 'back'>('front');
  
  flip() {
    this.side.update(s => s === 'front' ? 'back' : 'front');
  }
}

Reusable Animation Functions

// animations/shared.animations.ts
import { 
  animation, 
  style, 
  animate, 
  trigger, 
  transition, 
  useAnimation,
  query,
  stagger,
  keyframes
} from '@angular/animations';

// Reusable animation definitions
export const fadeIn = animation([
  style({ opacity: 0 }),
  animate('{{ duration }} {{ easing }}', style({ opacity: 1 }))
], {
  params: { duration: '300ms', easing: 'ease-out' }
});

export const fadeOut = animation([
  animate('{{ duration }} {{ easing }}', style({ opacity: 0 }))
], {
  params: { duration: '200ms', easing: 'ease-in' }
});

export const slideInLeft = animation([
  style({ transform: 'translateX(-100%)', opacity: 0 }),
  animate('{{ duration }} {{ easing }}', style({ 
    transform: 'translateX(0)', 
    opacity: 1 
  }))
], {
  params: { duration: '300ms', easing: 'ease-out' }
});

export const slideOutRight = animation([
  animate('{{ duration }} {{ easing }}', style({ 
    transform: 'translateX(100%)', 
    opacity: 0 
  }))
], {
  params: { duration: '200ms', easing: 'ease-in' }
});

export const bounce = animation([
  animate('{{ duration }}', keyframes([
    style({ transform: 'translateY(0)', offset: 0 }),
    style({ transform: 'translateY(-30px)', offset: 0.3 }),
    style({ transform: 'translateY(0)', offset: 0.5 }),
    style({ transform: 'translateY(-15px)', offset: 0.7 }),
    style({ transform: 'translateY(0)', offset: 1 })
  ]))
], {
  params: { duration: '500ms' }
});

export const shake = animation([
  animate('{{ duration }}', keyframes([
    style({ transform: 'translateX(0)', offset: 0 }),
    style({ transform: 'translateX(-10px)', offset: 0.2 }),
    style({ transform: 'translateX(10px)', offset: 0.4 }),
    style({ transform: 'translateX(-10px)', offset: 0.6 }),
    style({ transform: 'translateX(10px)', offset: 0.8 }),
    style({ transform: 'translateX(0)', offset: 1 })
  ]))
], {
  params: { duration: '400ms' }
});

// Reusable trigger factories
export function fadeInOutTrigger(name: string = 'fadeInOut') {
  return trigger(name, [
    transition(':enter', useAnimation(fadeIn)),
    transition(':leave', useAnimation(fadeOut))
  ]);
}

export function slideInOutTrigger(name: string = 'slideInOut') {
  return trigger(name, [
    transition(':enter', useAnimation(slideInLeft)),
    transition(':leave', useAnimation(slideOutRight))
  ]);
}

export function listStaggerTrigger(name: string = 'listStagger') {
  return trigger(name, [
    transition('* => *', [
      query(':enter', [
        style({ opacity: 0, transform: 'translateY(-15px)' }),
        stagger('50ms', [
          animate('300ms ease-out', style({ 
            opacity: 1, 
            transform: 'translateY(0)' 
          }))
        ])
      ], { optional: true })
    ])
  ]);
}

// Usage in component
@Component({
  animations: [
    fadeInOutTrigger('notification'),
    listStaggerTrigger('items'),
    trigger('attention', [
      transition('* => bounce', useAnimation(bounce)),
      transition('* => shake', useAnimation(shake))
    ])
  ],
  template: `
    @if (showNotification()) {
      <div [@notification]>Notification</div>
    }
    
    <div [@items]="items().length">
      @for (item of items(); track item.id) {
        <div>{{ item.name }}</div>
      }
    </div>
    
    <button 
      [@attention]="attentionState()" 
      (@attention.done)="onAnimationDone()"
    >
      Click me
    </button>
  `
})
export class AnimatedComponent {
  showNotification = signal(true);
  items = signal<Item[]>([]);
  attentionState = signal('');
  
  triggerBounce() {
    this.attentionState.set('bounce');
  }
  
  triggerShake() {
    this.attentionState.set('shake');
  }
  
  onAnimationDone() {
    this.attentionState.set('');
  }
}

Animation Callbacks

@Component({
  animations: [
    trigger('flyInOut', [
      transition(':enter', [
        style({ transform: 'translateX(-100%)' }),
        animate('300ms ease-out')
      ]),
      transition(':leave', [
        animate('300ms ease-in', style({ transform: 'translateX(100%)' }))
      ])
    ])
  ],
  template: `
    @if (visible()) {
      <div 
        [@flyInOut]
        (@flyInOut.start)="onAnimationStart($event)"
        (@flyInOut.done)="onAnimationDone($event)"
      >
        Animated content
      </div>
    }
  `
})
export class CallbackComponent {
  visible = signal(true);
  isAnimating = signal(false);
  
  onAnimationStart(event: AnimationEvent) {
    this.isAnimating.set(true);
    console.log('Animation started:', {
      fromState: event.fromState,
      toState: event.toState,
      totalTime: event.totalTime
    });
  }
  
  onAnimationDone(event: AnimationEvent) {
    this.isAnimating.set(false);
    console.log('Animation completed:', event.toState);
  }
}

Performance Tips

Use transform & opacity

Animate only transform and opacity for 60fps - they’re GPU accelerated

will-change Hint

Use will-change: transform for complex animations

Disable When Not Visible

Use @.disabled binding to disable animations conditionally

Lazy Load Animations

Use provideAnimationsAsync() to lazy-load animation code
// Disable animations conditionally
@Component({
  template: `
    <div 
      [@myAnimation]="state"
      [@.disabled]="prefersReducedMotion()"
    >
      Content
    </div>
  `
})
export class AccessibleComponent {
  private mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
  prefersReducedMotion = signal(this.mediaQuery.matches);
  
  constructor() {
    this.mediaQuery.addEventListener('change', (e) => {
      this.prefersReducedMotion.set(e.matches);
    });
  }
}

Next: Internationalization (i18n)

Add multi-language support to your Angular applications