Animations Overview
Estimated Time: 2 hours | Difficulty: Intermediate | Prerequisites: Components, Templates
Copy
┌─────────────────────────────────────────────────────────────────────────┐
│ 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
Copy
// 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
Copy
// 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
Copy
// 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
Copy
// 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
Copy
// 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
Copy
// 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
Copy
// 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
Copy
@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 animationsDisable When Not Visible
Use
@.disabled binding to disable animations conditionallyLazy Load Animations
Use
provideAnimationsAsync() to lazy-load animation codeCopy
// 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