gsap and lenis are the runtimes — declared as peer dependencies, so you control the version.
Why
GSAP and ScrollTrigger are the heavy machinery behind scroll-driven sites, and wiring them into Angular means the same boilerplate every time: register plugins once, browser-only; do DOM work after render; scope everything to a gsap.context() so it reverts on destroy. This package is that boilerplate, written once, as directives.
Every directive early-returns on the server; nothing touches the DOM there.
Reduced motion is a first-class path — timelines jump to their final state, marquees freeze, counters print their final value, sequences draw their last frame.
Lenis smooth scroll is synced to ScrollTrigger and driven off gsap.ticker.
Triggers re-measure themselves after the page settles — marquee clones, SplitText rewraps and font swaps all move content that ScrollTrigger has already measured.
Requirements
TESTED AGAINST
NAME
TYPE
DEFAULT
NOTES
@angular/core
^22.0.0
22.0.5
Standalone APIs, signal inputs and afterNextRender are all required.
typescript
~6.0.0
6.0.3
Whatever your Angular version supports.
node
>=20
24.15.0
Build and SSR only.
gsap
^3.13.0
3.15.0
SplitText became free in 3.13, which is why the range starts there.
lenis
^1.3.0
1.3.25
Only used by ScrollService; the directives work without smooth scroll.
Peer ranges are wider than this — the table lists the exact versions the demos on this page are running, so you have a known-good combination to fall back on.
Providers
Required — provideGsap() goes in your application config. Without it ScrollTrigger and SplitText are never registered and every directive here silently does nothing.
Everything here is standalone, but standalone components and directives are importable from an @NgModule — put them in the module's imports, not declarations. No importProvidersFrom is needed: @NgModule.providers is typed Array<Provider | EnvironmentProviders>, so the provide*() functions drop straight in.
TS · app.module.ts
import { NgModule } from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { MarqueeDirective, CountUpDirective, SplitRevealDirective, ScrubTimelineDirective, FrameSequenceComponent, provideGsap } from '@code_with_sachin/ngx-gsap';
import { AppComponent } from './app.component';
@NgModule({
declarations: [AppComponent],
// Standalone components and directives go in `imports`.
imports: [
BrowserModule,
MarqueeDirective,
CountUpDirective,
SplitRevealDirective,
ScrubTimelineDirective,
FrameSequenceComponent,
],
providers: [provideGsap()],
bootstrap: [AppComponent],
})
export class AppModule {}
ngxMarquee
Wraps the host's content in a track and clones it until the row is at least twice the host width, then loops it linearly. Clones are aria-hidden.
CRAFT OVER HYPE·NO SECOND TRY·PIXELS ARE A PROMISE·
निश्चयरसअद्वितीयखड्ग
HTML
<div ngxMarquee [speed]="80" class="whitespace-nowrap">
<span>CRAFT OVER HYPE · </span>
</div>
<!-- direction -1 runs rightwards -->
<div ngxMarquee [speed]="140" [direction]="-1">…</div>
INPUTS
NAME
TYPE
DEFAULT
NOTES
speed
number
90
Pixels per second.
direction
1 | -1
1
1 scrolls leftwards, -1 rightwards.
ngxCountUp & ngxSplitReveal
Both fire on scroll. The demo remounts them so you can replay without scrolling away.
PROFESSIONALS
YEARS
SSR SAFE
Every pixel is a promise, and every promise is kept
Required. Target value; counting starts when the host hits 85% of the viewport, once.
duration
number
1.6
Seconds, eased with power2.out.
suffix
string
''
Appended to every frame — "+", "%", "k".
HTML
<h2 ngxSplitReveal mode="words" [stagger]="0.05">
Every pixel is a promise
</h2>
<!-- ⚠️ Never use mode="chars" on Devanagari or other Indic scripts —
conjunct ligatures break when split per character. -->
<p ngxSplitReveal mode="lines" [scrubbed]="false">…</p>
NGXSPLITREVEAL INPUTS
NAME
TYPE
DEFAULT
NOTES
mode
'lines' | 'words' | 'chars'
'words'
Never use chars on Indic scripts — conjunct ligatures break.
scrubbed
boolean
true
true ties progress to scroll; false plays once on enter and reverses on exit.
stagger
number
0.03
Seconds between each piece.
y
number
28
Travel in pixels.
ngxScrub
A scroll-scrubbed timeline bound to the host. Scroll through the strip below — the bar and the glyph are driven by scroll position, not time.
खड्ग
Pick the range so it plays while the element is on screen. A short block with end="bottom 40%" is finished before it reaches the middle of the viewport; ending on its own top keeps the motion where the reader is looking. A fractional scrub adds catch-up smoothing so it never feels stepped.
TS
@Component({
imports: [ScrubTimelineDirective],
template: `
<!-- Range it so the motion happens while the element is on screen:
'top 90%' → 'top 15%' spans almost the whole visible pass.
A fractional scrub adds catch-up smoothing. -->
<section ngxScrub start="top 90%" end="top 15%" [scrub]="0.6"
(timelineReady)="build($event)">
<div class="bar"></div>
</section>
`,
})
export class Chapter {
// The directive runs your callback inside a gsap.context() scoped to the
// host, so plain selector strings only match inside this section — and
// everything reverts automatically on destroy.
protected build(tl: gsap.core.Timeline): void {
tl.to('.bar', { width: '100%', ease: 'none' });
}
}
INPUTS & OUTPUTS
NAME
TYPE
DEFAULT
NOTES
start
string
'top bottom'
ScrollTrigger start position.
end
string
'bottom top'
ScrollTrigger end position, e.g. "+=150%".
scrub
boolean | number
true
A number adds that many seconds of catch-up smoothing.
pin
boolean
false
Pin the host for the duration of the timeline.
markers
boolean
false
ScrollTrigger's debug markers.
timelineReady
output<gsap.core.Timeline>
—
Populate the timeline synchronously in the handler.
<ngx-frame-sequence>
A scroll-scrubbed canvas image sequence. Frames are fetched and decoded to ImageBitmaps about two viewports before arrival, drawn at devicePixelRatio capped at 2. Keep scrolling — the section below pins and plays 48 frames.
Required. Image URLs in order, decoded to ImageBitmaps on approach.
pinLength
number
200
Viewport-heights of scroll the pin consumes. 0 disables pinning.
fit
'cover' | 'contain'
'cover'
How each frame fills the canvas.
external
boolean
false
Creates no scroll trigger of its own — drive it with setProgress().
progress
Signal<number>
—
0–1 sequence progress; drive overlay swaps off it.
setProgress(p)
(number) => void
—
External-drive entry point; clamps to 0–1.
ScrollService
Lenis smooth scroll, synced to ScrollTrigger and driven off gsap.ticker. The rAF loop never touches change detection, so it is zoneless-friendly — read progress as a signal instead.
progress · 0%
Reduced motion keeps native scrolling — init() returns early and progress stays at 0.
TS
import { ScrollService } from '@code_with_sachin/ngx-gsap';
export class App {
private readonly scroll = inject(ScrollService);
constructor() {
// Call once, from the root component, after first render.
afterNextRender(() => this.scroll.init());
}
// progress is a signal — bind it straight into a progress bar.
protected readonly progress = this.scroll.progress;
toContact() { this.scroll.scrollTo('#contact', -80); }
pause() { this.scroll.stop(); }
resume() { this.scroll.start(); }
}
MEMBERS
NAME
TYPE
DEFAULT
NOTES
init()
() => void
—
Starts Lenis and syncs ScrollTrigger. Idempotent, browser-only, and skipped under reduced motion.
pin holds the section while the timeline runs, so the scroll distance becomes animation time. end='+=120%' spends 1.2 viewport-heights of scroll on it.
The classic sideways-scroll section: pin the frame, translate the row by its own overflow width. No horizontal scrollbar, no wheel hijacking.
शृङ्गार
LOVE
हास्य
MIRTH
रौद्र
FURY
वीर
VALOUR
शान्त
PEACE
HTML
<div ngxScrub [pin]="true" start="top top" end="+=140%" [scrub]="0.4"
(timelineReady)="buildGallery($event)" class="overflow-hidden">
<div class="gallery-row flex gap-6">
@for (rasa of rasas; track rasa.sa) { <div class="w-56 shrink-0">…</div> }
</div>
</div>
// A function value is re-evaluated on every ScrollTrigger refresh,
// so the distance stays correct through resizes.
protected buildGallery(tl: gsap.core.Timeline): void {
tl.to('.gallery-row', {
x: () => {
const row = document.querySelector<HTMLElement>('.gallery-row')!;
return -(row.scrollWidth - row.parentElement!.clientWidth + 24);
},
ease: 'none',
duration: 1,
});
}
Externally driven frames
NGX-FRAME-SEQUENCE
external stops the component making its own trigger. Lazy preloading still happens; you call setProgress() from anything — a slider here, a parent timeline in production.
words is the safe default. lines suits paragraphs. chars is the showy one — and the one that destroys Devanagari, because conjuncts are single glyphs made of several code points.
mode="words"
The blade remembers every hand that held it
mode="lines"
The blade remembers every hand that held it
mode="chars"
The blade remembers every hand that held it
mode="words" on Devanagari — safe
क्षत्रियस्य धर्मः शौर्यम्
HTML
<p ngxSplitReveal mode="words" [scrubbed]="false">…</p>
<p ngxSplitReveal mode="lines" [scrubbed]="false">…</p>
<p ngxSplitReveal mode="chars" [scrubbed]="false">…</p>
<!-- Devanagari: words is safe, chars is not — a conjunct like क्ष is one
glyph built from several code points, and splitting it breaks the shape. -->
<p ngxSplitReveal mode="words" lang="sa">क्षत्रियस्य धर्मः शौर्यम्</p>
Ticker strip
NGXMARQUEE
Two rows at different speeds in opposite directions reads as depth. Clones are aria-hidden, so a screen reader hears the row once.
OPEN SOURCE◆ANGULAR 22◆SSR SAFE◆ZERO DEPS◆
OPEN SOURCEANGULAR 22SSR SAFEZERO DEPS
HTML
<div ngxMarquee [speed]="45">
@for (t of tickers; track t) { <span class="px-5">{{ t }}</span> }
</div>
<div ngxMarquee [speed]="90" [direction]="-1">
@for (t of tickers; track t) { <span class="px-5">{{ t }}</span> }
</div>