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

@cosmictraveler002/anim-kit

v1.6.1

Published

Modular, framework-agnostic animation library — GSAP + ScrollTrigger + Lenis effects packaged as composable ES modules.

Downloads

1,484

Readme

anim-kit

A modular, framework-agnostic animation library by kalakritico.in — GSAP + ScrollTrigger + Lenis effects packaged as independent ES modules.

TypeScript source → compiled ESM + .d.ts output. No framework, no virtual DOM, no components: every effect resolves plain DOM selectors, so it works with React, Vue, Next, Svelte, Astro or plain HTML.

import { lineReveal, marquee, menuOverlay } from "@cosmictraveler002/anim-kit";

const destroy = lineReveal("[data-lines]", { mode: "scroll" });
// …later (route change, HMR, teardown):
destroy();

Contents


Install

npm install @cosmictraveler002/anim-kit
import { smoothScroll, horizontalScroll } from "@cosmictraveler002/anim-kit";
import "@cosmictraveler002/anim-kit/styles";   // companion stylesheet (plain .css, optional but recommended)

anim-kit ships ESM only with generated .d.ts declarations — no CJS build, no runtime CSS-in-JS. gsap and lenis are regular dependencies, so any bundler (Vite / Next / webpack / Remix) resolves them for you.

From source (development)

git clone <this repo>
cd anim-kit
npm install
npm run build      # tsc → dist/ (ESM + .d.ts) + CSS copy + tsup standalone bundle
npm test           # build + unit smoke + demo wiring smoke
npm run demo       # visual demo on http://localhost:4321/demo/

Subpath exports

Everything the exports map in package.json exposes (all paths resolve inside the published tarball):

| Specifier | Resolves to | Use for | |---|---|---| | @cosmictraveler002/anim-kit | dist/index.js + dist/index.d.ts | the full barrel — 48 exports | | @cosmictraveler002/anim-kit/effects/<name> | dist/effects/<name>.js + .d.ts | one effect in isolation (marquee, lineReveal, …) | | @cosmictraveler002/anim-kit/three | dist/three/index.js + .d.ts | WebGL effects — needs the optional three peer (never a core dep) | | @cosmictraveler002/anim-kit/standalone | dist/anim-kit.standalone.js (types → index.d.ts) | the self-contained bundle — same API | | @cosmictraveler002/anim-kit/styles | dist/styles/anim-kit.css | untouched plain CSS |

import { marquee } from "@cosmictraveler002/anim-kit/effects/marquee";  // deep import, no barrel
import "@cosmictraveler002/anim-kit/styles";

TypeScript ≥ 4.7 with moduleResolution: "bundler" or "node16"/"nodenext" resolves declarations through the same map — no typesVersions shim needed.


CDN usage

No build step on the consumer's end: dist/ is served as-is from the npm tarball by any npm CDN. Every URL is version-pinned — npm versions are immutable, so @cosmictraveler002/[email protected] always resolves to exactly that build, forever (only a new version creates a new URL; nothing floats unless you ask for a range).

Option 1 — standalone bundle (simplest)

dist/anim-kit.standalone.js is a self-contained ESM bundle with gsap (+ the plugins anim-kit uses) and lenis inlined — no import map, one URL, works identically on jsDelivr and unpkg:

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@cosmictraveler002/[email protected]/dist/styles/anim-kit.css" />

<script type="module">
  import {
    smoothScroll, lineReveal, marquee,
  } from "https://cdn.jsdelivr.net/npm/@cosmictraveler002/[email protected]/dist/anim-kit.standalone.js";

  smoothScroll();
  lineReveal("[data-lines]", { mode: "scroll" });
  marquee("[data-marquee-track]", { speed: 40 });
</script>

unpkg serves the same file: https://unpkg.com/@cosmictraveler002/[email protected]/dist/anim-kit.standalone.js

Option 2 — jsDelivr +esm

jsDelivr bundles anim-kit with its dependencies on the fly (also immutable per version):

<script type="module">
  import { lineReveal } from "https://cdn.jsdelivr.net/npm/@cosmictraveler002/[email protected]/+esm";
</script>

Option 3 — per-file ESM + import map (jsDelivr and unpkg)

The unbundled dist/index.js contains bare imports (gsap, lenis), so pin them in an import map. This is the exact shape the demo runs locally, with CDN URLs — and the way to share one GSAP between anim-kit and the rest of your page:

<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@cosmictraveler002/[email protected]/dist/styles/anim-kit.css" />

<script type="importmap">
  {
    "imports": {
      "@cosmictraveler002/anim-kit": "https://cdn.jsdelivr.net/npm/@cosmictraveler002/[email protected]/dist/index.js",
      "@cosmictraveler002/anim-kit/three": "https://cdn.jsdelivr.net/npm/@cosmictraveler002/[email protected]/dist/three/index.js",
      "three": "https://cdn.jsdelivr.net/npm/[email protected]/build/three.module.js",
      "gsap": "https://cdn.jsdelivr.net/npm/[email protected]/index.js",
      "gsap/ScrollTrigger": "https://cdn.jsdelivr.net/npm/[email protected]/ScrollTrigger.js",
      "gsap/SplitText": "https://cdn.jsdelivr.net/npm/[email protected]/SplitText.js",
      "gsap/Draggable": "https://cdn.jsdelivr.net/npm/[email protected]/Draggable.js",
      "gsap/CustomEase": "https://cdn.jsdelivr.net/npm/[email protected]/CustomEase.js",
      "gsap/Flip": "https://cdn.jsdelivr.net/npm/[email protected]/Flip.js",
      "gsap/ScrollSmoother": "https://cdn.jsdelivr.net/npm/[email protected]/ScrollSmoother.js",
      "lenis": "https://cdn.jsdelivr.net/npm/[email protected]/dist/lenis.mjs"
    }
  }
</script>

<script type="module">
  import { smoothScroll, lineReveal } from "@cosmictraveler002/anim-kit";
  // per-effect deep imports work here too:
  // import { dragStrip } from "@cosmictraveler002/anim-kit/effects/dragStrip";
</script>

Swap the host for unpkg (https://unpkg.com/@cosmictraveler002/[email protected]/dist/index.js, …) — the file layout is identical. GSAP subpaths are listed one by one because import maps match specifiers literally: a trailing-slash prefix map would produce extension-less URLs, which CDNs don't serve. The gsap/lenis pins match package-lock.json.

Version discipline — the pin must match the code

The pin is a contract between the URL and the importing code: a bundle older than the names your code imports fails at module instantiation, before a single line runs —

SyntaxError: The requested module '…/anim-kit@<old-version>/dist/anim-kit.standalone.js'
does not provide an export named 'clipWipe'

On a page with a preloader that reads as frozen on the loader: the script that would dismiss it never executed. Whenever a loader won't clear, open the console first — an … does not provide an export named 'X' error is version skew, not an effect bug.

Rules that keep it from happening:

  • Never hand-copy the demo or hand-edit CDN URLs. Regenerate the deployable copy with npm run sync:live — it rewrites every pin from CDN_VERSION in scripts/prompts.mjs, the single source, and demo-smoke fails CI if demo/, demo_live/ or this README drift from the release version.
  • On a release bump, change version and CDN_VERSION together (the smoke enforces it); every copy prompt, the docs page and demo_live/ follow automatically.
  • In your own project, every CDN URL you ship must carry the same version — mixing a new JS pin with an old CSS pin (or vice versa) is the same bug in slow motion.

Quick start

<p data-lines>Every line of this paragraph is masked and slid up on scroll.</p>
<div class="ak-marquee">
  <div class="ak-marquee__viewport">
    <div class="ak-marquee__track" data-marquee-track data-dir="left">
      <span>Prink</span><span>Zerodha</span><span>Superyou</span>
    </div>
  </div>
</div>
import { smoothScroll, lineReveal, marquee, compose } from "@cosmictraveler002/anim-kit";

const scroller = smoothScroll({ lerp: 0.08, smoothWheel: true });

const teardown = compose(
  scroller.destroy,
  lineReveal("[data-lines]", { mode: "scroll" }),
  marquee("[data-marquee-track]", { speed: 40 }),
);

window.addEventListener("pagehide", () => teardown(), { once: true });

The contract

Every effect follows one shape:

effect(target, options) => destroy
  • target — anything assignable to TargetLike: a selector string, an Element, an array of elements, a NodeList, or null/undefined.
  • options — a plain object of documented, defaulted fields. Every options object also accepts force?: boolean (see Reduced motion).
  • destroy — a () => void that kills tweens/ScrollTriggers (paused springs included), removes listeners and clones, restores the original markup, and reverts the inline styles the effect overwrote. Always safe to call once; call it on teardown.

A handful of effects need more than a destroy function and return a handle instead — those handles still expose .destroy():

| Effect | Handle | | --------------- | -------------------------------------------- | | smoothScroll | { lenis, scrollTo, active, destroy } | | menuOverlay | { open, close, toggle, isOpen, destroy } | | themeReveal | { set, toggle, current, destroy } | | audioBars | { start, stop, destroy } |

Missing targets never throw. If nothing matches, the effect returns an immediate no-op destroy — safe to call during progressive enhancement.

initGSAP() runs automatically inside every effect (registration is idempotent), but you can call it yourself if you want gsap/ScrollTrigger configured before first paint.


Smooth scroll

Lenis is wired to ScrollTrigger the canonical way: Lenis drives the scroll, lenis.on("scroll", ScrollTrigger.update) keeps triggers in sync, and lenis.raf is ticked from gsap.ticker so scroll and tweens run off one clock. GSAP's lag smoothing stays on: ticker listeners receive the same adjusted clock the tweens use, so a stalled frame (GC pause, buffer tick) advances one short step everywhere instead of snapping the scroll and every scroll-linked tween to a new time in a single jump.

const scroller = smoothScroll({
  lerp: 0.08,          // interpolation factor (default) — lower = floatier
  smoothWheel: true,   // default
  smoothTouch: false,  // default; true fights native touch scrolling
  orientation: "vertical",
  initialScroll: 0,
  useScrollerProxy: false, // opt-in scrollerProxy for nested scrollers
  touchMultiplier: 1,  // touch-drag sensitivity (Lenis default 1)
  wheelMultiplier: 1,  // mouse-wheel sensitivity (Lenis default 1)
  // Lag smoothing: leave unset for GSAP's built-in 500ms → 33ms compensation,
  // false to disable it (the old behaviour), or retune for short hitches:
  lagSmoothing: { threshold: 150 },
});

scroller.scrollTo("#work", { duration: 1.2 }); // programmatic, smoothed
scroller.active;   // false when Lenis was skipped (e.g. reduced motion)
scroller.destroy();

Call it before creating scroll effects so the first refresh sees the right scroller. When motion is reduced, smoothScroll stays inert and native scrolling is left alone.


Effect categories

Every effect belongs to exactly one category → subcategory slot. The tree below is the library's map: it orders this API reference, groups the demo's prompt dock, and backs the category / subcategory fields on GET /api/prompts.

| Category | Subcategory | Effects | | --- | --- | --- | | Core & setup | Smooth scrolling | smoothScroll | | Text animations | Line & mask reveals | lineReveal, maskReveal | | Text animations | Per-character scatter | scatterText | | Text animations | Decode & scramble | scrambleText | | Text animations | Rolling text | rollText, reelText | | Text animations | Counters | counter | | Text animations | Layout transfers | flipWords | | Scroll & media | Pinned galleries | horizontalScroll, stackedCards, stackedCardsPinned | | Scroll & media | Parallax & depth | parallax | | Scroll & media | Heroes & media | heroShrink, mediaSettle | | Scroll & media | Enter reveals | revealRule, unfoldReveal, clipWipe | | WebGL | Shader media | webglMedia, glRail, coverflowWheel | | WebGL | Scenes & overlays | tearReveal, ditherReveal | | WebGL | Kinetic type | wordmarkWave | | Loops & marquees | Marquees | marquee | | Loops & marquees | Draggables & rails | dragStrip, dragRail | | Loops & marquees | Equalizers | audioBars | | Buttons & links | Liquid fills | liquidButton | | Buttons & links | Underlines | underlineLink | | Buttons & links | Magnetic hover | magnetic | | Navigation & overlays | Menus & nav | navHide, menuOverlay | | Navigation & overlays | Cursors | cursorFollower | | Intros & transitions | Preloaders | preloader | | Intros & transitions | Theme wipes | themeReveal | | Intros & transitions | Page transitions | inkWipe | | Logos & SVG | Path reveals | logoReveal |

Growing the library: a subcategory is the slot sibling effects land in — add the id to TAXONOMY in scripts/prompts.mjs, export the effect from src/index.ts, and give it a prompt entry. The demo smoke fails when an effect is unclassified, classified twice, or the tree references an effect that does not exist.


API

Core

initGSAP()

Registers the plugins anim-kit relies on (ScrollTrigger, SplitText, Draggable, CustomEase, Flip, ScrollSmoother) and the studio's custom eases. Idempotent; called for you by every effect.

EASES

EASES.curtain    // "ak-curtain"     .76,0,.24,1   — menu curtain, panel wipes
EASES.cardStack  // "ak-card-stack"  SVG cubic bezier — cascading card deck
EASES.reveal     // "ak-reveal"      .165,.84,.44,1 — mask/rule reveals
EASES.preloadOut // "ak-preload-out" .895,.03,.685,.22 — preloader exit

Use them anywhere GSAP accepts an ease: gsap.to(el, { ease: EASES.curtain }).

split(target, options) => { elements, revert }

Text splitting with a dependency-free fallback if SplitText is unavailable.

const { elements, revert } = split("h1", {
  type: "lines",          // "chars" | "words" | "lines" (or an array)
  mask: true,             // wrap each line in an overflow-hidden mask
  linesClass: "ak-line++",// "++" is replaced by the index
  lineThreshold: 0.05,    // ignore lines shorter than 5% of the container
});
revert(); // restores the original markup exactly

guard(options, run) => destroy

Central reduced-motion gate. If the user prefers reduced motion and options.force is not set, it returns a no-op destroy; otherwise it runs run(). Every effect goes through it.


Text animations

Typography in motion — masked lines, rising masks, per-character scatter, decode reveals, rolling words and reels, tickers, layout transfers.

lineReveal(target, options?) => destroy

Splits text into masked lines and staggers them up. The signature reveal of the source site.

lineReveal("[data-hero-text]", { mode: "immediate", delay: 0.35 }); // above the fold
lineReveal("[data-lines]", { mode: "scroll" });                     // reverses on leave
lineReveal("[data-headline]", { split: "chars", stagger: 0.03 });    // per-character rise

| Option | Default | Notes | | --------- | --------------- | --------------------------------------- | | mode | "scroll" | "scroll" or "immediate" | | split | "lines" | "lines" or "chars" (per-character masked rise) | | stagger | 0.1 | seconds between lines (0.03 for chars) | | duration| 1 | seconds | | ease | "power4.out" | | | delay | 0 | seconds | | start | "top 90%" | ScrollTrigger start | | end | "bottom 10%" | ScrollTrigger end |

DOM: any block of text — headings with <br> hard breaks work. Produces .ak-line-mask > .ak-line per line (or .ak-char-mask > .ak-char with split: "chars"); destroy() restores the original HTML.

maskReveal(target, options?) => destroy

Inline overflow:hidden heading reveal: the inner span rises from below the mask and settles.

<h2>
  <span class="ak-mask"><span class="ak-mask__inner" data-mask>Text that</span></span>
  <span class="ak-mask"><span class="ak-mask__inner" data-mask>rises into view.</span></span>
</h2>
maskReveal("[data-mask]"); // siblings inside one parent stagger together

| Option | Default | | --------- | ---------------- | | from / to | "100%" / "0%" | | duration| 0.5 | | ease | EASES.reveal | | stagger | 0.1 | | delay | 0 | | start / end | "top 90%" / "bottom 10%" | | mode | "scroll" — or "immediate" to play at once |

scatterText(wrap, options?) => destroy

The giant pinned band ("So, are you ready to Stand out?"): the line scrolls horizontally while each character starts at a random yPercent/rotation and settles as it crosses the viewport.

scatterText("[data-scatter-pin]", {
  line: "[data-scatter]",
  pinTarget: "[data-scatter-pin]",
  granularity: "chars",   // or "words"
  scatterY: 60,           // ±60% of line height
  scatterRotation: 15,    // ±15°
  scrub: 0.5,
  settleStart: "left 100%",
  settleEnd: "left 15%",
});

DOM: .ak-char / .ak-space spans are generated for you (and removed on destroy()). Line measurement waits for document.fonts.ready so travel distance is correct with webfonts.

scrambleText(target, options?) => destroy

The decode / cipher reveal: each character churns through the charset and settles on its final glyph, left to right. Letters scramble; digits, punctuation and spaces stay put; case is preserved.

scrambleText("[data-scramble]");                                   // once, on enter
scrambleText("[data-headline]", { mode: "immediate", delay: 0.2 }); // right away
scrambleText(".nav-link", { mode: "hover", durationPerChar: 0.12 }); // on hover

| Option | Default | Notes | | --- | --- | --- | | mode | "scroll" | "scroll" (once on enter), "immediate", or "hover" (re-scrambles on pointerenter) | | charset | "abcdefghijklmnopqrstuvwxyz" | glyphs letters churn through | | durationPerChar | 0.18 | seconds each character scrambles | | stagger | 0.04 | seconds between character starts | | delay | 0 | seconds before the timeline starts | | start | "top 80%" | ScrollTrigger start (mode: "scroll") |

DOM: plain-text elements only — the effect rewrites textContent while scrambling and restores the original exactly on destroy().

rollText(target, options?) => destroy

The rolling word rotator: two or more rows stacked into a hidden overflow box one row tall, rolling to the next on an interval. The first row is cloned at the end so the wrap is seamless (same trick as marquee()).

<span class="ak-roll" data-roll>
  <span>Design</span><span>Build</span><span>Motion</span>
</span>
rollText("[data-roll]", { interval: 2.2, duration: 0.6 });
rollText("[data-roll-rev]", { direction: "down" }); // walk rows in reverse

| Option | Default | Notes | | --- | --- | --- | | interval | 2.2 | seconds each row is shown (including the roll) | | duration | 0.6 | roll duration, seconds | | ease | "power4.inOut" | GSAP ease for the roll | | direction | "up" | "up" or "down" |

DOM: rows are direct children of the target; the effect stacks them as blocks itself. destroy() unwraps the rows, removes the clone and restores every inline style — markup comes back byte-identical.

reelText(target, options?) => destroy

Per-character odometer roll — the DOM slot-machine decode. Every alphanumeric character gets a masked cell holding a vertical strip of same-case ghost glyphs; the strip rolls upward and lands on the real text, staggered left to right. Spaces and punctuation pass through untouched.

<p data-reel>Every character rolls into place.</p>
reelText("[data-reel]", { mode: "scroll", frames: 4, stagger: 0.05 }); // roll on enter
reelText("[data-immediate]");                                          // roll at init

| Option | Default | Notes | | --- | --- | --- | | mode | "immediate" | "scroll" rolls on viewport enter instead | | start | "top 85%" | ScrollTrigger start (scroll mode) | | frames | 4 | ghost glyphs per character before the final one | | duration | 0.8 | roll time per character, seconds | | stagger | 0.05 | delay between characters, seconds | | ease | "power4.out" | GSAP ease for the roll | | replay | false | re-arm on every scroll re-entry instead of once | | force | false | run even under prefers-reduced-motion |

DOM: targets must be plain text — the effect snapshots innerHTML, wraps each character in a measured mask (width taken from the final glyph, so the line never jitters while spinning) and restores the original markup byte for byte when the roll completes and on destroy(). Scroll-mode registration happens once and is killed on teardown; reduced motion leaves the target completely untouched. No stylesheet required — masks are inline.

flipWords(target, { to }, options?) => destroy

Words measured in one layout, moved into another and animated from where they stood — the FLIP technique: a column of words fans out into a row, driven by scroll progress or played once.

// Both blocks share one grid cell so the stage never reflows mid-flight:
flipWords("[data-flip-from]", { to: "[data-flip-to]", scrub: 0.6 });

// One-shot on enter (reverses on leave-back), or immediately:
flipWords("[data-flip-from]", { to: "[data-flip-to]", mode: "scroll" });

| Option | Default | Notes | | --- | --- | --- | | to | — (required) | destination block; every word is moved into it | | words | source children | [data-flip-word] matches, else the source's element children | | duration | 1.4 | seconds for one word's travel | | ease | "power4.inOut" | GSAP ease | | stagger | 0.2 | seconds between word starts | | scale | 0.2 | mid-flight squash each word pops through (0 disables) | | mode | "scroll" | or "immediate" to play at once | | scrub | unset | number = scrub smoothing seconds, true = immediate | | start / end | "top 75%" / "bottom 45%" | ScrollTrigger positions |

DOM: put the source and destination blocks in the same grid cell (grid-area: 1 / 1, a min-height on both keeps the stage stable). The words live in the destination from init but render at their source positions until the timeline runs, so scrubbing reverses perfectly. destroy() kills the timeline, puts every word back in its original parent and restores the inline transform.

counter(target, options?) => destroy

Tabular number ticker.

counter("[data-count]", { to: 240, duration: 3, suffix: "+" });
counter("[data-count-scroll]", { to: 98, onScroll: true });          // waits for view
counter("[data-count-pad]", { to: 42, pad: 3 });                     // 000 → 042
counter("[data-progress]", { progress: true, pad: 2, suffix: "%" }); // scrubs 00% → 100%

| Option | Default | | ------------ | -------------- | | from / to| 0 / 100 | | duration | 4 | | ease | "power1.inOut" | | pad | 0 (none) | | suffix | "" | | onScroll | false | | progress | false — scrub the value from scroll progress instead of a timed tween | | start / end | "top 90%" / "bottom top" — ScrollTrigger positions (end with progress) | | onComplete | (value) => {} |


Scroll & media

Scroll-driven storytelling — enter reveals, depth, pinned galleries, hero media.

revealRule(target, options?) => destroy

The thin rule that draws itself to full width.

revealRule("[data-rule]", { duration: 1, delay: 0.2 }); // default ease: EASES.reveal

DOM: any element that should animate width: 0 → 100% when it enters.

unfoldReveal(target, options?) => destroy

Blocks that grow open from an edge: scaleY: 0 → 1 from the top (or bottom) for a vertical unfold, scaleX: 0 → 1 from the left for a horizontal one — targets stagger together off the first match's trigger.

unfoldReveal("[data-unfold]", { axis: "y", origin: "top" });
unfoldReveal("[data-unfold-x]", { axis: "x", origin: "left", duration: 1.2 });

| Option | Default | Notes | | --- | --- | --- | | axis | "y" | "y" → scaleY, "x" → scaleX | | origin | "top" / "left" | transformOrigin, defaults per axis | | duration | 0.7 | seconds | | ease | "power3.out" | GSAP ease | | stagger | 0.08 | seconds between targets | | delay | 0 | seconds | | mode | "scroll" | or "immediate" to play at once | | start | "top 85%" | ScrollTrigger start | | replay | false | re-unfold when leaving / re-entering |

destroy() clears transform + transform-origin, so elements rest exactly as authored.

clipWipe(target, options?) => destroy

A clip-path: inset() reveal: the element is collapsed behind one edge, corner (or inside a frame margin) and the inset animates to zero so it wipes into view.

clipWipe("[data-clip]", { from: "left" });                    // inset(0 100% 0 0) → 0
clipWipe("[data-frame]", { from: "frame", inset: 12 });       // opens out of a frame
clipWipe("[data-corner]", { from: "bottom-right" });          // opens toward the opposite corner
clipWipe("[data-scrub]", { from: "frame", scrub: 0.5 });      // bound to scroll progress

| Option | Default | Notes | | --- | --- | --- | | from | "left" | "left" / "right" / "top" / "bottom", a corner ("top-left" / "top-right" / "bottom-left" / "bottom-right"), or "frame" | | inset | 15 | frame margin in % (from: "frame") | | duration | 1 | seconds | | ease | "power3.out" | GSAP ease | | stagger | 0.08 | seconds between targets | | mode | "scroll" | or "immediate" to play at once | | start | "top 85%" | ScrollTrigger start | | end | "top 20%" | ScrollTrigger end (scrub mode) | | scrub | unset | number = scrub smoothing seconds, true = immediate: tie the wipe to scroll progress | | replay | false | re-wipe when leaving / re-entering |

Works on images, video, blocks and text. destroy() removes the inline clip-path, restoring the authored (visible) state.

parallax(target, options?) => destroy

data-speed parallax over everything inside target.

<img data-speed="-0.5" src="…" />  <!-- slower than scroll -->
<img data-speed="0.8"  src="…" />  <!-- faster than scroll -->
parallax("[data-parallax]", {
  attribute: "data-speed",
  scale: 50,            // yPercent multiplier
  start: "50% bottom",
  end: "bottom top",
});

Each element tweens yPercent: value × scale with scrub: true and ease: "none" — so data-speed="0.8" settles at yPercent: 40; negative speeds drift up against the scroll.

horizontalScroll(track, options?) => destroy

Pinned horizontal gallery; panel images get a secondary parallax driven by containerAnimation, so they settle as they cross the viewport horizontally.

horizontalScroll("[data-htrack]", {
  section: "[data-hsection]",   // pinned trigger (defaults to track's <section>)
  panelImage: "[data-speed-img]", // extra parallax selector, null to disable
  scrub: 0.5,
  imageScrub: 0.2,
  travel: () => 1500,           // override the default scrollWidth − innerWidth
});

DOM:

<section data-hsection>
  <div class="h-track" data-htrack>          <!-- width: max-content -->
    <div class="h-panel">…<img data-speed-img></div> × N
  </div>
</section>

stackedCards(wrap, options?) => destroy

Pinned card deck — cards cascade with the ak-card-stack bezier.

stackedCards("[data-stack-wrap]", {
  viewport: "[data-stack-viewport]", // defaults to wrap's first child
  card: ".ak-card",
  scrub: 0.5,
  stagger: 0.12,
  ease: EASES.cardStack,
});

DOM: a tall wrapper (e.g. height: 500vh) containing a sticky viewport that holds the cards:

<div class="stack-wrap" data-stack-wrap>       <!-- tall scroll runway -->
  <div class="stack-viewport" data-stack-viewport>  <!-- position: sticky; top: 0 -->
    <div class="stack-cards">
      <a class="ak-card">01 …</a> × N
    </div>
  </div>
</div>

stackedCardsPinned(wrap, { viewport, … }) => destroy

Same deck for when the sticky viewport is a sibling rather than a child — pins viewport with pinSpacing: false across wrap's scroll length. viewport is required here.

heroShrink(target, options?) => destroy

Hero media that scales down and drifts as it scrolls away.

heroShrink("[data-hero-media]", { offsetY: "49vh", scale: 0.23, scrub: 1 });
// options: offsetX "0px", start "top top", end "bottom top"

mediaSettle(target, options?) => destroy

Images and video that arrive slightly oversized and ease down to size as the section enters — content lands instead of popping in. Set scrub to bind the settle to scroll progress, or replay: true to reverse back to from on leave-back and replay on every re-enter.

mediaSettle("[data-settle]", { from: 1.15, duration: 1.5 });  // on enter
mediaSettle("[data-settle-scrub]", { scrub: 0.5, from: 1.2 }); // scroll-bound

| Option | Default | Notes | | --- | --- | --- | | from | 1.15 | starting scale — settles down to 1 | | duration | 1.5 | seconds (enter mode) | | ease | "power2.out" | GSAP ease (enter mode) | | origin | "center" | transformOrigin | | stagger | 0.06 | seconds between targets | | mode | "scroll" | or "immediate" to play at once | | start / end | "top 75%" / "bottom top" | ScrollTrigger positions | | scrub | unset | number = scrub smoothing seconds, true = immediate | | replay | false | re-settle when leaving / re-entering (enter mode) |

destroy() clears transform — media returns to its authored scale. Unlike heroShrink() (which scrubs media down as it leaves), mediaSettle() plays the entrance.


WebGL

GPU media — three.js re-renders your content as shader cards, kinetic type and reveal scenes. This is the only category behind the @cosmictraveler002/anim-kit/three subpath: three is an optional peer (see Subpath exports), so the core barrel and the standalone bundle stay three-free.

webglMedia(target, options?) => destroy

From @cosmictraveler002/anim-kit/three, not the core barrel.

Re-renders a plain <img> as a rounded GL plane: hover presses a dent into the picture (the sample UV is squeezed toward the cursor, with a fake dome lighting term and a chroma split), and the card wipes in bottom-up on reveal. Corners are shader-true (rounded-box SDF with a 1px AA edge) and the cover crop runs in-shader (object-fit: cover maths), so any source aspect fills any card aspect without letterboxing.

<figure data-gl><img src="photo.jpg" alt="" /></figure>
import { webglMedia } from "@cosmictraveler002/anim-kit/three";

webglMedia("[data-gl]", { corner: 18, dent: 70, chroma: 2 });

| Option | Default | Notes | | --- | --- | --- | | src | first <img> | image source override when the wrapper has none | | corner | 16 | corner radius, px (SDF, not border-radius) | | dent | 70 | dent depth pressed into the card on hover, px | | chroma | 2 | rgb split at full hover, px | | reveal | true | wipe the card in bottom-up when its texture loads | | revealDuration | 1.1 | reveal duration, seconds | | hoverDuration | 0.6 | hover response duration, seconds | | dpr | 2 | device-pixel-ratio cap | | force | false | run even under prefers-reduced-motion |

Silent no-op ladder. Missing target → no-op; no WebGLRenderingContext (SSR, jsdom, WebGL disabled) → no-op before any context probe (a probe itself logs an error in jsdom); renderer refused → no-op; texture 404 → the plain <img> stays visible. Nothing ever logs. Reduced motion → the canvas never mounts; the static image is the resting state.

DOM. The wrapper gets inline position: relative if it was static (so the canvas can cover it — restored on destroy) and keeps layout, sizing and alt text in its <img>; the canvas takes over only after the texture has loaded. destroy() kills ticker + tweens + resize observer and disposes geometry, material, texture and renderer.

glRail(track, options?) => destroy

From @cosmictraveler002/anim-kit/three, not the core barrel.

The bounded drag rail drawn as a WebGL overlay: one fixed alpha canvas covers the stage and re-creates every card's picture from its DOM rect each frame, with the surface bent around an invisible cylinder — dragRail's physics (intent/pos ticker, tanh rubber-band, throw momentum, Lenis-safe wheel) underneath, and a perspective grid floor that fades into the horizon and drifts with the rail. The DOM keeps layout, labels and hit areas: the canvas paints under the track, so a caption sits over the GL media for free, and only each <img> gets hidden — after its texture has loaded.

The bend is one uniform per card in the vertex shader: each vertex maps its offset onto the cylinder (a = o / R, x = R·sin a, z = −R·(1−cos a)) re-centred on the card's own middle — the centre stays glued to the DOM position while the edges foreshorten, and the rotation arrives free from the curve. axis: "auto" hands the same rule to physics and bend: a row overflows → horizontal, only the column overflows → vertical, so one init serves the desktop row and the mobile stack.

<div class="rail-screen"> <!-- position: relative; overflow: hidden -->
  <div data-rail>
    <article class="rail-card"><img src="card-01.jpg" alt="" /><p>Night Atlas</p></article>
    <article class="rail-card"><img src="card-02.jpg" alt="" /><p>Signal Bloom</p></article>
  </div>
</div>
import { glRail } from "@cosmictraveler002/anim-kit/three";

glRail("[data-rail]", { radius: 1200, grid: true, throwScale: 14 });

| Option | Default | Notes | | --- | --- | --- | | viewport | track's parent | the stage the canvas covers — keep it overflow: hidden | | card | ":scope > *" | cards re-rendered — each needs an <img> | | axis | "auto" | "x" / "y" / "auto" — picks motion and bend axis | | radius | 1200 | cylinder radius the cards bend around, px | | corner | 16 | corner radius, px (SDF, not border-radius) | | grid | true | perspective grid floor under the cards | | dpr | 2 | device-pixel-ratio cap | | lerp / edge / throwScale / wheel / onTick | 0.1 / 140 / 14 / true / — | dragRail physics passthrough | | force | false | run even under prefers-reduced-motion |

Silent no-op ladder. Missing target → no-op; no WebGLRenderingContext (SSR, jsdom, WebGL disabled) → the flat dragRail rail still runs (plain <img>s, full motion) before any context probe; renderer refused → flat rail; texture 404 → that one card keeps its image; reduced motion → nothing mounts. Nothing ever logs. destroy() unwinds physics + GPU together (listeners, ticker, observers, geometry, materials, textures, renderer, canvas) and restores image opacities + the stage's inline position.

DOM. Stage position: relative; overflow: hidden with a transparent card background; the track is a flex row (or flex-direction: column; width: 100%; height: max-content for the vertical layout) with cursor: grab; user-select: none.


coverflowWheel(target, options?) => handle

Media cards on a flattened vertical wheel: each card bends along the arc in the vertex shader instead of staying a rigid plane, fronts face you bright while the backs show mirrored, one mip blurrier and half-lit, every card carries a soft rim glow and a hair of blur at its edge — but photos stay fully solid to their border, no translucent frames. Drag to scrub — a release within 5 % of a slot commits to the neighbouring card, smaller drags spring back — and while idle the wheel creeps a slot's lead-in and then advances on its own.

import { coverflowWheel } from "@cosmictraveler002/anim-kit/three";

const wheel = coverflowWheel("#deck", { idle: 5 });
wheel.goTo(3); // jump to a card (wraps)
wheel.next(); // one card on, 0.5 s
wheel.destroy();

| Option | Default | Notes | | --- | --- | --- | | index | 0 | first card on show (wraps) | | autoplay / idle | true / 5 | idle advance — seconds of stillness (with a 1/8-slot lead-in creep) | | duration | 1.15 | goTo() tween, seconds (next() / prev() use 0.5) | | margin | 0.1 | gap between cards, as a fraction of one slot | | glow | 0.32 | rim-glow intensity around every card, 0–1 (0 = hard flat cards) | | edgeBlur | 1 | soft blur at every card edge, 0–1 (0 = sharp edges); cards stay opaque either way | | dpr | 2 | device-pixel-ratio cap |

Cards are the host's own <img>s — cover-cropped into textures at mount (a failed decode drops that card; fewer than two survivors → inert) and restored from their inline opacity on teardown. Pointer capture keeps the drag over the deck wherever the pointer is and native image-dragging is blocked so a card never swallows the stream; the host claims cursor: grab + touch-action: pan-y and a data-ak-coverflow stamp, all restored by destroy(). Card backs reuse the front texture (mirrored, half light in-shader) — no second texture, no extra pass.

Silent no-op ladder. Missing target → inert handle; no WebGLRenderingContext (SSR, jsdom, WebGL disabled) → inert before any context probe; renderer refused → inert; reduced motion → inert, nothing mounts. Every call on an inert handle is a safe no-op — nothing ever logs. destroy() kills the tween + ticker, disconnects the observers, removes the canvas and restores the images, host styles and dataset.


tearReveal(target, options?) => destroy

A torn-edge sheet that sweeps a section in (or out): a noise-displaced boundary travels across the overlay — the fbm joins the field before thresholding, so patches tear away ahead of the front and ride across as separate shards — while a barrel warp bows the edge into an arriving curve instead of a straight line wobbling in place.

tearReveal("[data-tear]", {
  color: ["#ff8a1e", "#ff5252"], // [foot, top] gradient stops (or one colour)
  blend: "multiply",             // tint the section instead of covering it
  direction: "up",               // up | down | left | right
  mode: "scroll",                // play on enter — or "immediate"
  duration: 1,                   // seconds for the timed run
});
// …or bind it to scroll instead:
tearReveal("[data-tall]", { scrub: 0.4, start: "top 90%", end: "top 30%" });
tearReveal(document.body, { fixed: true }); // page-sized sweep over the viewport

| Option | Default | Notes | | --- | --- | --- | | color | "#111111" | one colour or [foot, top] gradient stops | | blend | "none" | CSS mix-blend-mode — multiply tints what is under the sheet | | direction | "up" | up / down / left / right | | dispAmp / dispScale / dispDetail / dispDrift | 0.175 / 12.2 / 5 / 0 | tear shape — how far, how big, how gritty, how fast it crawls | | soft | 0.001 | threshold half-width — small keeps shards crisp | | lens | -0.275 | barrel warp — negative bows the middle of the boundary up | | duration / ease | 1 / "power2.inOut" | timed run (mode: "scroll" or "immediate") | | scrub | — | smoothing seconds (or true) — progress follows the scroll, both ways | | start / end | "top 85%" / "top 25%" | ScrollTrigger positions | | replay | false | re-run on every viewport entry | | fixed / z / dpr | false / 1 / 2 | viewport-sized canvas, stacking, pixel-ratio cap |

Both ends of the travel are sealed: the displaced boundary reaches the far side with its deepest tear at exactly zero, so the last half-covered pixels never read as speckle or leftover haze.

Silent no-op ladder. Missing target → no-op; no WebGLRenderingContext (SSR, jsdom, WebGL disabled) → no-op before any context probe; renderer refused → no-op; reduced motion → nothing mounts. Nothing ever logs. destroy() kills the tween + ScrollTrigger, removes the canvas and restores the host's styles.

DOM. position: relative; overflow: hidden on the target — or skip the target box entirely with fixed: true for a page-sized sweep.


ditherReveal(target, options?) => destroy

A photo punches through its placeholder in a hard-edged, fbm-speckled ring expanding from the centre outward — no soft dissolve, no crossfade: every pixel on the boundary flips between hole and not-hole. The noise spread ramps to zero at both ends of the travel, so progress 0 is exactly empty and progress 1 exactly solid.

ditherReveal("[data-reveal]", {
  plate: "#14140f", // flat placeholder where the mask has not opened yet
  duration: 1.15,   // seconds, ease "power4.out"
  maskScale: 50,    // fbm frequency across the element — lump scale of the edge
  hard: true,       // the signature speckle (false = a soft dissolve)
  play: "visible",  // fire on first viewport entry ("load" = when the texture decodes)
});

| Option | Default | Notes | | --- | --- | --- | | src | first <img> | image source override when the wrapper has no image | | duration / ease | 1.15 / "power4.out" | the reveal clock | | plate | — | flat placeholder colour (omit → the wrapper's own background shows through) | | maskScale / spread / detail | 50 / 0.35 / 4 | edge shape — frequency, reach, fbm octaves | | hard | true | hard speckle vs soft dissolve | | play | "visible" | "visible" fires on first viewport entry, "load" on texture decode | | dpr | 2 | device-pixel-ratio cap |

The <img> keeps layout and alt text: the canvas covers the wrapper and only takes over its pixels once the texture has decoded — with a plate the placeholder covers from load, without one the image only steps aside when the reveal actually starts. The cover-crop runs in-shader (object-fit: cover maths), so any source aspect fills any card aspect.

Silent no-op ladder. Missing target → no-op; no WebGLRenderingContext (SSR, jsdom, WebGL disabled) → no-op before any context probe; renderer refused → no-op; texture 404 → the plain <img> stays untouched; reduced motion → the canvas never mounts and the static image is the resting state. Nothing ever logs. destroy() kills the tween + observers, disposes geometry/material/texture/renderer, removes the canvas and restores the image opacity and wrapper position.


wordmarkWave(target, options?) => destroy

The target's own headline rendered to a texture and simulated as a spring lattice: the pointer shears the letters like thick liquid, red and cyan split out of the colour while the surface deforms, and every vertex springs back to exact rest. Text, font and colour come from the element itself — nothing to export, nothing to align.

import { wordmarkWave } from "@cosmictraveler002/anim-kit/three";

const destroy = wordmarkWave("#mark", {
  drag: 1, // pointer-velocity term — how hard the letters smear
  push: 0, // radial push — vertices flee the cursor while it sits still
  chroma: true, // red/cyan fringes while deforming
});

| Option | Default | Notes | | --- | --- | --- | | text / font / color | target's own | override what is drawn (the host's colour is hidden while mounted) | | outline / tracking | 0 / 0 | stroke width, px (0 = filled) and extra letter spacing, px | | drag / push | 1 / 0 | pointer-velocity and radial-push terms of the simulation | | chroma | true | red/cyan chromatic split while deforming | | segments | 0 | grid columns — auto (≈10 px cells, ~5 k vertices) | | opacity / z / dpr | 1 / 1 / 2 | canvas opacity, stacking order, pixel-ratio cap |

The simulation steps at a fixed 60 Hz (max 4 steps per frame; a gap over 0.2 s counts as one step) so a backgrounded tab resumes where it left instead of exploding, and the ticker idles while the host is off-screen. Silent no-op ladder: missing target → no-op; no WebGLRenderingContext (SSR, jsdom, WebGL disabled) → no-op before any context probe; renderer refused → no-op; reduced motion → nothing mounts, the real text simply stays. Nothing ever logs. destroy() stops the ticker + observers, disposes the renderer, removes the canvas and restores the host colour it hid.


Loops & marquees

Continuous motion — marquees, draggable rails, equaliser bars.

marquee(track, options?) => destroy

Dual-row constant-speed marquee driven by requestAnimationFrame — 40 px/s, matching the source site.

marquee("[data-marquee-track]", { speed: 40, direction: "left", pauseOnHover: false });

| Option | Default | Notes | | -------------- | ------- | ------------------------------------------------- | | speed | 40 | pixels per second | | direction | data-dir | "left" / "right"; otherwise read from data-dir | | clone | true | duplicate content when it isn't already doubled | | pauseOnHover | false | |

DOM: a flex track of width: max-content inside an overflow: hidden viewport. Tracks are found by the [data-marquee-track] marker (pass one track, or a container and every track inside it is picked up); data-dir="left|right" sets each row's direction. The .ak-marquee* classes in the companion stylesheet provide the viewport and its edge masks. destroy() stops the rAF loop and removes the copy it duplicated (flag + children), so the markup matches what you started with — a copy you tiled yourself is left alone.

dragStrip(track, options?) => destroy

Infinite draggable carousel (GSAP Draggable), with items tilting as you pull and springing straight on release.

dragStrip("[data-drag]", { maxRotation: 60, rotationScale: 120, settleDuration: 1, inertia: false, clone: true, item: ":scope > *" });

| Option | Default | Notes | | ---------------- | --------------- | ------------------------------------------------------- | | maxRotation | 100 | max tilt in degrees at full drag speed | | rotationScale | 100 | divisor on the normalised drag speed — higher is subtler | | settleDuration | 1 | spring-back duration on release, seconds | | inertia | false | throw after release — requires GSAP's InertiaPlugin | | clone | true | duplicate content for a seamless loop; false clamps | | item | ":scope > *" | items inside the strip that tilt |

How the loop works. Draggable is the single writer of the track's X transform — a liveSnap hook folds every position back into [-loop, 0] (Draggable has no modifiers option; liveSnap is the supported place), so dragging is 1:1 with the pointer and never stutters between a tween and the drag. Folding is only invisible when the content tiles: with clone: true the strip duplicates itself until one tile is at least as wide as the viewport, and loop is always a whole multiple of the tile width — the same trick marquee() uses, so the seam is invisible. With clone: false there is nothing to fold against, so the strip clamps at the content edges instead (finite, but never an empty gap). During an inertia throw, liveSnap keeps folding each frame while the end target stays raw, so momentum keeps its direction.

Rotation tracks pointer speed (normalised to a 60 fps frame so mouse and touch event rates feel the same) rather than a raw per-event delta, and is driven by two quickTo tweens: a fast follow during the drag, then a settleDuration spring on release. destroy() kills both tweens and the Draggable, removes the cloned tiles and restores the element's inline cursor/user-select/touch-action.

DOM: an overflow: hidden viewport wrapping a flex track of width: max-content (the effect sets cursor: grab, user-select: none and touch-action: pan-y inline and restores them on destroy); items keep transform-origin: 50% 100% so they pivot from their base.

dragRail(track, options?) => destroy

Bounded drag rail with real physics — the finite counterpart to dragStrip()'s infinite loop. Pointer, wheel and trackpad all feed one intent value; a single ticker lerps the track toward it (lerp scaled by GSAP's deltaRatio, so the settle rate is framerate-independent), past either end the intent squashes through a tanh rubber-band (edge * tanh(overshoot / edge) — pull further, gain less, never a hard stop), and a release coasts on velocity sampled over the last few frames. Horizontal by default; axis: "auto" flips the rail vertical when only the column overflows, so a mobile card stack needs no re-init.

dragRail("[data-rail]", { tilt: 0.05, throwScale: 14, wheel: true, axis: "auto" });

| Option | Default | Notes | | --- | --- | --- | | viewport | track's parent | scroll viewport around the track | | axis | "x" | "x" / "y" / "auto" — auto follows the layout (pan-y / pan-x claims the rail's own gesture) | | item | ":scope > *" | children that tilt with velocity | | lerp | 0.1 | follow speed toward intent, per 60fps frame (0..1) | | edge | 140 | rubber-band resistance distance past the ends, px | | throwScale | 14 | momentum multiplier on release | | wheel | true | wheel/trackpad drives the rail too | | tilt | 0 | degrees of tilt per px/frame of velocity (0 = off) | | tiltMax | 8 | tilt clamp, degrees | | onTick | — | (pos, velocity) => {} every rendered frame | | force | false | run even under prefers-reduced-motion |

Single writer. Pointer, wheel and release handlers only ever touch intent; the ticker is the sole writer of the track transform — that separation is what gives a hard throw its buttery settle (the v1 stutter came from letting every input path write x directly).

Wheel is Lenis-safe. While the rail can still move in that direction the event gets preventDefault and stopPropagation (Lenis listens above us and would scroll the page in parallel). Once over-extended a full edge, the wheel passes through to the page — the section never traps the reader. When input stops, a restore force springs any overscrolled intent home while the lerp chases it.

Two axes. axis: "auto" re-resolves on every measure (only the column overflows → vertical) and re-keys the transform on a flip instead of leaving the stale axis behind — resize across the breakpoint and the rail just follows. vs glRail: the identical physics runs beneath glRail's WebGL overlay (bent cards + grid floor, /three subpath) — use plain dragRail for DOM media, glRail when the pictures should curve.

DOM: same shape as dragStrip — overflow: hidden viewport, flex track of width: max-content; the effect sets inline cursor / user-select / touch-action and clears them plus the transform/tilt on destroy(). Feed onTick(pos, velocity) a shader or skew effect to drive along with the rail.

audioBars(target, options?) => handle

Equaliser visualiser — returns { start, stop, destroy }.

const eq = audioBars("[data-eq]", { interval: 100, minHeight: 4, maxHeight: 16, bounce: 0.75, bar: ":scope > *" });
eq.start();  // animate
eq.stop();   // hold
eq.destroy();

DOM: a row of <i> bars — .ak-eq in the companion stylesheet. destroy() clears the interval, kills in-flight bar tweens (otherwise their next frame would rewrite height after teardown) and clears the inline height.


Buttons & links

Hover affordances for CTAs and inline links — fills, underlines, magnetic pulls.

liquidButton(target, options?) => destroy

SVG wave floods the button on hover.

liquidButton("[data-liquid]", { direction: "up", duration: 900, fill: "var(--ak-primary)", labelColor: "#fff" });

DOM: the .ak-liquid structure (the stylesheet defines the classes; the wave path shape is yours to choose):

<button class="ak-liquid" data-liquid>
  <svg class="ak-liquid__wave" viewBox="0 0 100 100" preserveAspectRatio="none">
    <path d="M0,30 Q50,-5 100,30 L100,100 L0,100 Z" />
  </svg>
  <span class="ak-liquid__label">Lets Talk →</span>
</button>

The effect stamps each element with data-ak-liquid="up|down" plus the --ak-liquid-duration/fill/label CSS variables (and removes them on destroy). Because direction applies to every matched element, scope the call for buttons that should fill the other way:

liquidButton("[data-liquid]", { direction: "up" });
liquidButton('[data-liquid][data-dir="down"]', { direction: "down" });

underlineLink(target) => destroy

Underline sweep for links — matches a.ak-underline.

underlineLink("a.ak-underline");   // or any link list

Pure CSS under the hood — it just adds/removes the .ak-underline class whose ::after sweep is styled by the companion stylesheet, and destroy() removes the class again.

magnetic(target, options?) => destroy

Buttons and links that lean toward the pointer while hovered — following a fraction of the pull with a tilt — then spring back to rest with an elastic snap on leave.

magnetic("[data-magnet]", { strength: 0.5, rotation: 10, scale: 1.04 });

| Option | Default | Notes | | --- | --- | --- | | strength | 0.4 | how far the element follows the pointer — fraction of its own box | | rotation | 8 | max tilt in degrees at full pull (0 disables) | | scale | 1 | scale held while the pointer is over the element (1 = none) | | duration | 1.2 | spring-back duration, seconds | | ease | "elastic.out(1, 0.35)" | spring-back ease |

DOM: pass a list (selector, array, NodeList) — each element gets its own listeners and its own pull. Keep CSS transitions off transform for magnetic elements (GSAP animates transform directly). destroy() removes the listeners, kills in-flight tweens and restores the inline transform.


Navigation & overlays

Page chrome — header behaviour, fullscreen menu, cursor.

navHide(nav, options?) => destroy

Header that hides on scroll-down and returns on scroll-up.

navHide("[data-nav]", { threshold: 200, hideY: -100, mobileBreakpoint: 768, startHidden: true });

destroy() removes the scroll listener, kills any in-flight slide and clears the nav's transform — it never starts a new animation during teardown.

menuOverlay(options) => handle

Full-screen curtain menu — clip-path polygon expands from the bottom edge, links stagger in.

const menu = menuOverlay({
  overlay: "[data-menu]",                          // required
  openTrigger: "[data-menu-open], [data-menu-open-2]",
  closeTrigger: "[data-menu-close]",
  nav: "[data-nav]",                               // slides away while open
  link: ".menu-link a",                            // default
  chrome: "[data-menu-chrome]",                    // default
  duration: 1,
  stagger: 0.1,
  initialOpen: false,
  onOpen: () => {}, onClose: () => {},
});

menu.open(); menu.close(); menu.toggle(); menu.isOpen(); menu.destroy();

Curtain uses EASES.curtain (.76,0,.24,1).

cursorFollower(zone, options?) => destroy

Spring-followed cursor tag, e.g. "▶ Play Showreel" over a video. The tag is pinned position: fixed and driven in viewport space (clientX/Y + offset), so it can live anywhere in the DOM — it only reacts while the pointer is over the zone. Style it with opacity: 0 and light text (color: #fff): the exclusion blend inverts the tag against whatever is behind it, which only reads with a light source colour.

cursorFollower("[data-showreel]", {
  follower: "[data-cursor]",           // defaults to the first [data-cursor]
  offset: 14,
  spring: { mass: 0.1, stiffness: 120 },
  blendMode: "exclusion",
  fade: true,                          // fade in/out with the pointer
});

destroy() kills both spring tweens (they are paused at creation and would otherwise live on the global timeline forever) plus any in-flight fade, removes the listeners, and restores the inline styles it overwrote.


Intros & transitions

Entrance and theme-change moments.

preloader(target, options?) => destroy

The 0→100 intro: counter ticks up while an SVG glyph fills via inset() clip-path, then the glyph scales up and the backdrop fades.

preloader("[data-preloader]", {
  glyph: "[data-glyph]",        // defaults to the first <svg> inside the root
  counter: "[data-counter]",    // defaults to [data-counter] inside the root
  backdrop: "[data-backdrop]",  // defaults to [data-backdrop] inside the root
  duration: 4,                  // seconds
  step: 5,                      // increment per tick
  interval: 200,                // ms
  sessionGuard: true,           // skip when already shown this session
  storageKey: "ak-preloader-shown",
  onComplete: () => {},
});

// Options-object form also works:
preloader({ root: "#preloader", sessionGuard: false });

destroy() clears the timers and kills the tweens.

themeReveal(options?) => handle

Light/dark toggle with a circular View Transitions wipe (graceful fallback to an instant swap when the API is missing).

const theme = themeReveal({
  toggle: "[data-theme]",
  storageKey: "ak-theme",
  initial: "dark",          // defaults to <html>'s current class
  origin: "50% 50%",        // or an element to centre the circle on
  duration: 1,
  onChange: (t) => {},
});

theme.set("dark"); theme.toggle(); theme.current(); theme.destroy();

inkWipe(options?) => handle

A brush-loaded ink sheet sweeps across the whole page: cover() paints it over, the swap happens at full cover, unveil() lets the new page arrive behind the same stroke — one continuous left→right travel whose edge is seeded, so it never boils between frames. Bristle spurs and flecks run ahead of the front.

const wipe = inkWipe({ links: "[data-wipe]" }); // intercepts links; next page auto-unveils

await wipe.cover();   // resolves at full cover — swap your DOM here
await wipe.unveil();  // the new page arrives behind the same stroke
wipe.destroy();

// Options-object form or target-first form both work:
inkWipe({ host: "#overlay", color: "#111" });
inkWipe(document.body, { coverMs: 700 });

| Option | Default | Notes | | --- | --- | --- | | host | document.body | overlay host the canvas is appended to (target arg also works) | | color | "#0b0b0b" | ink colour | | coverMs / unveilMs | 550 / 650 | sweep durations | | ease | "power2.inOut" | GSAP ease for both halves | | tilt / rough / bristle | 0.35 / 44 / 180 | edge lean (× height), noise amplitude px, longest spur px | | links | — | selectors intercepted — a click runs the wipe before navigating | | sessionKey / autoUnveil | "ak-ink-wipe" / true | cross-page handoff flag + the page-load unveil | | z | 9998 | overlay z-index |

The MPA handoff is a sessionStorage flag: a covered navigation stores sessionKey, the next page reads it, covers instantly and unveils — no flash between documents. Reduced motion snaps both halves instantly to a solid sheet, so navigation still never flashes the swap; without a 2D context (SSR, jsdom) both promises resolve immediately and nothing paints. destroy() removes the canvas, its listeners and the link interception.


Logos & SVG

Vector reveals for brand marks.

logoReveal(svg, options?) => destroy

SVG wordmark assembling letter by letter (staggered yPercent + fade).

logoReveal("[data-logo]", { path: ".svg-anim-path", stagger: 0.05, once: false });
// defaults: duration 1, ease "power2.out", start "top 80%", end "bottom top"

DOM: <svg data-logo> containing paths matching [data-logo-path], .svg-anim-path.


Utilities

toArray(target, scope?)        // resolve TargetLike → Element[]
one(target, scope?)            // resolve TargetLike → first Element | null
onReady(fn)                    // run after DOMContentLoaded (or immediately)
compose(...fns)                // combine destroy fns → one destroy (skips holes)
raf(fn)                        // rAF loop → returns a stop function
prefersReducedMotion()         // boolean, honours matchMedia

Types: TargetLike, Destroy, CommonOptions.


Styling

import "@cosmictraveler002/anim-kit/styles";   // → dist/styles/anim-kit.css

The companion stylesheet supplies:

  • Design tokens: --ak-primary, --ak-curtain, --ak-reveal, --ak-out
  • Text masks: .ak-line-mask, .ak-line, .ak-char-mask, .ak-char, .ak-word, .ak-space
  • Heading masks: .ak-mask, .ak-mask__inner
  • Rolling text: .ak-roll, .ak-roll__inner
  • Liquid button: .ak-liquid, .ak-liquid__wave, .ak-liquid__label
  • Underline: .ak-underline
  • Marquee: .ak-marquee, .ak-marquee__viewport, .ak-marquee__track
  • Drag strip: .ak-drag-track
  • Menu: .menu-overlay, .menu-overlay-bar, .menu-link
  • Card deck: .ak-stack-viewport, .ak-stack-cards, .ak-card
  • Misc: .ak-counter, .ak-eq

Override the tokens to rebrand:

:root {
  --ak-primary: #ff5c39;
  --ak-curtain: cubic-bezier(0.76, 0, 0.24, 1);
}

Effects only touch inline styles/transforms; the layout classes above are the resting states (safe with reduced motion).


Reduced motion

Everything routes through guard():

  • With prefers-reduced-motion: reduce, effects do not animate — they snap to a safe resting state (e.g. preloader hides the overlay, menuOverlay leaves the curtain collapsed, smoothScroll stays inert).
  • Opt a single call out with force: true:
lineReveal("h1", { force: true });   // animate regardless

Keyboard focus styles (.ak-liquid:focus-visible, .ak-underline:focus-visible) are preserved by the stylesheet.


Framework integration

Because each effect is target + options → destroy, wiring is mechanical.

React

useEffect(() => {
  const destroy = lineReveal(ref.current, { mode: "scroll" });
  return destroy;            // runs on unmount / StrictMode double-invoke
}, []);

Vue

onMounted(() => (destroy = marquee("[data-marquee-track]", { speed: 40 })));
onBeforeUnmount(() => destroy?.());

Svelte

<script lang="ts">
  import { onMount } from "svelte";
  onMount(() => parallax("[data-parallax]")); // returned fn runs on destroy
</script>

Next.js (App Router) — effects touch window, so create them in useEffect; never at module scope.

Mount effects after content is in the DOM (and after fonts/images if the effect measures — scatterText waits for document.fonts.ready itself), then destroy on teardown. Route changes and HMR are why destroy() exists.


Demo & tests

npm run build     # tsc → dist/ (ESM + .d.ts) + CSS copy + tsup standalone bundle
npm run demo      # static server on http://localhost:4321/demo/
npm run sync:live # regenerate demo_live/ (static deploy) from demo/
npm test          # build + unit smoke + demo integration smoke
npm run smoke     # both smokes (expects dist/ to exist)
npm run typecheck # tsc --noEmit (what CI runs)

The demo page wires the whole effect set against one document — demo/index.html + demo/demo.js — and every effect in the catalogue has an on-page copy prompt chip (the demo smoke enforces the two-way mapping). (split() is exercised by the unit smoke instead.)

Two trees share that wiring:

  • demo/ (default — npm run demo → /demo/) — import map on local files (/node_modules/..., /dist/index.js): fast iteration, works offline, and the pages demo-smoke drives.
  • demo_live/ (npm run sync:live regenerates it