Skip to main content
← Back to Motion Recipes
intermediateScroll

Scroll Reveal

Reveal an element as it enters the viewport using an SSR-safe ScrollTrigger directive.

attribute directiveinput() signalsDestroyRefisPlatformBrowserScrollTriggertimelinescrubcustom scroller
02

Demo

Scroll inside this box ↓

Scroll down ↓

Each card fades and slides in as it crosses this container — not the page.

Scoped ScrollTrigger

andGsapScroll points its ScrollTrigger at this box through the scroller input.

Cleaned up on destroy

Every ScrollTrigger created here is killed in ngOnDestroy, so nothing leaks on route change.

How it works

andGsapScroll is an attribute directive: drop it on any element with a from or to input and it creates a GSAP timeline driven by a ScrollTrigger scoped to that one element. The demo box scrolls independently of the page, so the directive's scroller input points ScrollTrigger at the box itself (via a CSS selector) instead of the window.

Each card gets its own ScrollTrigger instance, created in ngAfterViewInit and destroyed in ngOnDestroy — so scrolling the container, resizing the window, or navigating away never leaves a stale trigger listening in the background.

The Angular way

  • Both gsap and gsap/ScrollTrigger are imported with await Promise.all([import('gsap'), import('gsap/ScrollTrigger')]) inside ngAfterViewInit, guarded by isPlatformBrowser(this.platformId) — so the plugin is never registered during SSR or prerendering.
  • All directive options — start, end, scrub, scroller, from, to — are input() signals, matching the other andGsap* directives in this project.
  • ngOnDestroy calls this.tl?.scrollTrigger?.kill() before this.tl?.kill() — killing the trigger first prevents it from firing once more during teardown.

Source code

import { ChangeDetectionStrategy, Component } from '@angular/core';
import { AndGsapScrollDirective } from '@shared/directives/and-gsap-scroll.directive';

interface ScrollSection {
  id: number;
  title: string;
  body: string;
}

const SECTIONS: ScrollSection[] = [
  {
    id: 1,
    title: 'Scroll down ↓',
    body: 'Each card fades and slides in as it crosses this container — not the page.',
  },
  {
    id: 2,
    title: 'Scoped ScrollTrigger',
    body: 'andGsapScroll points its ScrollTrigger at this box through the scroller input.',
  },
  {
    id: 3,
    title: 'Cleaned up on destroy',
    body: 'Every ScrollTrigger created here is killed in ngOnDestroy, so nothing leaks on route change.',
  },
];

@Component({
  selector: 'app-scroll-reveal-demo',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [AndGsapScrollDirective],
  template: `
    <div
      data-scroll-demo-viewport
      class="h-80 w-full max-w-md overflow-y-auto rounded-2xl border border-border bg-card/50 p-6"
    >
      <p class="mb-32 text-sm text-muted-foreground">
        Scroll inside this box ↓
      </p>
      @for (section of sections; track section.id) {
        <div
          andGsapScroll
          scroller="[data-scroll-demo-viewport]"
          start="top 85%"
          [from]="{ opacity: 0, y: 40, duration: 0.6, ease: 'power3.out' }"
          class="mb-32 rounded-2xl border border-border bg-background p-6 last:mb-0"
        >
          <h4 class="font-semibold text-foreground">{{ section.title }}</h4>
          <p class="mt-2 text-sm text-muted-foreground">{{ section.body }}</p>
        </div>
      }
    </div>
  `,
})
export default class ScrollRevealDemo {
  sections = SECTIONS;
}

Implementation recipe

  1. Create the directive as a standalone @Directive({ selector: '[andGsapScroll]' }).
  2. Add the static markup: any element with the attribute and a from/to object.
  3. Query the animated element with ElementRef via inject() — the directive's host is the trigger.
  4. Lazy-load GSAP and ScrollTrigger together inside ngAfterViewInit, behind an isPlatformBrowser guard.
  5. Build the animation: a gsap.timeline({ scrollTrigger: { trigger, scroller, start, scrub } }).
  6. Add cleanup: kill the scrollTrigger and the timeline in ngOnDestroy.
  7. Add reduced motion: skip the scroll-linked animation entirely and jump straight to the resting state when prefers-reduced-motion: reduce matches.
  8. Test keyboard/accessibility: the content must already be present and readable in the DOM — scroll-triggered reveals should never gate content behind JavaScript execution.

Accessibility notes

  • prefers-reduced-motion
  • content readable without JavaScript

Performance notes

  • kill the ScrollTrigger and timeline on destroy
  • animate transform and opacity, never layout properties

Common pitfalls

  • Importing gsap/ScrollTrigger at the top of the file and calling gsap.registerPlugin() in the constructor — constructors run during SSR too, so this can execute browser-only plugin code on the server. Always lazy-load it inside a browser-guarded lifecycle hook.
  • Forgetting to pass scroller when the trigger lives inside a scrollable container instead of the page — without it, ScrollTrigger watches the wrong scroll position.
  • Leaving a ScrollTrigger alive after a route change — always kill it in ngOnDestroy, not just the timeline.