@cosmictraveler002/anim-kit 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +293 -36
  2. package/dist/anim-kit.standalone.js +18 -8
  3. package/dist/anim-kit.standalone.js.map +1 -1
  4. package/dist/core/gsap.d.ts +3 -2
  5. package/dist/core/gsap.d.ts.map +1 -1
  6. package/dist/core/gsap.js +4 -3
  7. package/dist/core/gsap.js.map +1 -1
  8. package/dist/core/smooth-scroll.d.ts +1 -1
  9. package/dist/core/smooth-scroll.js +1 -1
  10. package/dist/effects/clip-wipe.d.ts +10 -2
  11. package/dist/effects/clip-wipe.d.ts.map +1 -1
  12. package/dist/effects/clip-wipe.js +30 -9
  13. package/dist/effects/clip-wipe.js.map +1 -1
  14. package/dist/effects/cursor-follower.d.ts.map +1 -1
  15. package/dist/effects/cursor-follower.js +22 -4
  16. package/dist/effects/cursor-follower.js.map +1 -1
  17. package/dist/effects/drag-rail.d.ts +23 -0
  18. package/dist/effects/drag-rail.d.ts.map +1 -0
  19. package/dist/effects/drag-rail.js +196 -0
  20. package/dist/effects/drag-rail.js.map +1 -0
  21. package/dist/effects/flip-words.d.ts +31 -0
  22. package/dist/effects/flip-words.d.ts.map +1 -0
  23. package/dist/effects/flip-words.js +104 -0
  24. package/dist/effects/flip-words.js.map +1 -0
  25. package/dist/effects/magnetic.d.ts +15 -0
  26. package/dist/effects/magnetic.d.ts.map +1 -0
  27. package/dist/effects/magnetic.js +59 -0
  28. package/dist/effects/magnetic.js.map +1 -0
  29. package/dist/effects/media-settle.d.ts +2 -0
  30. package/dist/effects/media-settle.d.ts.map +1 -1
  31. package/dist/effects/media-settle.js +5 -2
  32. package/dist/effects/media-settle.js.map +1 -1
  33. package/dist/effects/preloader.js +1 -1
  34. package/dist/effects/reel-text.d.ts +19 -0
  35. package/dist/effects/reel-text.d.ts.map +1 -0
  36. package/dist/effects/reel-text.js +163 -0
  37. package/dist/effects/reel-text.js.map +1 -0
  38. package/dist/index.d.ts +14 -2
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +9 -1
  41. package/dist/index.js.map +1 -1
  42. package/dist/three/index.d.ts +13 -0
  43. package/dist/three/index.d.ts.map +1 -0
  44. package/dist/three/index.js +12 -0
  45. package/dist/three/index.js.map +1 -0
  46. package/dist/three/webgl-media.d.ts +21 -0
  47. package/dist/three/webgl-media.d.ts.map +1 -0
  48. package/dist/three/webgl-media.js +268 -0
  49. package/dist/three/webgl-media.js.map +1 -0
  50. package/package.json +17 -1
  51. package/src/core/gsap.ts +4 -3
  52. package/src/core/smooth-scroll.ts +1 -1
  53. package/src/effects/clip-wipe.ts +50 -10
  54. package/src/effects/cursor-follower.ts +21 -4
  55. package/src/effects/drag-rail.ts +236 -0
  56. package/src/effects/flip-words.ts +148 -0
  57. package/src/effects/magnetic.ts +87 -0
  58. package/src/effects/media-settle.ts +7 -1
  59. package/src/effects/preloader.ts +1 -1
  60. package/src/effects/reel-text.ts +212 -0
  61. package/src/index.ts +18 -2
  62. package/src/three/index.ts +12 -0
  63. package/src/three/webgl-media.ts +325 -0
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # anim-kit
2
2
 
3
- A modular, framework-agnostic animation library extracted from
4
- [dzinrstudio.com](https://dzinrstudio.com/) — GSAP + ScrollTrigger + Lenis
3
+ A modular, framework-agnostic animation library by
4
+ [kalakritico.in](https://kalakritico.in/) — GSAP + ScrollTrigger + Lenis
5
5
  effects packaged as independent ES modules.
6
6
 
7
7
  TypeScript source → compiled ESM + `.d.ts` output. No framework, no virtual DOM,
@@ -78,8 +78,9 @@ inside the published tarball):
78
78
 
79
79
  | Specifier | Resolves to | Use for |
80
80
  |---|---|---|
81
- | `@cosmictraveler002/anim-kit` | `dist/index.js` + `dist/index.d.ts` | the full barrel — 43 exports |
81
+ | `@cosmictraveler002/anim-kit` | `dist/index.js` + `dist/index.d.ts` | the full barrel — 47 exports |
82
82
  | `@cosmictraveler002/anim-kit/effects/<name>` | `dist/effects/<name>.js` + `.d.ts` | one effect in isolation (`marquee`, `lineReveal`, …) |
83
+ | `@cosmictraveler002/anim-kit/three` | `dist/three/index.js` + `.d.ts` | WebGL effects — needs the optional `three` peer (never a core dep) |
83
84
  | `@cosmictraveler002/anim-kit/standalone` | `dist/anim-kit.standalone.js` (types → `index.d.ts`) | the self-contained bundle — same API |
84
85
  | `@cosmictraveler002/anim-kit/styles` | `dist/styles/anim-kit.css` | untouched plain CSS |
85
86
 
@@ -97,7 +98,7 @@ resolves declarations through the same map — no `typesVersions` shim needed.
97
98
 
98
99
  No build step on the consumer's end: `dist/` is served as-is from the npm
99
100
  tarball by any npm CDN. Every URL is **version-pinned** — npm versions are
100
- immutable, so `@cosmictraveler002/anim-kit@1.1.0` always resolves to exactly that build, forever
101
+ immutable, so `@cosmictraveler002/anim-kit@1.3.0` always resolves to exactly that build, forever
101
102
  (only a new version creates a new URL; nothing floats unless you ask for a
102
103
  range).
103
104
 
@@ -108,12 +109,12 @@ plugins anim-kit uses) and `lenis` **inlined** — no import map, one URL, works
108
109
  identically on jsDelivr and unpkg:
109
110
 
110
111
  ```html
111
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@cosmictraveler002/anim-kit@1.1.0/dist/styles/anim-kit.css" />
112
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@cosmictraveler002/anim-kit@1.3.0/dist/styles/anim-kit.css" />
112
113
 
113
114
  <script type="module">
114
115
  import {
115
116
  smoothScroll, lineReveal, marquee,
116
- } from "https://cdn.jsdelivr.net/npm/@cosmictraveler002/anim-kit@1.1.0/dist/anim-kit.standalone.js";
117
+ } from "https://cdn.jsdelivr.net/npm/@cosmictraveler002/anim-kit@1.3.0/dist/anim-kit.standalone.js";
117
118
 
118
119
  smoothScroll();
119
120
  lineReveal("[data-lines]", { mode: "scroll" });
@@ -121,7 +122,7 @@ identically on jsDelivr and unpkg:
121
122
  </script>
122
123
  ```
123
124
 
124
- unpkg serves the same file: `https://unpkg.com/@cosmictraveler002/anim-kit@1.1.0/dist/anim-kit.standalone.js`
125
+ unpkg serves the same file: `https://unpkg.com/@cosmictraveler002/anim-kit@1.3.0/dist/anim-kit.standalone.js`
125
126
 
126
127
  ### Option 2 — jsDelivr `+esm`
127
128
 
@@ -130,7 +131,7 @@ per version):
130
131
 
131
132
  ```html
132
133
  <script type="module">
133
- import { lineReveal } from "https://cdn.jsdelivr.net/npm/@cosmictraveler002/anim-kit@1.1.0/+esm";
134
+ import { lineReveal } from "https://cdn.jsdelivr.net/npm/@cosmictraveler002/anim-kit@1.3.0/+esm";
134
135
  </script>
135
136
  ```
136
137
 
@@ -142,17 +143,20 @@ locally, with CDN URLs — and the way to share one GSAP between anim-kit and
142
143
  the rest of your page:
143
144
 
144
145
  ```html
145
- <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@cosmictraveler002/anim-kit@1.1.0/dist/styles/anim-kit.css" />
146
+ <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@cosmictraveler002/anim-kit@1.3.0/dist/styles/anim-kit.css" />
146
147
 
147
148
  <script type="importmap">
148
149
  {
149
150
  "imports": {
150
- "@cosmictraveler002/anim-kit": "https://cdn.jsdelivr.net/npm/@cosmictraveler002/anim-kit@1.1.0/dist/index.js",
151
+ "@cosmictraveler002/anim-kit": "https://cdn.jsdelivr.net/npm/@cosmictraveler002/anim-kit@1.3.0/dist/index.js",
152
+ "@cosmictraveler002/anim-kit/three": "https://cdn.jsdelivr.net/npm/@cosmictraveler002/anim-kit@1.3.0/dist/three/index.js",
153
+ "three": "https://cdn.jsdelivr.net/npm/three@0.186.1/build/three.module.js",
151
154
  "gsap": "https://cdn.jsdelivr.net/npm/gsap@3.15.0/index.js",
152
155
  "gsap/ScrollTrigger": "https://cdn.jsdelivr.net/npm/gsap@3.15.0/ScrollTrigger.js",
153
156
  "gsap/SplitText": "https://cdn.jsdelivr.net/npm/gsap@3.15.0/SplitText.js",
154
157
  "gsap/Draggable": "https://cdn.jsdelivr.net/npm/gsap@3.15.0/Draggable.js",
155
158
  "gsap/CustomEase": "https://cdn.jsdelivr.net/npm/gsap@3.15.0/CustomEase.js",
159
+ "gsap/Flip": "https://cdn.jsdelivr.net/npm/gsap@3.15.0/Flip.js",
156
160
  "gsap/ScrollSmoother": "https://cdn.jsdelivr.net/npm/gsap@3.15.0/ScrollSmoother.js",
157
161
  "lenis": "https://cdn.jsdelivr.net/npm/lenis@1.3.26/dist/lenis.mjs"
158
162
  }
@@ -166,12 +170,42 @@ the rest of your page:
166
170
  </script>
167
171
  ```
168
172
 
169
- Swap the host for unpkg (`https://unpkg.com/@cosmictraveler002/anim-kit@1.1.0/dist/index.js`, …) —
173
+ Swap the host for unpkg (`https://unpkg.com/@cosmictraveler002/anim-kit@1.3.0/dist/index.js`, …) —
170
174
  the file layout is identical. GSAP subpaths are listed one by one because
171
175
  import maps match specifiers literally: a trailing-slash prefix map would
172
176
  produce extension-less URLs, which CDNs don't serve. The `gsap`/`lenis` pins
173
177
  match `package-lock.json`.
174
178
 
179
+ ### Version discipline — the pin must match the code
180
+
181
+ The pin is a contract between the URL and the importing code: a bundle older
182
+ than the names your code imports fails at **module instantiation**, before a
183
+ single line runs —
184
+
185
+ ```text
186
+ SyntaxError: The requested module '…/anim-kit@<old-version>/dist/anim-kit.standalone.js'
187
+ does not provide an export named 'clipWipe'
188
+ ```
189
+
190
+ On a page with a preloader that reads as **frozen on the loader**: the script
191
+ that would dismiss it never executed. Whenever a loader won't clear, open the
192
+ console first — an `… does not provide an export named 'X'` error is version
193
+ skew, not an effect bug.
194
+
195
+ Rules that keep it from happening:
196
+
197
+ - **Never hand-copy the demo or hand-edit CDN URLs.** Regenerate the
198
+ deployable copy with `npm run sync:live` — it rewrites every pin from
199
+ `CDN_VERSION` in `scripts/prompts.mjs`, the single source, and
200
+ `demo-smoke` fails CI if `demo/`, `demo_live/` or this README drift from
201
+ the release version.
202
+ - **On a release bump**, change `version` and `CDN_VERSION` together (the
203
+ smoke enforces it); every copy prompt, the docs page and `demo_live/`
204
+ follow automatically.
205
+ - **In your own project**, every CDN URL you ship must carry the *same*
206
+ version — mixing a new JS pin with an old CSS pin (or vice versa) is the
207
+ same bug in slow motion.
208
+
175
209
  ---
176
210
 
177
211
  ## Quick start
@@ -279,17 +313,20 @@ prompt dock, and backs the `category` / `subcategory` fields on
279
313
  | Text animations | Line & mask reveals | `lineReveal`, `maskReveal` |
280
314
  | Text animations | Per-character scatter | `scatterText` |
281
315
  | Text animations | Decode & scramble | `scrambleText` |
282
- | Text animations | Rolling text | `rollText` |
316
+ | Text animations | Rolling text | `rollText`, `reelText` |
283
317
  | Text animations | Counters | `counter` |
318
+ | Text animations | Layout transfers | `flipWords` |
284
319
  | Scroll & media | Pinned galleries | `horizontalScroll`, `stackedCards`, `stackedCardsPinned` |
285
320
  | Scroll & media | Parallax & depth | `parallax` |
286
321
  | Scroll & media | Heroes & media | `heroShrink`, `mediaSettle` |
287
322
  | Scroll & media | Enter reveals | `revealRule`, `unfoldReveal`, `clipWipe` |
323
+ | WebGL | Shader media | `webglMedia` |
288
324
  | Loops & marquees | Marquees | `marquee` |
289
- | Loops & marquees | Infinite draggables | `dragStrip` |
325
+ | Loops & marquees | Draggables & rails | `dragStrip`, `dragRail` |
290
326
  | Loops & marquees | Equalizers | `audioBars` |
291
327
  | Buttons & links | Liquid fills | `liquidButton` |
292
328
  | Buttons & links | Underlines | `underlineLink` |
329
+ | Buttons & links | Magnetic hover | `magnetic` |
293
330
  | Navigation & overlays | Menus & nav | `navHide`, `menuOverlay` |
294
331
  | Navigation & overlays | Cursors | `cursorFollower` |
295
332
  | Intros & transitions | Preloaders | `preloader` |
@@ -310,7 +347,7 @@ that does not exist.
310
347
  #### `initGSAP()`
311
348
 
312
349
  Registers the plugins anim-kit relies on (`ScrollTrigger`, `SplitText`,
313
- `Draggable`, `CustomEase`, `ScrollSmoother`) and the studio's custom eases.
350
+ `Draggable`, `CustomEase`, `Flip`, `ScrollSmoother`) and the studio's custom eases.
314
351
  Idempotent; called for you by every effect.
315
352
 
316
353
  #### `EASES`
@@ -349,7 +386,7 @@ Central reduced-motion gate. If the user prefers reduced motion and
349
386
  ### Text animations
350
387
 
351
388
  Typography in motion — masked lines, rising masks, per-character scatter,
352
- decode reveals, rolling words, tickers.
389
+ decode reveals, rolling words and reels, tickers, layout transfers.
353
390
 
354
391
  #### `lineReveal(target, options?) => destroy`
355
392
 
@@ -478,6 +515,73 @@ rollText("[data-roll-rev]", { direction: "down" }); // walk rows in reverse
478
515
  blocks itself. `destroy()` unwraps the rows, removes the clone and restores
479
516
  every inline style — markup comes back byte-identical.
480
517
 
518
+ #### `reelText(target, options?) => destroy`
519
+
520
+ Per-character odometer roll — the DOM slot-machine decode. Every alphanumeric
521
+ character gets a masked cell holding a vertical strip of same-case ghost
522
+ glyphs; the strip rolls upward and lands on the real text, staggered left to
523
+ right. Spaces and punctuation pass through untouched.
524
+
525
+ ```html
526
+ <p data-reel>Every character rolls into place.</p>
527
+ ```
528
+
529
+ ```ts
530
+ reelText("[data-reel]", { mode: "scroll", frames: 4, stagger: 0.05 }); // roll on enter
531
+ reelText("[data-immediate]"); // roll at init
532
+ ```
533
+
534
+ | Option | Default | Notes |
535
+ | --- | --- | --- |
536
+ | `mode` | `"immediate"` | `"scroll"` rolls on viewport enter instead |
537
+ | `start` | `"top 85%"` | ScrollTrigger start (scroll mode) |
538
+ | `frames` | `4` | ghost glyphs per character before the final one |
539
+ | `duration` | `0.8` | roll time per character, seconds |
540
+ | `stagger` | `0.05` | delay between characters, seconds |
541
+ | `ease` | `"power4.out"` | GSAP ease for the roll |
542
+ | `replay` | `false` | re-arm on every scroll re-entry instead of once |
543
+ | `force` | `false` | run even under `prefers-reduced-motion` |
544
+
545
+ **DOM:** targets must be plain text — the effect snapshots `innerHTML`, wraps
546
+ each character in a measured mask (width taken from the *final* glyph, so the
547
+ line never jitters while spinning) and restores the original markup byte for
548
+ byte when the roll completes and on `destroy()`. Scroll-mode registration
549
+ happens once and is killed on teardown; reduced motion leaves the target
550
+ completely untouched. No stylesheet required — masks are inline.
551
+
552
+ #### `flipWords(target, { to }, options?) => destroy`
553
+
554
+ Words measured in one layout, moved into another and animated from where
555
+ they stood — the FLIP technique: a column of words fans out into a row,
556
+ driven by scroll progress or played once.
557
+
558
+ ```ts
559
+ // Both blocks share one grid cell so the stage never reflows mid-flight:
560
+ flipWords("[data-flip-from]", { to: "[data-flip-to]", scrub: 0.6 });
561
+
562
+ // One-shot on enter (reverses on leave-back), or immediately:
563
+ flipWords("[data-flip-from]", { to: "[data-flip-to]", mode: "scroll" });
564
+ ```
565
+
566
+ | Option | Default | Notes |
567
+ | --- | --- | --- |
568
+ | `to` | — (required) | destination block; every word is moved into it |
569
+ | `words` | source children | `[data-flip-word]` matches, else the source's element children |
570
+ | `duration` | `1.4` | seconds for one word's travel |
571
+ | `ease` | `"power4.inOut"` | GSAP ease |
572
+ | `stagger` | `0.2` | seconds between word starts |
573
+ | `scale` | `0.2` | mid-flight squash each word pops through (`0` disables) |
574
+ | `mode` | `"scroll"` | or `"immediate"` to play at once |
575
+ | `scrub` | unset | number = scrub smoothing seconds, `true` = immediate |
576
+ | `start / end` | `"top 75%"` / `"bottom 45%"` | ScrollTrigger positions |
577
+
578
+ **DOM:** put the source and destination blocks in the same grid cell
579
+ (`grid-area: 1 / 1`, a `min-height` on both keeps the stage stable). The words
580
+ live in the destination from init but render at their source positions until
581
+ the timeline runs, so scrubbing reverses perfectly. `destroy()` kills the
582
+ timeline, puts every word back in its original parent and restores the
583
+ inline transform.
584
+
481
585
  #### `counter(target, options?) => destroy`
482
586
 
483
587
  Tabular number ticker.
@@ -545,23 +649,28 @@ as authored.
545
649
 
546
650
  #### `clipWipe(target, options?) => destroy`
547
651
 
548
- A `clip-path: inset()` reveal: the element is collapsed behind one edge (or
549
- inside a frame margin) and the inset animates to zero so it wipes into view.
652
+ A `clip-path: inset()` reveal: the element is collapsed behind one edge,
653
+ corner (or inside a frame margin) and the inset animates to zero so it wipes
654
+ into view.
550
655
 
551
656
  ```ts
552
657
  clipWipe("[data-clip]", { from: "left" }); // inset(0 100% 0 0) → 0
553
658
  clipWipe("[data-frame]", { from: "frame", inset: 12 }); // opens out of a frame
659
+ clipWipe("[data-corner]", { from: "bottom-right" }); // opens toward the opposite corner
660
+ clipWipe("[data-scrub]", { from: "frame", scrub: 0.5 }); // bound to scroll progress
554
661
  ```
555
662
 
556
663
  | Option | Default | Notes |
557
664
  | --- | --- | --- |
558
- | `from` | `"left"` | `"left"` / `"right"` / `"top"` / `"bottom"` / `"frame"` |
665
+ | `from` | `"left"` | `"left"` / `"right"` / `"top"` / `"bottom"`, a corner (`"top-left"` / `"top-right"` / `"bottom-left"` / `"bottom-right"`), or `"frame"` |
559
666
  | `inset` | `15` | frame margin in % (`from: "frame"`) |
560
667
  | `duration` | `1` | seconds |
561
668
  | `ease` | `"power3.out"` | GSAP ease |
562
669
  | `stagger` | `0.08` | seconds between targets |
563
670
  | `mode` | `"scroll"` | or `"immediate"` to play at once |
564
671
  | `start` | `"top 85%"` | ScrollTrigger start |
672
+ | `end` | `"top 20%"` | ScrollTrigger end (scrub mode) |
673
+ | `scrub` | unset | number = scrub smoothing seconds, `true` = immediate: tie the wipe to scroll progress |
565
674
  | `replay` | `false` | re-wipe when leaving / re-entering |
566
675
 
567
676
  Works on images, video, blocks and text. `destroy()` removes the inline
@@ -660,7 +769,8 @@ heroShrink("[data-hero-media]", { offsetY: "49vh", scale: 0.23, scrub: 1 });
660
769
 
661
770
  Images and video that arrive slightly oversized and ease down to size as the
662
771
  section enters — content lands instead of popping in. Set `scrub` to bind the
663
- settle to scroll progress instead of playing it once.
772
+ settle to scroll progress, or `replay: true` to reverse back to `from` on
773
+ leave-back and replay on every re-enter.
664
774
 
665
775
  ```ts
666
776
  mediaSettle("[data-settle]", { from: 1.15, duration: 1.5 }); // on enter
@@ -677,6 +787,7 @@ mediaSettle("[data-settle-scrub]", { scrub: 0.5, from: 1.2 }); // scroll-bound
677
787
  | `mode` | `"scroll"` | or `"immediate"` to play at once |
678
788
  | `start` / `end` | `"top 75%"` / `"bottom top"` | ScrollTrigger positions |
679
789
  | `scrub` | unset | number = scrub smoothing seconds, `true` = immediate |
790
+ | `replay` | `false` | re-settle when leaving / re-entering (enter mode) |
680
791
 
681
792
  `destroy()` clears `transform` — media returns to its authored scale. Unlike
682
793
  `heroShrink()` (which scrubs media down as it *leaves*), `mediaSettle()` plays
@@ -684,9 +795,63 @@ the entrance.
684
795
 
685
796
  ---
686
797
 
798
+ ### WebGL
799
+
800
+ GPU media — three.js re-renders your images as shader cards. This is the only
801
+ category behind the `@cosmictraveler002/anim-kit/three` subpath: `three` is an
802
+ **optional peer** (see [Subpath exports](#subpath-exports)), so the core
803
+ barrel and the standalone bundle stay three-free.
804
+
805
+ #### `webglMedia(target, options?) => destroy`
806
+
807
+ > From `@cosmictraveler002/anim-kit/three`, not the core barrel.
808
+
809
+ Re-renders a plain `<img>` as a rounded GL plane: hover presses a dent into
810
+ the picture (the sample UV is squeezed toward the cursor, with a fake dome
811
+ lighting term and a chroma split), and the card wipes in bottom-up on reveal.
812
+ Corners are shader-true (rounded-box SDF with a 1px AA edge) and the cover
813
+ crop runs in-shader (`object-fit: cover` maths), so any source aspect fills
814
+ any card aspect without letterboxing.
815
+
816
+ ```html
817
+ <figure data-gl><img src="photo.jpg" alt="" /></figure>
818
+ ```
819
+
820
+ ```ts
821
+ import { webglMedia } from "@cosmictraveler002/anim-kit/three";
822
+
823
+ webglMedia("[data-gl]", { corner: 18, dent: 70, chroma: 2 });
824
+ ```
825
+
826
+ | Option | Default | Notes |
827
+ | --- | --- | --- |
828
+ | `src` | first `<img>` | image source override when the wrapper has none |
829
+ | `corner` | `16` | corner radius, px (SDF, not `border-radius`) |
830
+ | `dent` | `70` | dent depth pressed into the card on hover, px |
831
+ | `chroma` | `2` | rgb split at full hover, px |
832
+ | `reveal` | `true` | wipe the card in bottom-up when its texture loads |
833
+ | `revealDuration` | `1.1` | reveal duration, seconds |
834
+ | `hoverDuration` | `0.6` | hover response duration, seconds |
835
+ | `dpr` | `2` | device-pixel-ratio cap |
836
+ | `force` | `false` | run even under `prefers-reduced-motion` |
837
+
838
+ **Silent no-op ladder.** Missing target → no-op; no `WebGLRenderingContext`
839
+ (SSR, jsdom, WebGL disabled) → no-op *before* any context probe (a probe
840
+ itself logs an error in jsdom); renderer refused → no-op; texture 404 → the
841
+ plain `<img>` stays visible. Nothing ever logs. Reduced motion → the canvas
842
+ never mounts; the static image is the resting state.
843
+
844
+ **DOM.** The wrapper gets inline `position: relative` if it was static (so
845
+ the canvas can cover it — restored on destroy) and keeps layout, sizing and
846
+ alt text in its `<img>`; the canvas takes over only after the texture has
847
+ loaded. `destroy()` kills ticker + tweens + resize observer and disposes
848
+ geometry, material, texture and renderer.
849
+
850
+ ---
851
+
687
852
  ### Loops & marquees
688
853
 
689
- Continuous motion — marquees, infinite draggables, equaliser bars.
854
+ Continuous motion — marquees, draggable rails, equaliser bars.
690
855
 
691
856
  #### `marquee(track, options?) => destroy`
692
857
 
@@ -755,6 +920,50 @@ cursor/user-select/touch-action.
755
920
  `touch-action: pan-y` inline and restores them on destroy); items keep
756
921
  `transform-origin: 50% 100%` so they pivot from their base.
757
922
 
923
+ #### `dragRail(track, options?) => destroy`
924
+
925
+ Bounded drag rail with real physics — the finite counterpart to
926
+ `dragStrip()`'s infinite loop. Pointer, wheel and trackpad all feed one
927
+ *intent* value; a single ticker lerps the track toward it (`lerp` scaled by
928
+ GSAP's `deltaRatio`, so the settle rate is framerate-independent), past
929
+ either end the intent squashes through a tanh rubber-band
930
+ (`edge * tanh(overshoot / edge)` — pull further, gain less, never a hard
931
+ stop), and a release coasts on velocity sampled over the last few frames.
932
+
933
+ ```ts
934
+ dragRail("[data-rail]", { tilt: 0.05, throwScale: 14, wheel: true });
935
+ ```
936
+
937
+ | Option | Default | Notes |
938
+ | --- | --- | --- |
939
+ | `viewport` | track's parent | scroll viewport around the track |
940
+ | `item` | `":scope > *"` | children that tilt with velocity |
941
+ | `lerp` | `0.1` | follow speed toward intent, per 60fps frame (0..1) |
942
+ | `edge` | `140` | rubber-band resistance distance past the ends, px |
943
+ | `throwScale` | `14` | momentum multiplier on release |
944
+ | `wheel` | `true` | wheel/trackpad drives the rail too |
945
+ | `tilt` | `0` | degrees of tilt per px/frame of velocity (`0` = off) |
946
+ | `tiltMax` | `8` | tilt clamp, degrees |
947
+ | `onTick` | — | `(pos, velocity) => {}` every rendered frame |
948
+ | `force` | `false` | run even under `prefers-reduced-motion` |
949
+
950
+ **Single writer.** Pointer, wheel and release handlers only ever touch
951
+ *intent*; the ticker is the sole writer of the track transform — that
952
+ separation is what gives a hard throw its buttery settle (the v1 stutter
953
+ came from letting every input path write `x` directly).
954
+
955
+ **Wheel is Lenis-safe.** While the rail can still move in that direction the
956
+ event gets `preventDefault` **and** `stopPropagation` (Lenis listens above us
957
+ and would scroll the page in parallel). Once over-extended a full `edge`, the
958
+ wheel passes through to the page — the section never traps the reader. When
959
+ input stops, a restore force springs any overscrolled intent home while the
960
+ lerp chases it.
961
+
962
+ **DOM:** same shape as `dragStrip` — `overflow: hidden` viewport, flex track
963
+ of `width: max-content`; the effect sets inline `cursor` / `user-select` /
964
+ `touch-action` and clears them plus the transform/tilt on `destroy()`. Feed
965
+ `onTick(pos, velocity)` a shader or skew effect to drive along with the rail.
966
+
758
967
  #### `audioBars(target, options?) => handle`
759
968
 
760
969
  Equaliser visualiser — returns `{ start, stop, destroy }`.
@@ -774,7 +983,7 @@ would rewrite `height` *after* teardown) and clears the inline height.
774
983
 
775
984
  ### Buttons & links
776
985
 
777
- Hover affordances for CTAs and inline links.
986
+ Hover affordances for CTAs and inline links — fills, underlines, magnetic pulls.
778
987
 
779
988
  #### `liquidButton(target, options?) => destroy`
780
989
 
@@ -818,6 +1027,29 @@ Pure CSS under the hood — it just adds/removes the `.ak-underline` class whose
818
1027
  `::after` sweep is styled by the companion stylesheet, and `destroy()` removes
819
1028
  the class again.
820
1029
 
1030
+ #### `magnetic(target, options?) => destroy`
1031
+
1032
+ Buttons and links that lean toward the pointer while hovered — following a
1033
+ fraction of the pull with a tilt — then spring back to rest with an elastic
1034
+ snap on leave.
1035
+
1036
+ ```ts
1037
+ magnetic("[data-magnet]", { strength: 0.5, rotation: 10, scale: 1.04 });
1038
+ ```
1039
+
1040
+ | Option | Default | Notes |
1041
+ | --- | --- | --- |
1042
+ | `strength` | `0.4` | how far the element follows the pointer — fraction of its own box |
1043
+ | `rotation` | `8` | max tilt in degrees at full pull (`0` disables) |
1044
+ | `scale` | `1` | scale held while the pointer is over the element (`1` = none) |
1045
+ | `duration` | `1.2` | spring-back duration, seconds |
1046
+ | `ease` | `"elastic.out(1, 0.35)"` | spring-back ease |
1047
+
1048
+ **DOM:** pass a list (selector, array, NodeList) — each element gets its own
1049
+ listeners and its own pull. Keep CSS transitions off `transform` for magnetic
1050
+ elements (GSAP animates transform directly). `destroy()` removes the
1051
+ listeners, kills in-flight tweens and restores the inline transform.
1052
+
821
1053
  ---
822
1054
 
823
1055
  ### Navigation & overlays
@@ -861,7 +1093,12 @@ Curtain uses `EASES.curtain` (`.76,0,.24,1`).
861
1093
 
862
1094
  #### `cursorFollower(zone, options?) => destroy`
863
1095
 
864
- Spring-followed cursor tag, e.g. "▶ Play Showreel" over a video.
1096
+ Spring-followed cursor tag, e.g. "▶ Play Showreel" over a video. The tag is
1097
+ pinned `position: fixed` and driven in viewport space (`clientX/Y + offset`),
1098
+ so it can live anywhere in the DOM — it only reacts while the pointer is over
1099
+ the zone. Style it with `opacity: 0` and light text (`color: #fff`): the
1100
+ `exclusion` blend inverts the tag against whatever is behind it, which only
1101
+ reads with a light source colour.
865
1102
 
866
1103
  ```ts
867
1104
  cursorFollower("[data-showreel]", {
@@ -1055,22 +1292,43 @@ destroy on teardown. Route changes and HMR are why `destroy()` exists.
1055
1292
  ```bash
1056
1293
  npm run build # tsc → dist/ (ESM + .d.ts) + CSS copy + tsup standalone bundle
1057
1294
  npm run demo # static server on http://localhost:4321/demo/
1295
+ npm run sync:live # regenerate demo_live/ (static deploy) from demo/
1058
1296
  npm test # build + unit smoke + demo integration smoke
1059
1297
  npm run smoke # both smokes (expects dist/ to exist)
1060
1298
  npm run typecheck # tsc --noEmit (what CI runs)
1061
1299
  ```
1062
1300
 
1063
1301
  The demo page wires the whole effect set against one document —
1064
- `demo/index.html` + `demo/demo.js`. (`stackedCardsPinned`, the
1065
- sibling-viewport variant, plus `split()` are exercised by the unit smoke
1066
- instead.)
1302
+ `demo/index.html` + `demo/demo.js` — and every effect in the catalogue has
1303
+ an on-page *copy prompt* chip (the demo smoke enforces the two-way mapping).
1304
+ (`split()` is exercised by the unit smoke instead.)
1305
+
1306
+ Two trees share that wiring:
1307
+
1308
+ - **`demo/`** (default — `npm run demo` → `/demo/`) — import map on local
1309
+ files (`/node_modules/...`, `/dist/index.js`): fast iteration, works
1310
+ offline, and the pages `demo-smoke` drives.
1311
+ - **`demo_live/`** (`npm run sync:live` regenerates it from `demo/`) — a
1312
+ deploy-anywhere copy: the **version-pinned CDN** import map and stylesheet
1313
+ (jsdelivr `@<version>` for the package — the `/three` subpath included —
1314
+ plus `three@0.186.1`, `gsap@3.15.0`, `lenis@1.3.26`: the exact URLs the
1315
+ prompts teach), a byte-copied `assets/` folder, relative internal links, a
1316
+ generated
1317
+ `prompts.json`, and agent crawler files (`robots.txt`, `sitemap.xml`,
1318
+ `llms.txt` with absolute URLs from `SITE_URL`), so the dock and docs run on
1319
+ any static host with no Node server (`/api/prompts` first, `prompts.json`
1320
+ fallback) and agents reach the full catalogue in one fetch instead of
1321
+ scraping HTML. Upload the folder as-is (Vercel, GitHub Pages, Netlify, S3).
1322
+ `demo-smoke` re-runs the generator and fails if the folder goes stale or a
1323
+ pin stops matching the release version.
1067
1324
 
1068
1325
  ### Copy-prompt API
1069
1326
 
1070
1327
  The demo server doubles as a **prompt server**: every effect has a ready-to-
1071
- paste *"how to implement this with anim-kit"* prompt — markup, import, CDN
1072
- usage (version-pinned, no build step), initialisation call, options table,
1073
- teardown and gotchas:
1328
+ paste *"how to implement this with anim-kit"* prompt — markup, a numbered
1329
+ copy-paste procedure that runs straight from the version-pinned CDN (no
1330
+ build step, plus the npm import for bundlers), initialisation call, options
1331
+ table, teardown and gotchas:
1074
1332
 
1075
1333
  ```bash
1076
1334
  curl http://localhost:4321/api/prompts # { count, categories, prompts: [{ id, title, summary, category, subcategory, text }] }
@@ -1078,7 +1336,7 @@ curl http://localhost:4321/api/prompts/marquee # one prompt, text/plain
1078
1336
  ```
1079
1337
 
1080
1338
  On the page, every labelled section carries a **copy prompt** chip, and the
1081
- floating **⧉ prompts (27)** button at the bottom right opens the full
1339
+ floating **⧉ prompts (29)** button at the bottom right opens the full
1082
1340
  catalogue grouped by [effect category](#effect-categories) — one click copies
1083
1341
  an effect's prompt (the prompt states its category), *copy all* puts the
1084
1342
  entire set on the clipboard. The catalogue lives in `scripts/prompts.mjs`:
@@ -1089,11 +1347,11 @@ and slotting the effect into a subcategory.
1089
1347
  **Unit smoke** (`scripts/smoke.mjs`) runs the built bundle in **jsdom** and
1090
1348
  asserts:
1091
1349
 
1092
- 1. all 43 exports are present;
1350
+ 1. all 47 exports are present;
1093
1351
  2. plugins (`ScrollTrigger`, `SplitText`, `Draggable`, `CustomEase`,
1094
- `ScrollSmoother`) and the 4 custom eases are registered;
1352
+ `Flip`, `ScrollSmoother`) and the 4 custom eases are registered;
1095
1353
  3. every effect no-ops safely on missing targets;
1096
- 4. 22 effects mount on real markup and unmount cleanly;
1354
+ 4. 24 effects mount on real markup and unmount cleanly;
1097
1355
  5. `preloader` ticks in both the positional and options-object call forms;
1098
1356
  6. `lineReveal` actually splits into masked lines (and per-character masks
1099
1357
  with `split: "chars"`) and restores markup on destroy;
@@ -1132,9 +1390,9 @@ anim-kit/
1132
1390
  │ │ ├─ guard.ts reduced-motion gate
1133
1391
  │ │ ├─ util.ts toArray/one/onReady/compose/raf
1134
1392
  │ │ └─ types.ts TargetLike / Destroy / CommonOptions
1135
- │ ├─ effects/ one file per effect (22 files, 26 effect functions)
1393
+ │ ├─ effects/ one file per effect (24 files, 28 effect functions)
1136
1394
  │ ├─ styles/anim-kit.css companion stylesheet
1137
- │ └─ index.ts barrel — 43 exports
1395
+ │ └─ index.ts barrel — 47 exports
1138
1396
  ├─ demo/ visual demo (import map, no bundler)
1139
1397
  ├─ scripts/
1140
1398
  │ ├─ serve.mjs static server + /api/prompts (:4321)
@@ -1165,8 +1423,7 @@ Each effect is an independent module — if you only need the marquee, import
1165
1423
 
1166
1424
  ## Credits
1167
1425
 
1168
- Effects extracted and reimplemented from the animation patterns of
1169
- [dzinrstudio.com](https://dzinrstudio.com/). Built on
1426
+ Effects by [kalakritico.in](https://kalakritico.in/). Built on
1170
1427
  [GSAP](https://gsap.com/) (free plugins only) and
1171
1428
  [Lenis](https://lenis.darkroom.engineering/).
1172
1429