Scroll Reveal
Reveal an element as it enters the viewport using an SSR-safe ScrollTrigger directive.
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
gsapandgsap/ScrollTriggerare imported withawait Promise.all([import('gsap'), import('gsap/ScrollTrigger')])insidengAfterViewInit, guarded byisPlatformBrowser(this.platformId)— so the plugin is never registered during SSR or prerendering. - All directive options —
start,end,scrub,scroller,from,to— areinput()signals, matching the otherandGsap*directives in this project. ngOnDestroycallsthis.tl?.scrollTrigger?.kill()beforethis.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
- Create the directive as a standalone
@Directive({ selector: '[andGsapScroll]' }). - Add the static markup: any element with the attribute and a
from/toobject. - Query the animated element with
ElementRefviainject()— the directive's host is the trigger. - Lazy-load GSAP and
ScrollTriggertogether insidengAfterViewInit, behind anisPlatformBrowserguard. - Build the animation: a
gsap.timeline({ scrollTrigger: { trigger, scroller, start, scrub } }). - Add cleanup: kill the
scrollTriggerand the timeline inngOnDestroy. - Add reduced motion: skip the scroll-linked animation entirely and jump straight to the resting state when
prefers-reduced-motion: reducematches. - 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/ScrollTriggerat the top of the file and callinggsap.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
scrollerwhen the trigger lives inside a scrollable container instead of the page — without it,ScrollTriggerwatches the wrong scroll position. - Leaving a
ScrollTriggeralive after a route change — always kill it inngOnDestroy, not just the timeline.