कालः · ANGULAR 22+

@code_with_sachin/ngx-gsap

GSAP + Lenis directives — scrub timelines, split text, marquees, sequences.

INSTALL

BASH
npm i @code_with_sachin/ngx-gsap gsap lenis

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

NAMETYPEDEFAULTNOTES
@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.

TS · app.config.ts
import { ApplicationConfig } from '@angular/core';
import { provideGsap } from '@code_with_sachin/ngx-gsap';

export const appConfig: ApplicationConfig = {
  providers: [
    provideGsap(),
  ],
};

// main.ts
// bootstrapApplication(App, appConfig);

Using NgModules

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 ·&nbsp;</span>
</div>

<!-- direction -1 runs rightwards -->
<div ngxMarquee [speed]="140" [direction]="-1">…</div>

INPUTS

NAMETYPEDEFAULTNOTES
speednumber 90 Pixels per second.
direction1 | -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

HTML
<span [ngxCountUp]="70000" suffix="+" [duration]="2"></span>

NGXCOUNTUP INPUTS

NAMETYPEDEFAULTNOTES
ngxCountUpnumberRequired. Target value; counting starts when the host hits 85% of the viewport, once.
durationnumber 1.6 Seconds, eased with power2.out.
suffixstring '' 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

NAMETYPEDEFAULTNOTES
mode'lines' | 'words' | 'chars' 'words' Never use chars on Indic scripts — conjunct ligatures break.
scrubbedboolean true true ties progress to scroll; false plays once on enter and reverses on exit.
staggernumber 0.03 Seconds between each piece.
ynumber 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

NAMETYPEDEFAULTNOTES
startstring 'top bottom' ScrollTrigger start position.
endstring 'bottom top' ScrollTrigger end position, e.g. "+=150%".
scrubboolean | number true A number adds that many seconds of catch-up smoothing.
pinboolean false Pin the host for the duration of the timeline.
markersboolean false ScrollTrigger's debug markers.
timelineReadyoutput<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.

NGX-FRAME-SEQUENCE · 48 FRAMES

HTML
<ngx-frame-sequence
  class="relative block h-screen"
  [frames]="frames"
  [pinLength]="150"
  fit="cover"
>
  <!-- projected content rides along while pinned -->
  <h3 class="absolute inset-0 grid place-items-center">{{ caption() }}</h3>
</ngx-frame-sequence>

// frames are preloaded as ImageBitmaps ~2 viewports before arrival
protected readonly frames = Array.from(
  { length: 48 },
  (_, i) => `/frames/talwar/f${String(i + 1).padStart(2, '0')}.webp`,
);

INPUTS & MEMBERS

NAMETYPEDEFAULTNOTES
framesstring[]Required. Image URLs in order, decoded to ImageBitmaps on approach.
pinLengthnumber 200 Viewport-heights of scroll the pin consumes. 0 disables pinning.
fit'cover' | 'contain' 'cover' How each frame fills the canvas.
externalboolean false Creates no scroll trigger of its own — drive it with setProgress().
progressSignal<number>0–1 sequence progress; drive overlay swaps off it.
setProgress(p)(number) => voidExternal-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

NAMETYPEDEFAULTNOTES
init()() => voidStarts Lenis and syncs ScrollTrigger. Idempotent, browser-only, and skipped under reduced motion.
progressSignal<number>0–1 page scroll progress. Updated outside change detection.
refresh()() => voidRe-measure every ScrollTrigger. Called for you after setup, fonts and height changes — call it yourself after anything else that moves the page late.
scrollTo(target, offset?)(string | number | HTMLElement, number) => voidFalls back to scrollIntoView when Lenis is off.
stop() / start()() => voidPause and resume smooth scrolling — useful behind a modal.
destroy()() => voidCalled automatically when the root injector is destroyed.

Recipes

Patterns built from the same five directives. Each one is live on this page — scroll it, then take the markup.

Parallax layers

NGXSCRUB

One timeline, three targets moving at different rates. Because everything is on the same scrubbed timeline, the layers can never drift out of sync.

दूर

मध्य

निकट

HTML
<div ngxScrub start="top 95%" end="top 10%" [scrub]="0.5"
     (timelineReady)="buildParallax($event)" class="relative h-56 overflow-hidden">
  <p class="plx-back">दूर</p>
  <p class="plx-mid">मध्य</p>
  <p class="plx-front">निकट</p>
</div>

// One timeline, three rates — they can never drift apart.
protected buildParallax(tl: gsap.core.Timeline): void {
  tl.to('.plx-back',  { y: -20,  ease: 'none', duration: 1 }, 0)
    .to('.plx-mid',   { y: -60,  ease: 'none', duration: 1 }, 0)
    .to('.plx-front', { y: -110, ease: 'none', duration: 1 }, 0);
}

Pinned chapter

NGXSCRUB · PIN

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.

क्षत्र

HELD WHILE YOU SCROLL

HTML
<div ngxScrub [pin]="true" start="top top" end="+=120%" [scrub]="0.4"
     (timelineReady)="buildPinned($event)" class="grid h-72 place-items-center">
  <div>
    <p class="pin-glyph">क्षत्र</p>
    <p class="pin-caption">HELD WHILE YOU SCROLL</p>
  </div>
</div>

protected buildPinned(tl: gsap.core.Timeline): void {
  tl.fromTo('.pin-glyph',   { scale: 0.7, autoAlpha: 0.3 },
                            { scale: 1.15, autoAlpha: 1, ease: 'none', duration: 1 }, 0)
    .fromTo('.pin-caption', { letterSpacing: '0.3em' },
                            { letterSpacing: '0.9em', ease: 'none', duration: 1 }, 0);
}

Horizontal gallery

NGXSCRUB · PIN

The classic sideways-scroll section: pin the frame, translate the row by its own overflow width. No horizontal scrollbar, no wheel hijacking.

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.

HTML
<ngx-frame-sequence #ext [frames]="frames" [external]="true" fit="contain"
                   class="relative block h-64" />

<input type="range" min="0" max="100" (input)="driveFrames($event)" />

private readonly extSeq = viewChild<FrameSequenceComponent>('ext');

protected driveFrames(event: Event): void {
  const pct = Number((event.target as HTMLInputElement).value);
  this.extSeq()?.setProgress(pct / 100);   // clamped to 0–1 internally
}

Split modes compared

NGXSPLITREVEAL

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 SOURCEANGULAR 22SSR SAFEZERO 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>

Built for sachinsingh.me — this package ships from that portfolio's own workspace.

MIT · Sachin Singh