Stagger Reveal
Reveal a list of elements one after another using gsap.from and stagger.
01
Demo
Design
Build
Animate
Test
Ship
Measure
How it works
The demo renders six cards with a plain @for loop and a template reference variable, #item, on each one. A signal query, viewChildren<ElementRef>('item'), collects those elements once the view has rendered. A single gsap.from() call then animates all of them together โ fading in and sliding up โ with the stagger option spacing out each element's start time by 80ms.
Because it's one tween targeting an array of elements rather than one tween per card, GSAP only has to manage a single timeline internally, which is both simpler to clean up and cheaper to run.
The Angular way
- The component is standalone with OnPush change detection โ GSAP mutates the DOM directly, so there's no reactive state for Angular to track once the animation starts.
viewChildren('item')is a signal-based query, so the list of elements is always read fresh fromitemRefs()instead of being cached as a mutable field.- GSAP is imported lazily with
await import('gsap')insideafterNextRender(). This project's SSR setup still runsafterRenderhooks during prerendering, so the callback also checksisPlatformBrowser()before touchingmatchMediaor GSAP โ the same guard used everywhere else in this codebase. DestroyRef.onDestroy()kills the tween when the component is torn down, so nothing keeps animating (or keeps a reference alive) after a route change.
Source code
import { isPlatformBrowser } from '@angular/common';
import {
ChangeDetectionStrategy,
Component,
DestroyRef,
ElementRef,
PLATFORM_ID,
afterNextRender,
inject,
signal,
viewChildren,
} from '@angular/core';
import type { gsap } from 'gsap';
interface StaggerItem {
id: number;
label: string;
emoji: string;
}
const ITEMS: StaggerItem[] = [
{ id: 1, label: 'Design', emoji: '๐จ' },
{ id: 2, label: 'Build', emoji: '๐ ๏ธ' },
{ id: 3, label: 'Animate', emoji: 'โจ' },
{ id: 4, label: 'Test', emoji: '๐งช' },
{ id: 5, label: 'Ship', emoji: '๐' },
{ id: 6, label: 'Measure', emoji: '๐' },
];
@Component({
selector: 'app-stagger-reveal-demo',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="w-full max-w-2xl">
<div class="grid grid-cols-2 sm:grid-cols-3 gap-4">
@for (item of items(); track item.id) {
<div
#item
class="flex flex-col items-center gap-2 rounded-2xl border border-border bg-card p-6 text-center"
>
<span class="text-3xl" aria-hidden="true">{{ item.emoji }}</span>
<span class="text-sm font-semibold text-foreground">{{
item.label
}}</span>
</div>
}
</div>
<button
type="button"
class="mt-6 inline-flex items-center gap-2 rounded-lg border border-border bg-card px-4 py-2 text-sm font-medium text-foreground hover:border-primary/50 transition-colors"
(click)="replay()"
>
<span aria-hidden="true">โป</span> Replay
</button>
</div>
`,
})
export default class StaggerRevealDemo {
private readonly destroyRef = inject(DestroyRef);
private readonly platformId = inject(PLATFORM_ID);
private readonly itemRefs = viewChildren<ElementRef<HTMLElement>>('item');
private gsapInstance: typeof import('gsap').default | null = null;
private tween: gsap.core.Tween | null = null;
items = signal(ITEMS);
constructor() {
afterNextRender(async () => {
if (!isPlatformBrowser(this.platformId)) return;
const { gsap } = await import('gsap');
this.gsapInstance = gsap;
this.play();
});
this.destroyRef.onDestroy(() => this.tween?.kill());
}
private play() {
if (!this.gsapInstance) return;
const targets = this.itemRefs().map((ref) => ref.nativeElement);
if (!targets.length) return;
this.tween?.kill();
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
this.gsapInstance.set(targets, { opacity: 1, y: 0 });
return;
}
this.tween = this.gsapInstance.from(targets, {
opacity: 0,
y: 24,
duration: 0.6,
ease: 'power3.out',
stagger: 0.08,
});
}
replay() {
this.play();
}
}
Implementation recipe
- Create the standalone component with
OnPush. - Add the static markup: a
@forloop over your data, with a#itemtemplate reference on each element. - Query the animated elements with
viewChildren<ElementRef>('item'). - Lazy-load GSAP with
await import('gsap')insideafterNextRender(). - Build the animation:
gsap.from(targets, { opacity: 0, y: 24, stagger: 0.08 }). - Add cleanup: kill the tween in
destroyRef.onDestroy(). - Add reduced motion: check
matchMedia('(prefers-reduced-motion: reduce)')and callgsap.set()to the resting state instead of animating. - Test keyboard/accessibility: confirm the cards are readable and in the DOM before the animation runs, and that reduced motion leaves them fully visible immediately.
Accessibility notes
- prefers-reduced-motion
- content is in the DOM and readable before it animates
Performance notes
- animate transform and opacity only
- one tween for the whole list instead of one per item
Common pitfalls
- Animating one card per tween instead of passing the whole array to a single
gsap.from()call โ it works, but it's harder to stagger consistently and harder to clean up. - Reading
itemRefs()before the view has rendered โ always trigger the first animation from insideafterNextRender(), not the constructor. - Forgetting
stagger's unit is seconds between starts, not total duration โ a large list with a large stagger can take longer to finish than expected.