← All guides

Guide · Motion

GSAP ScrollTrigger in Angular, built with Claude Code

Scroll animations that load late, respect reduced motion, clean up after themselves and don't break prerendering. All in one 50-line file.

Last updated

The rules I gave Claude. Animation must never cost the first paint, never run during the prerender, never hide content if JavaScript fails, and never run for people who've turned motion off. Every part of the helper below exists because of one of those rules.

1Load GSAP on the first interaction, not on page load

GSAP and ScrollTrigger together are about 110 KB before compression. Nobody needs a scroll animation before they scroll, so the import waits for the first scroll, touch or key press:

let interacted: Promise<void> | undefined;
const firstInteraction = () => interacted ??= new Promise<void>((resolve) => {
  const events = ['scroll', 'wheel', 'touchstart', 'pointerdown', 'keydown'];
  const go = () => { events.forEach((e) => removeEventListener(e, go)); resolve(); };
  events.forEach((e) => addEventListener(e, go, { passive: true }));
});

async function loadGsap() {
  await firstInteraction();
  const [{ gsap }, { ScrollTrigger }] = await Promise.all([import('gsap'), import('gsap/ScrollTrigger')]);
  gsap.registerPlugin(ScrollTrigger);
  return { gsap, ScrollTrigger };
}

The dynamic import() also puts GSAP in its own chunk, so it's not in the main bundle at all.

2Run only in the browser, and only with motion on

This site is prerendered (see deploying Angular to Cloudflare), so component code also runs in Node at build time. afterNextRender never runs there, which keeps GSAP out of the build. The reduced-motion check comes first:

export function useMotion(setup: (gsap: Gsap, st: ScrollTriggerT, host: HTMLElement) => void) {
  const host = inject(ElementRef).nativeElement as HTMLElement;
  let ctx: { revert(): void } | undefined, destroyed = false;
  inject(DestroyRef).onDestroy(() => { destroyed = true; ctx?.revert(); });
  afterNextRender(async () => {
    if (!prefersMotion()) return;
    const { gsap, ScrollTrigger } = await loadGsap();
    if (!destroyed) ctx = gsap.context(() => setup(gsap, ScrollTrigger, host), host);
  });
}

gsap.context records every tween and ScrollTrigger the page made, so ctx.revert() removes them all when you navigate away. No leaked triggers firing on the next page.

3Use it from a page

A page passes a setup function. Selectors are scoped to the page's own element:

constructor() {
  useMotion((gsap, st, host) => {
    gsap.to(host.querySelector('.topo'), {
      yPercent: 25, ease: 'none',
      scrollTrigger: { trigger: host.querySelector('.hero'), start: 'top top', end: 'bottom top', scrub: true },
    });
  });
}

4Never hide what's already on screen

The obvious reveal sets every card to opacity: 0 and fades them in. Because GSAP loads late, cards that are already visible would blink out and back in. The fix: only hide elements that are still below the fold.

export function revealOnScroll(gsap: Gsap, st: ScrollTriggerT, host: HTMLElement, selector: string) {
  const below = [...host.querySelectorAll<HTMLElement>(selector)]
    .filter((el) => el.getBoundingClientRect().top > innerHeight);
  if (!below.length) return;
  gsap.set(below, { opacity: 0, y: 40 });
  st.batch(below, { start: 'top 90%', once: true,
    onEnter: (b) => gsap.to(b, { opacity: 1, y: 0, duration: .8, ease: 'power3.out', stagger: .1 }) });
}

Content is visible in the prerendered HTML and stays visible if GSAP never loads. A unit test checks that no card starts with an inline opacity.

5Check layout shift, not just the look

A pinned section on one guide looked fine but scored a Cumulative Layout Shift of 0.105, just over Google's 0.1 limit, because pinning moved the box when the animation started. I found it by asking Claude to measure it in a real browser:

Open each page in headless Chrome, scroll it, and report the layout-shift entries:
which element moved and by how much. Also check that GSAP's chunk only loads after the
first scroll.

The redesigned home page keeps motion to one page-load sequence in CSS plus a slow parallax, and measures 0.

6Smooth scrolling, synced

Lenis makes the wheel scroll smooth. It must tell ScrollTrigger every time it moves, or triggers fire at the wrong spot:

const lenis = new Lenis();
lenis.on('scroll', ScrollTrigger.update);
gsap.ticker.add((t) => lenis.raf(t * 1000));
gsap.ticker.lagSmoothing(0);
Less is more. A fade-up on every section is the first thing an AI adds, and it makes a site feel generic. Pick one moment worth animating and keep the rest still.