npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@zakkster/lite-signal-gsap

v1.0.1

Published

Zero-GC bridge between GSAP and @zakkster/lite-signal. Pool-backed proxy tweens, reactive-target smooth-follow, auto-cleanup inside effects. No per-frame allocations.

Readme

@zakkster/lite-signal-gsap

npm version npm bundle size npm downloads npm total downloads TypeScript License: MIT

Zero-GC bridge between GSAP and @zakkster/lite-signal. Drive reactive signal nodes from GSAP tweens through a preallocated proxy pool — no per-frame allocations after warmup.

One pool for the lifetime of the app. No closure per onUpdate. No fresh onUpdateParams array per retarget. No {x, y} object literals churning through the new-space generation. Auto-cleanup when used inside lite-signal effects.

import { signal } from "@zakkster/lite-signal";
import { tweenToSignal } from "@zakkster/lite-signal-gsap";

const cursorX = signal(0);
const cursorY = signal(0);
const playerX = signal(0);
const playerY = signal(0);

window.addEventListener("pointermove", (e) => {
    cursorX.set(e.clientX);
    cursorY.set(e.clientY);
});

// Smooth-follow. Every cursor move retargets the live tween in-place.
tweenToSignal(playerX, cursorX, { duration: 0.3, ease: "power2.out" });
tweenToSignal(playerY, cursorY, { duration: 0.3, ease: "power2.out" });

Contents


Why

Reactive state libraries and animation engines are notoriously bad at composing without GC pressure. The naive bridge looks like this, and it's what you write first:

// The code you write first, and regret later
function followCursor(playerX, cursorX) {
    let tween = null;
    watch(cursorX, (next) => {
        if (tween) tween.kill();
        tween = gsap.to({ value: playerX.peek() }, {
            value: next,
            duration: 0.3,
            onUpdate() { playerX.set(this.targets()[0].value); }
        });
    });
}

Every cursor move (often 120 Hz on modern displays) allocates:

  • a new { value } object literal,
  • a new onUpdate closure capturing playerX,
  • a new targets array internally in GSAP,
  • a new tween vars object spread.

For a single follower at 120 Hz, that's ~480 allocations per second. Multiply by 50 followers in a particle system and you're at 24 000 allocations per second from the bridge layer alone, before GSAP even ticks. Major GC pauses turn smooth 60 fps into periodic 30 ms stutters.

flowchart LR
    subgraph N["Naive bridge — per retarget"]
        direction TB
        N1[cursor moves]
        N2[allocate proxy object literal]
        N3[allocate onUpdate closure]
        N4[allocate vars spread object]
        N5[GSAP creates new tween]
        N6[old proxy + closure<br/>become garbage]
        N1 --> N2 --> N3 --> N4 --> N5 --> N6 -.->|GC pressure| N1
    end
    subgraph B["lite-signal-gsap — per retarget"]
        direction TB
        B1[cursor moves]
        B2[proxy reused from pool]
        B3[onUpdate is module-level<br/>no closure]
        B4[params array reused<br/>mutate index 1]
        B5[GSAP creates new tween]
        B5 -.->|no garbage from bridge| B1
        B1 --> B2 --> B3 --> B4 --> B5
    end

@zakkster/lite-signal-gsap owns a preallocated pool of proxy objects, shares a single module-level onUpdate across every tween, and pre-allocates the onUpdateParams array per proxy slot. The bridge contributes zero allocations per frame in steady state. GSAP itself still allocates its own tween objects internally — that's outside our control — but the marginal cost of the bridge layer is gone.

What this is not

  • Not a renderer. It draws nothing. You bring the canvas, WebGL, DOM, or whatever.
  • Not a tween engine. GSAP is the engine. This library is plumbing.
  • Not an easing library. GSAP accepts (progress: number) => number natively, so @zakkster/lite-ease curves work without a bridge function. Strict modular boundaries.
  • Not magic. Hand-rolled gsap.to(myObject, ...) with your own pool is faster (no Set lookups for killTweensOf, no generation guard). This library trades a small constant for safe auto-cleanup + a managed pool + a typed API in ~250 lines of code.

Install

npm i @zakkster/lite-signal-gsap @zakkster/lite-signal gsap

ESM-only. Ships TypeScript definitions alongside the source. GSAP and @zakkster/lite-signal are peer dependencies — install at the versions your app uses.

import {
    tweenSignal,
    tweenToSignal,
    tweenRecordSignal,
    killTweensOf,
    isTweening,
    configure,
    stats
} from "@zakkster/lite-signal-gsap";

Quick start

Scalar tween — fire-and-forget

import { signal } from "@zakkster/lite-signal";
import { tweenSignal } from "@zakkster/lite-signal-gsap";

const opacity = signal(0);
tweenSignal(opacity, 1, { duration: 0.5, ease: "power2.out" });

The returned handle is a callable stop function with .tween attached for advanced control. Calling stop() kills the tween and releases the proxy slot. It is idempotent.

const stop = tweenSignal(playerX, 500, { duration: 2 });
stop.tween.pause();        // pause via the underlying GSAP tween
stop.tween.timeScale(0.5); // half speed
stop();                    // kill + release

Reactive-target tween — smooth follow

tweenToSignal is the differentiator. The live tween retargets in-place whenever the target signal changes.

import { signal } from "@zakkster/lite-signal";
import { tweenToSignal } from "@zakkster/lite-signal-gsap";

const cursorX = signal(0);
const playerX = signal(0);

tweenToSignal(playerX, cursorX, {
    duration: 0.25,
    ease: "power2.out",
    onSettle: () => console.log("caught up")
});

onSettle fires each time the follower catches the target and no retarget is pending. onComplete and onInterrupt from vars are deliberately stripped — they would fire per-overwrite, which is rarely what you want.

Record tween — many signals, one ticker

const t = { x: signal(0), y: signal(0), scale: signal(1) };

tweenRecordSignal(t, { x: 100, scale: 1.5 }, { duration: 1 });
//                          ^ y is left untouched

One GSAP tween, one onUpdate, three signal.set calls per frame. Each signal propagates independently — downstream computeds that read only t.x won't re-evaluate when t.scale changes.


How it works

The proxy pool

GSAP tweens a plain object's numeric properties. The bridge owns a preallocated pool of these objects (proxies) and claims one per tween.

flowchart TB
    subgraph P["Proxy pool — preallocated, reused forever"]
        direction LR
        P0["Slot 0<br/>{ gen, value, _sig, params }"]
        P1["Slot 1<br/>{ gen, value, _sig, params }"]
        P2["Slot 2<br/>{ gen, value, _sig, params }"]
        P3["..."]
    end
    P -.->|claim on tween creation| T[live GSAP tween]
    T -.->|release on completion or kill| P

Each slot carries:

  • gen — a monotonically-increasing integer bumped on every claim and every retarget. The shared onUpdate compares the proxy's current gen against the gen captured at tween-creation time; a stale fire from an overwritten tween is rejected before it can corrupt the signal.
  • value — the property GSAP animates. For scalar tweens.
  • _sig — the target signal, set on claim, nulled on release.
  • _sigs — the target record (for tweenRecordSignal), nulled on release.
  • params — a 2-slot array [proxy, gen] passed to GSAP as onUpdateParams. Mutated on retarget rather than reallocated.

The hot path

Every running tween's onUpdate fires through a single module-level function:

function sharedOnUpdate(proxy, expectedGen) {
    if (proxy.gen !== expectedGen) return; // stale fire — bail
    proxy._sig.set(proxy.value);
}

No closure. No this. No argument shuffling. V8 inlines this aggressively. The onUpdateParams: proxy.params reference is mutated in place across retargets:

proxy.gen++;
proxy.params[1] = proxy.gen;   // tells the next onUpdate which gen is "live"
gsap.to(proxy, { ..., onUpdateParams: proxy.params });

GSAP reads onUpdateParams at every fire, so the mutation propagates immediately. The previous tween has been killed by overwrite: "auto" — its parameters are dead.

Tween lifecycle

sequenceDiagram
    participant U as user code
    participant B as bridge
    participant P as pool
    participant G as GSAP
    U->>B: tweenSignal(s, 100)
    B->>P: claimProxy()
    P-->>B: proxy (gen++)
    B->>G: gsap.to(proxy, vars)
    G-->>B: tween
    B-->>U: stop handle (callable)
    Note over G: per-frame onUpdate fires<br/>proxy.value mutates<br/>shared handler does s.set(value)
    G->>B: onComplete fires
    B->>B: stop() called internally
    B->>G: tween.kill()
    B->>P: releaseProxy()

API reference

tweenSignal(targetSignal, targetValue, vars?)

Tween a numeric signal toward a target value.

| Parameter | Type | Notes | |---|---|---| | targetSignal | Signal<number> | The signal to animate. | | targetValue | number | End value. | | vars | SignalTweenVars | GSAP TweenVars (duration, ease, delay, repeat, yoyo, callbacks…). |

Returns a callable stop handle with .tween attached.

tweenToSignal(outputSignal, targetSignal, vars?)

Tween outputSignal toward a reactive target. The live tween retargets via GSAP's overwrite: "auto" whenever any signal read by targetSignal changes.

| Parameter | Type | Notes | |---|---|---| | outputSignal | Signal<number> | The signal the bridge writes to. | | targetSignal | Signal<number> | () => number | Reactive driver. May be a signal or any function that reads signals. | | vars | SignalToVars | Like SignalTweenVars but without onComplete / onInterrupt. Use onSettle instead. |

onSettle fires when the live tween reaches its target without being interrupted by a retarget. May fire multiple times over a watcher's lifetime if the target stays still long enough between retargets.

⚠️ Do not pass a targetSignal that transitively reads outputSignal — that creates an infinite reactive loop.

tweenRecordSignal(signalRecord, targets, vars?)

Tween a plain record of numeric signals as one GSAP tween.

| Parameter | Type | Notes | |---|---|---| | signalRecord | Record<string, Signal<number>> | E.g. { x: signal(0), y: signal(0) }. | | targets | Record<string, number> | Only listed keys are animated; others are left untouched. | | vars | SignalTweenVars | GSAP TweenVars. |

killTweensOf(target?)

Kill every live tween targeting target. Accepts a scalar signal or a record. Omit target to kill every tween managed by the bridge.

isTweening(target)

Returns true if the bridge owns a live tween for target. Synchronously accurate from the moment of creation — does not wait for GSAP's first tick. Membership reflects the bridge's proxy lifecycle: a tween enters on creation and leaves on natural completion, interruption, explicit kill, or killTweensOf.

This differs subtly from gsap.isTweening, which is tick-gated and returns false for paused or delayed tweens. For a lifecycle-aware bridge check, Set membership is the more useful semantic. For a strict "actively rendering right now" check, use the handle's .tween.isActive() directly.

configure(opts)

| Option | Type | Default | Notes | |---|---|---|---| | proxyPoolSize | number | 64 | Initial pool capacity. | | growthPolicy | "throw" | "grow" | "throw" | Behavior when the pool is exhausted at poolHighWaterMark. | | defaults | SignalTweenVars | {} | Default vars merged into every call (call-site vars take precedence). |

Locks after the first tween. Calling configure later throws. Set this at module init.

stats()

Returns a snapshot:

| Field | Type | Meaning | |---|---|---| | liveTweens | number | Tweens currently running. | | pooledProxies | number | Slots available in the pool. | | proxyPoolCapacity | number | Configured ceiling (throw-policy) or initial (grow-policy). | | poolHighWaterMark | number | Lifetime peak of allocated slots. Never decreases. | | retargetCount | number | Lifetime count of tweenToSignal retargets. |


Proxy monomorphism

To maintain zero-allocation hot paths, lite-signal-gsap reuses a preallocated pool of proxy objects across tweens. When you use tweenRecordSignal, the bridge dynamically assigns your record's properties (e.g., x, y) directly onto a pooled proxy.

When the tween finishes, the proxy is returned to the pool. To avoid GC pressure, the bridge does not delete the old keys; it merely nullifies their reactive links (proxy._sigs = null, values reset to 0).

If you subsequently claim that same slot for a different shape (e.g. r, g, b, a), the proxy object accumulates all keys (x, y, r, g, b, a). Over time, this forces V8's Inline Caching (IC) to treat the proxy as a polymorphic dictionary rather than a fast-path monomorphic struct.

The no-downside case. A pool that only ever sees one record shape stays fully monomorphic forever. The IC for proxy._sigs[key].set(proxy[key]) resolves on the first frame and never invalidates. This is the common case — most apps animate one or two stable shapes ({x, y} for 2D position, {r, g, b, a} for colors).

The constraint. For extreme performance targets (e.g. 120 fps WebGL particle orchestration with thousands of simultaneous record tweens), avoid mixing wildly different record shapes through the same pool. Either:

  • stick to a few consistent structural shapes across your app, or
  • split disjoint fields into separate scalar tweenSignal calls (the scalar proxies don't carry user-defined keys — they only have value, gen, and bridge-internal fields, so they stay monomorphic regardless).

For typical UI animation this is irrelevant. The penalty is measurable only in tight inner loops with thousands of operations per frame.


Auto-cleanup inside effects

Every tween constructor calls onCleanup(stop) unconditionally. lite-signal's onCleanup is a silent no-op outside a tracking context, so the call is always safe.

Inside an effect, the tween is killed when the effect re-runs or disposes:

import { signal, effect } from "@zakkster/lite-signal";
import { tweenSignal } from "@zakkster/lite-signal-gsap";

const alarm = signal(false);
const opacity = signal(0);

const dispose = effect(() => {
    if (alarm()) {
        // Auto-killed if `alarm` flips back to false before this completes.
        tweenSignal(opacity, 1, { duration: 0.5 });
    }
});

alarm.set(true);   // opacity tweens to 1
alarm.set(false);  // tween is killed; opacity stays wherever it was
dispose();         // any live tweens from the effect are killed

This pattern is how the demo manages its follower set — change the ease selection and the entire ensemble is rebuilt by a single effect dispose + recreate, with no manual per-follower stop() calls.

Outside any observer, the tween's lifetime is yours to manage via the returned stop handle.


Testing

npm test
# or, for the zero-allocation heap-budget check:
npm run test:gc

The test suite uses node:test (built into Node 18+) with --test-reporter=spec. It covers:

| Group | What's tested | |---|---| | tweenSignal | basic to-target, handle.tween escape hatch, onComplete chaining, proxy release on natural completion + on kill, handle idempotency | | Auto-cleanup | tween killed on effect dispose, prior tween killed on effect re-run, no-op outside tracking | | tweenToSignal | retargets on target change, settles correctly, onSettle fires on settle and NOT on retarget, kill stops watcher + tween, accepts both Signal and () => number | | tweenRecordSignal | multi-field animation, only-listed-keys-animate, proxy release | | killTweensOf / isTweening | scalar match, record match, kill-all behavior, false for untracked | | configure | locks after first use | | stats | live-tween count accuracy, monotonic high-water-mark, retargetCount tracking | | Zero-allocation (gated on --expose-gc) | 10 000 retargets keep heap growth under 2 MB |

A clean run ends with all tests passing and exit code 0. Suitable for CI.

The test strategy uses tween.progress(1) to fast-forward specific tweens deterministically — no real-time waits, no flaky timing assertions. The GSAP ticker still runs but the tests don't depend on it.


Running the demo

The demo is in demo/:

demo/index.html    # importmap + DOM shell
demo/main.js       # the actual demo

A "phase chase" visualization: a configurable set of followers each tweenToSignal toward the cursor, each with a different GSAP ease curve, so you can see the difference between power2.out, elastic.out, bounce.out, and friends side-by-side. The HUD reads stats() live — retargets/s will pin to your pointermove rate (typically 60–120 Hz on modern displays) while fps stays at the display refresh, which is the visual proof of the zero-GC claim.

The demo imports gsap and @zakkster/lite-signal from a CDN via <script type="importmap">, and imports @zakkster/lite-signal-gsap from ../SignalGsap.js (relative path to the source). No inlined library code in the demo. The import map is the integration test for the published ESM surface.

Because the demo loads ../SignalGsap.js over a relative path, you need a local server:

npx serve
# then open http://localhost:3000/demo/

file:// works in some browsers but not all; a server is the reliable path.


Browser & engine compatibility

The library is plain ESM with no DOM dependencies. Anywhere GSAP runs, this runs.

| Target | Library | Demo (canvas + importmap) | |---|---|---| | Chrome / Edge 89+ | ✅ | ✅ | | Firefox 108+ | ✅ | ✅ (importmap support) | | Safari 16.4+ | ✅ | ✅ | | Node.js 18+ | ✅ | — (browser only) | | Bun / Deno | ✅ | — |

Older browsers without import-map support can use the library directly — the demo is what relies on import maps for clean CDN imports.


Edge cases & guarantees

Behaviors the test suite pins down:

  • stop() is idempotent and re-entrancy-safe. Calling it twice is a no-op. Calling it from inside an onComplete callback is safe — the bridge already called it before invoking your callback.
  • stop.tween is reassigned on every retarget in tweenToSignal. It always references the currently running tween, never a stale one. If you grab the reference, retarget, then try to pause your grabbed reference, you'll be pausing a killed tween — re-read stop.tween after retargets.
  • Generation guards reject stale onUpdate fires. A tween killed by overwrite: "auto" whose final frame is still in GSAP's queue cannot write to a signal that has since been re-assigned. The gen counter on the proxy is bumped before the new tween starts; the old onUpdateParams[1] no longer matches, so the shared onUpdate bails early.
  • onCleanup(stop) is unconditional. Safe outside tracking; auto-fires inside an effect / computed / watch. No if (isTracking()) guard in user code.
  • tweenToSignal strips per-tween onComplete and onInterrupt. They would fire per overwrite, which is rarely what users want. Use onSettle for "follower caught the target" and stop for end-of-life.
  • tweenRecordSignal only animates keys present in targets. Fields in the record without target values are left at their current signal value; the bridge does not touch them.
  • configure locks after the first tween. This prevents foot-guns where pool sizes change underneath running tweens. Set it once at app init.
  • poolHighWaterMark never decreases. It's a lifetime metric for diagnostics. Use pooledProxies for instantaneous availability.
  • growthPolicy: "throw" throws synchronously when the next claim would exceed poolHighWaterMark. The error is recoverable — your code throws, the tween is never created, no proxy is leaked.
  • isTweening returns true for the entire bridge lifetime of a tween, including paused, delayed, and between-retarget states. It is synchronously correct from creation and does not wait for GSAP's first tick. For a strict "actively rendering right now" check matching gsap.isTweening semantics, use handle.tween.isActive() directly.

FAQ

Why a proxy pool? Can't I just gsap.to({}, ...) per tween? You can — and for a handful of tweens, you should. The pool exists for two reasons: (1) zero allocation per construction at scale, (2) bookkeeping for killTweensOf / isTweening / auto-cleanup. The pool's lookup overhead is a small constant; the alternative is per-tween closures and per-tween object literals, which scale poorly under load.

Why no signalEase() bridge for @zakkster/lite-ease? GSAP accepts custom ease functions in the shape of (progress: number) => number. @zakkster/lite-ease produces exactly that shape. They compose natively — adding a wrapper would be ceremony with no value. Strict modular boundaries.

Can I use a GSAP Timeline? Not directly in v1.1 — there's no signalTimeline constructor yet. If you need timeline behavior, build the timeline with gsap.timeline() directly and add your signal tweens to it through tl.add(tweenSignal(s, target, vars).tween, position). The .tween escape hatch on every handle gives you full GSAP API access.

Can I tween non-numeric properties? The bridge writes through signal.set(proxy.value) where proxy.value is whatever GSAP wrote. GSAP only knows how to interpolate numbers and color strings. If your signal carries a non-numeric value, this library is not the right tool — interpolate manually in an effect.

Why does tweenToSignal strip onComplete and onInterrupt? Because in a retargeting watcher, the per-tween onComplete fires every time the target stays still long enough for the live tween to settle, and onInterrupt fires on every overwrite. Both are confusing under the "watch a moving target" mental model. onSettle is the cleanly-named replacement for the only case users actually want: "the follower caught up". For end-of-life, the returned stop handle is unambiguous.

Does it leak in HMR / dev reloads? The bridge holds tween references for the lifetime of the live tweens. On module reload, the old GSAP ticker keeps running tweens against signals that no longer exist (their references in the new module are different). For dev safety, call killTweensOf() (no argument) in your HMR dispose hook, or scope your bridge usage inside effects so HMR cleanup tears them down.

What's the bundle cost? Single-file ESM, ~250 lines, ~6 KB minified before peer-dep counting. gsap and @zakkster/lite-signal are peer dependencies — they're not in the package weight.

Does it work in a Web Worker? GSAP itself doesn't need DOM, but its default ticker uses requestAnimationFrame which isn't available in workers. You can swap GSAP's ticker for setInterval-based ticking and the bridge will work, but this is GSAP territory, not bridge territory.


License

MIT © Zahary Shinikchiev