Skip to main content
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.
┌─────────────────────────────────────────────────────────────────────────┐
│              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-load animation code
    // OR provideAnimations() for immediate loading
  ]
};

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

// 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