@cosmictraveler002/anim-kit 1.1.0 → 1.2.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 (38) hide show
  1. package/README.md +114 -29
  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 +2 -1
  5. package/dist/core/gsap.d.ts.map +1 -1
  6. package/dist/core/gsap.js +3 -2
  7. package/dist/core/gsap.js.map +1 -1
  8. package/dist/effects/clip-wipe.d.ts +10 -2
  9. package/dist/effects/clip-wipe.d.ts.map +1 -1
  10. package/dist/effects/clip-wipe.js +30 -9
  11. package/dist/effects/clip-wipe.js.map +1 -1
  12. package/dist/effects/cursor-follower.d.ts.map +1 -1
  13. package/dist/effects/cursor-follower.js +22 -4
  14. package/dist/effects/cursor-follower.js.map +1 -1
  15. package/dist/effects/flip-words.d.ts +31 -0
  16. package/dist/effects/flip-words.d.ts.map +1 -0
  17. package/dist/effects/flip-words.js +104 -0
  18. package/dist/effects/flip-words.js.map +1 -0
  19. package/dist/effects/magnetic.d.ts +15 -0
  20. package/dist/effects/magnetic.d.ts.map +1 -0
  21. package/dist/effects/magnetic.js +59 -0
  22. package/dist/effects/magnetic.js.map +1 -0
  23. package/dist/effects/media-settle.d.ts +2 -0
  24. package/dist/effects/media-settle.d.ts.map +1 -1
  25. package/dist/effects/media-settle.js +5 -2
  26. package/dist/effects/media-settle.js.map +1 -1
  27. package/dist/index.d.ts +7 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +4 -0
  30. package/dist/index.js.map +1 -1
  31. package/package.json +1 -1
  32. package/src/core/gsap.ts +3 -2
  33. package/src/effects/clip-wipe.ts +50 -10
  34. package/src/effects/cursor-follower.ts +21 -4
  35. package/src/effects/flip-words.ts +148 -0
  36. package/src/effects/magnetic.ts +87 -0
  37. package/src/effects/media-settle.ts +7 -1
  38. package/src/index.ts +9 -1
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwCG;AAEH,oBAAoB;AACpB,OAAO,EAAE,IAAI,EAAE,aAAa,EAAE,SAAS,EAAE,SAAS,EAAE,UAAU,EAAE,cAAc,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAC;AAExH,OAAO,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAC;AACvD,OAAO,EAAE,KAAK,EAAE,MAAM,iBAAiB,CAAC;AAExC,OAAO,EAAE,KAAK,EAAE,MAAM,iBAAiB,CAAC;AACxC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAC;AAG3F,8BAA8B;AAC9B,OAAO,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AAGtD,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AAGlE,OAAO,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAC;AAG1D,OAAO,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AAGlD,OAAO,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AAGjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,gCAAgC,CAAC;AAGlE,OAAO,EAAE,YAAY,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AAG9E,OAAO,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AAGxD,OAAO,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AAGtD,OAAO,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AAGxD,OAAO,EAAE,OAAO,EAAE,MAAM,uBAAuB,CAAC;AAGhD,OAAO,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AAGtD,qBAAqB;AACrB,OAAO,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAC;AAG/C,OAAO,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AAGlD,OAAO,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;AAGpD,2BAA2B;AAC3B,OAAO,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAC;AAG1D,kCAAkC;AAClC,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAGzE,OAAO,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAG9D,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAG1D,OAAO,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AAGxD,OAAO,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AAGxD,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AAEH,oBAAoB;AACpB,OAAO,EAAE,IAAI,EAAE,aAAa,EAAE,SAAS,EAAE,SAAS,EAAE,UAAU,EAAE,cAAc,EAAE,QAAQ,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAC;AAExH,OAAO,EAAE,YAAY,EAAE,MAAM,yBAAyB,CAAC;AACvD,OAAO,EAAE,KAAK,EAAE,MAAM,iBAAiB,CAAC;AAExC,OAAO,EAAE,KAAK,EAAE,MAAM,iBAAiB,CAAC;AACxC,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,oBAAoB,EAAE,MAAM,gBAAgB,CAAC;AAG3F,8BAA8B;AAC9B,OAAO,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AAGtD,OAAO,EAAE,UAAU,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AAGlE,OAAO,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAC;AAG1D,OAAO,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AAGlD,OAAO,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AAGjD,OAAO,EAAE,gBAAgB,EAAE,MAAM,gCAAgC,CAAC;AAGlE,OAAO,EAAE,YAAY,EAAE,kBAAkB,EAAE,MAAM,4BAA4B,CAAC;AAG9E,OAAO,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AAGxD,OAAO,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AAGtD,OAAO,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AAGxD,OAAO,EAAE,OAAO,EAAE,MAAM,uBAAuB,CAAC;AAGhD,OAAO,EAAE,UAAU,EAAE,MAAM,0BAA0B,CAAC;AAGtD,qBAAqB;AACrB,OAAO,EAAE,OAAO,EAAE,MAAM,sBAAsB,CAAC;AAG/C,OAAO,EAAE,QAAQ,EAAE,MAAM,wBAAwB,CAAC;AAGlD,OAAO,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;AAGpD,2BAA2B;AAC3B,OAAO,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAC;AAG1D,OAAO,EAAE,SAAS,EAAE,MAAM,yBAAyB,CAAC;AAGpD,kCAAkC;AAClC,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,4BAA4B,CAAC;AAGzE,OAAO,EAAE,cAAc,EAAE,MAAM,8BAA8B,CAAC;AAG9D,OAAO,EAAE,QAAQ,EAAE,MAAM,uBAAuB,CAAC;AAGjD,OAAO,EAAE,OAAO,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAG1D,OAAO,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AAGxD,OAAO,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AAGxD,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmictraveler002/anim-kit",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Modular, framework-agnostic animation library — GSAP + ScrollTrigger + Lenis effects packaged as composable ES modules.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/core/gsap.ts CHANGED
@@ -10,10 +10,11 @@ import { SplitText } from "gsap/SplitText";
10
10
  import { Draggable } from "gsap/Draggable";
11
11
  import { CustomEase } from "gsap/CustomEase";
12
12
  import { ScrollSmoother } from "gsap/ScrollSmoother";
13
+ import { Flip } from "gsap/Flip";
13
14
  import { toArray } from "./util.js";
14
15
  import type { TargetLike } from "./types.js";
15
16
 
16
- export { gsap, ScrollTrigger, SplitText, Draggable, CustomEase, ScrollSmoother };
17
+ export { gsap, ScrollTrigger, SplitText, Draggable, CustomEase, ScrollSmoother, Flip };
17
18
 
18
19
  /** Named eases recovered from dzinrstudio.com's bundles. */
19
20
  export const EASES = {
@@ -34,7 +35,7 @@ export function initGSAP(): void {
34
35
  if (initted || typeof window === "undefined") return;
35
36
  initted = true;
36
37
 
37
- gsap.registerPlugin(ScrollTrigger, SplitText, Draggable, CustomEase);
38
+ gsap.registerPlugin(ScrollTrigger, SplitText, Draggable, CustomEase, Flip);
38
39
 
39
40
  // ScrollSmoother is a bonus for people who prefer it over Lenis; it is only
40
41
  // registered so `ScrollSmoother.create()` works if you call it yourself.
@@ -1,14 +1,22 @@
1
1
  /**
2
2
  * Clip wipe — a `clip-path: inset()` reveal.
3
3
  *
4
- * The element is collapsed behind one edge (or inside a frame margin) and
5
- * the inset animates to zero, wiping it into view:
4
+ * The element is collapsed behind one edge, corner (or inside a frame margin)
5
+ * and the inset animates to zero, wiping it into view:
6
6
  *
7
- * from: "left" inset(0 100% 0 0) → grows rightward from the left edge
8
- * from: "right" inset(0 0 0 100%) → grows leftward from the right edge
9
- * from: "top" inset(0 0 100% 0) → grows downward from the top edge
10
- * from: "bottom" inset(100% 0 0 0) → grows upward from the bottom edge
11
- * from: "frame" inset(15% 15% 15% 15%) → opens out of a centered frame
7
+ * from: "left" inset(0 100% 0 0) → grows rightward from the left edge
8
+ * from: "right" inset(0 0 0 100%) → grows leftward from the right edge
9
+ * from: "top" inset(0 0 100% 0) → grows downward from the top edge
10
+ * from: "bottom" inset(100% 0 0 0) → grows upward from the bottom edge
11
+ * from: "top-left" inset(0 100% 100% 0) → opens toward the bottom-right corner
12
+ * from: "top-right" inset(0 0 100% 100%) → opens toward the bottom-left corner
13
+ * from: "bottom-left" inset(100% 100% 0 0) → opens toward the top-right corner
14
+ * from: "bottom-right" inset(100% 0 0 100%) → opens toward the top-left corner
15
+ * from: "frame" inset(15% 15% 15% 15%) → opens out of a centered frame
16
+ *
17
+ * The wipe can also be bound to scroll progress (`scrub`), so the inset
18
+ * opens as the element travels through the viewport — the reveal then
19
+ * reverses with the scroll instead of playing once.
12
20
  *
13
21
  * Works on images, videos, blocks and text. destroy() kills the tween and
14
22
  * removes the inline clip-path.
@@ -18,9 +26,20 @@ import { guard } from "../core/guard.js";
18
26
  import { toArray } from "../core/util.js";
19
27
  import type { CommonOptions, Destroy, TargetLike } from "../core/types.js";
20
28
 
29
+ export type ClipWipeFrom =
30
+ | "left"
31
+ | "right"
32
+ | "top"
33
+ | "bottom"
34
+ | "top-left"
35
+ | "top-right"
36
+ | "bottom-left"
37
+ | "bottom-right"
38
+ | "frame";
39
+
21
40
  export interface ClipWipeOptions extends CommonOptions {
22
- /** Which edge the wipe starts from. @default "left" */
23
- from?: "left" | "right" | "top" | "bottom" | "frame";
41
+ /** Which edge or corner the wipe starts from. @default "left" */
42
+ from?: ClipWipeFrom;
24
43
  /** Frame margin in % (only used when `from: "frame"`). @default 15 */
25
44
  inset?: number;
26
45
  /** Animation duration, seconds. @default 1 */
@@ -35,8 +54,15 @@ export interface ClipWipeOptions extends CommonOptions {
35
54
  mode?: "scroll" | "immediate";
36
55
  /** ScrollTrigger start position. @default "top 85%" */
37
56
  start?: string;
57
+ /** ScrollTrigger end position (scrub mode). @default "top 20%" */
58
+ end?: string;
38
59
  /** Re-wipe when leaving / re-entering the viewport. @default false */
39
60
  replay?: boolean;
61
+ /**
62
+ * Bind the wipe to scroll progress instead of playing it on enter —
63
+ * number = scrub smoothing seconds, `true` = immediate. unset = one-shot.
64
+ */
65
+ scrub?: number | boolean;
40
66
  }
41
67
 
42
68
  const FROM: Record<string, string> = {
@@ -44,6 +70,10 @@ const FROM: Record<string, string> = {
44
70
  right: "inset(0% 0% 0% 100%)",
45
71
  top: "inset(0% 0% 100% 0%)",
46
72
  bottom: "inset(100% 0% 0% 0%)",
73
+ "top-left": "inset(0% 100% 100% 0%)",
74
+ "top-right": "inset(0% 0% 100% 100%)",
75
+ "bottom-left": "inset(100% 100% 0% 0%)",
76
+ "bottom-right": "inset(100% 0% 0% 100%)",
47
77
  };
48
78
  const TO = "inset(0% 0% 0% 0%)";
49
79
 
@@ -59,7 +89,9 @@ export function clipWipe(target: TargetLike, options: ClipWipeOptions = {}): Des
59
89
  delay = 0,
60
90
  mode = "scroll",
61
91
  start = "top 85%",
92
+ end = "top 20%",
62
93
  replay = false,
94
+ scrub,
63
95
  } = options;
64
96
 
65
97
  const els = toArray<HTMLElement>(target);
@@ -80,7 +112,15 @@ export function clipWipe(target: TargetLike, options: ClipWipeOptions = {}): Des
80
112
  delay,
81
113
  overwrite: "auto",
82
114
  };
83
- if (mode === "scroll") {
115
+ if (scrub !== undefined) {
116
+ vars.ease = "none";
117
+ vars.scrollTrigger = {
118
+ trigger: els[0],
119
+ start,
120
+ end,
121
+ scrub: scrub === true ? true : scrub,
122
+ };
123
+ } else if (mode === "scroll") {
84
124
  vars.scrollTrigger = {
85
125
  trigger: els[0],
86
126
  start,
@@ -5,6 +5,10 @@
5
5
  * whole thing fades/blurs in when the pointer enters the hot zone. The label
6
6
  * uses `mix-blend-mode: exclusion` so it inverts over any background.
7
7
  *
8
+ * The tag is pinned `position: fixed` and driven in VIEWPORT space
9
+ * (clientX/clientY + offset), so it can live anywhere in the DOM — it only
10
+ * reacts while the pointer is over the zone.
11
+ *
8
12
  * cursorFollower(zone, { follower: ".ak-cursor" })
9
13
  */
10
14
  import { gsap, initGSAP, killTweens } from "../core/gsap.js";
@@ -51,7 +55,11 @@ export function cursorFollower(
51
55
  };
52
56
 
53
57
  el.style.mixBlendMode = blendMode as never;
54
- el.style.position = "absolute";
58
+ // Viewport space: position:fixed + clientX/clientY keeps the tag at the
59
+ // pointer wherever it sits in the DOM. (The old position:absolute +
60
+ // zone-relative math only lined up when the tag was a child of the zone —
61
+ // a body-level tag landed near the document origin instead, off-screen.)
62
+ el.style.position = "fixed";
55
63
  el.style.left = "0";
56
64
  el.style.top = "0";
57
65
  el.style.pointerEvents = "none";
@@ -61,10 +69,19 @@ export function cursorFollower(
61
69
  const xTo = gsap.quickTo(el, "x", { ...spring, duration: 0.4, ease: "power3" });
62
70
  const yTo = gsap.quickTo(el, "y", { ...spring, duration: 0.4, ease: "power3" });
63
71
 
72
+ // First move lands on the pointer; later moves spring after it — starting
73
+ // from (0,0), the spring would otherwise carry the tag in from the corner.
74
+ let snapped = false;
64
75
  const onMove = (e: PointerEvent) => {
65
- const r = area.getBoundingClientRect();
66
- xTo(e.clientX - r.left + offset);
67
- yTo(e.clientY - r.top + offset);
76
+ const x = e.clientX + offset;
77
+ const y = e.clientY + offset;
78
+ if (snapped) {
79
+ xTo(x);
80
+ yTo(y);
81
+ } else {
82
+ gsap.set(el, { x, y });
83
+ snapped = true;
84
+ }
68
85
  if (fade) gsap.to(el, { opacity: 1, duration: 0.25, overwrite: "auto" });
69
86
  };
70
87
 
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Flip word transfer — words glide from one layout into another (FLIP).
3
+ *
4
+ * The source block holds the words; `to` is the block they land in. Both
5
+ * blocks should share one grid cell so the page never reflows when the
6
+ * words move:
7
+ *
8
+ * .flip-stage { display: grid; }
9
+ * .flip-stage > div { grid-area: 1 / 1; } // source + destination overlap
10
+ *
11
+ * On trigger every word is measured where it stands, moved into the
12
+ * destination, and animated from its old position (the classic FLIP
13
+ * technique), with a mid-flight squash so the words pop as they travel.
14
+ * Because the measurement happens up front, the words *look* like they are
15
+ * still in the source at scroll progress 0 even though they already live in
16
+ * the destination — so a scrubbed transfer reverses perfectly.
17
+ *
18
+ * flipWords("[data-flip-from]", { to: "[data-flip-to]", scrub: 1 });
19
+ *
20
+ * destroy() kills the timeline, puts every word back in its original
21
+ * parent (in the original order) and restores its inline transform.
22
+ */
23
+ import { gsap, Flip, ScrollTrigger, initGSAP, killTweens } from "../core/gsap.js";
24
+ import { guard } from "../core/guard.js";
25
+ import { one, toArray } from "../core/util.js";
26
+ import type { CommonOptions, Destroy, TargetLike } from "../core/types.js";
27
+
28
+ export interface FlipWordsOptions extends CommonOptions {
29
+ /** Destination block — every word is moved into it. */
30
+ to: TargetLike;
31
+ /**
32
+ * The words inside the source.
33
+ * @default `[data-flip-word]` matches, else the source's element children
34
+ */
35
+ words?: TargetLike;
36
+ /** Seconds for one word's travel. @default 1.4 */
37
+ duration?: number;
38
+ /** GSAP ease. @default "power4.inOut" */
39
+ ease?: string;
40
+ /** Seconds between word starts. @default 0.2 */
41
+ stagger?: number;
42
+ /** Mid-flight scale a word squashes to (0 disables the squash). @default 0.2 */
43
+ scale?: number;
44
+ /** "scroll" plays on enter (reverses on leave-back), "immediate" plays now. @default "scroll" */
45
+ mode?: "scroll" | "immediate";
46
+ /**
47
+ * Bind the transfer to scroll progress instead of playing it on enter —
48
+ * number = scrub smoothing seconds, `true` = immediate. unset = one-shot.
49
+ */
50
+ scrub?: number | boolean;
51
+ /** ScrollTrigger start. @default "top 75%" */
52
+ start?: string;
53
+ /** ScrollTrigger end (scrub mode). @default "bottom 45%" */
54
+ end?: string;
55
+ }
56
+
57
+ export function flipWords(from: TargetLike, options: FlipWordsOptions): Destroy {
58
+ initGSAP();
59
+
60
+ const src = one<HTMLElement>(from);
61
+ const dest = one<HTMLElement>(options?.to);
62
+ if (!src || !dest) return () => {};
63
+
64
+ const {
65
+ words,
66
+ duration = 1.4,
67
+ ease = "power4.inOut",
68
+ stagger = 0.2,
69
+ scale = 0.2,
70
+ mode = "scroll",
71
+ scrub,
72
+ start = "top 75%",
73
+ end = "bottom 45%",
74
+ } = options;
75
+
76
+ const marked = toArray<HTMLElement>(src.querySelectorAll("[data-flip-word]"));
77
+ const wordEls = words
78
+ ? toArray<HTMLElement>(words, src)
79
+ : marked.length
80
+ ? marked
81
+ : [...src.children] as HTMLElement[];
82
+ if (!wordEls.length) return () => {};
83
+
84
+ return guard(options, () => {
85
+ // Remember each word's home so destroy() can put everything back in order.
86
+ const home = wordEls.map((w) => ({ parent: w.parentNode, next: w.nextSibling }));
87
+ const prevTransform = wordEls.map((w) => w.style.transform);
88
+
89
+ // Measure in the source, move into the destination, then animate from
90
+ // the measurement — the FLIP. The wrapper timeline owns the flight so
91
+ // ScrollTrigger can scrub it and the scale accents can share its clock.
92
+ const state = Flip.getState(wordEls);
93
+ wordEls.forEach((w) => dest.appendChild(w));
94
+
95
+ const tl = gsap.timeline({ paused: true });
96
+ // The wrapper is paused, and `add` is synchronous, so the Flip child
97
+ // never renders on the global timeline before the wrapper owns it.
98
+ tl.add(Flip.from(state, { duration, ease, stagger: { each: stagger } }), 0);
99
+
100
+ if (scale > 0) {
101
+ wordEls.forEach((w, i) => {
102
+ const at = i * stagger;
103
+ tl.to(w, { scale, duration: duration * 0.5, ease }, at);
104
+ tl.to(w, { scale: 1, duration: duration * 0.5, ease }, at + duration * 0.5);
105
+ });
106
+ }
107
+
108
+ let st: ScrollTrigger | undefined;
109
+ if (mode === "immediate") {
110
+ tl.play(0);
111
+ } else if (scrub !== undefined) {
112
+ st = ScrollTrigger.create({
113
+ trigger: src,
114
+ start,
115
+ end,
116
+ scrub: scrub === true ? true : scrub,
117
+ animation: tl,
118
+ });
119
+ } else {
120
+ st = ScrollTrigger.create({
121
+ trigger: src,
122
+ start,
123
+ animation: tl,
124
+ toggleActions: "play none none reverse",
125
+ });
126
+ }
127
+ ScrollTrigger.refresh();
128
+
129
+ return () => {
130
+ st?.kill();
131
+ tl.kill();
132
+ killTweens(wordEls);
133
+ // Reparent home. A stored `next` sibling that has moved with the words
134
+ // is not in the parent anymore — fall back to appending in order.
135
+ wordEls.forEach((w, i) => {
136
+ const h = home[i];
137
+ if (!h.parent) return;
138
+ const ref = h.next && h.next.parentNode === h.parent ? h.next : null;
139
+ h.parent.insertBefore(w, ref);
140
+ });
141
+ gsap.set(wordEls, { clearProps: "transform" });
142
+ wordEls.forEach((w, i) => {
143
+ w.style.transform = prevTransform[i];
144
+ });
145
+ ScrollTrigger.refresh();
146
+ };
147
+ });
148
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Magnetic hover — buttons and links that pull toward the pointer.
3
+ *
4
+ * While the pointer is over the element it follows the cursor (a fraction
5
+ * of its own box), tilts toward the pull, and optionally grows a touch.
6
+ * On leave it springs back to rest with an elastic snap:
7
+ *
8
+ * magnetic("[data-magnet]", { strength: 0.5, rotation: 10 });
9
+ *
10
+ * Attach it to individual buttons/links (or a container's children — pass
11
+ * the list). destroy() removes the listeners, kills in-flight tweens and
12
+ * restores the inline transform.
13
+ */
14
+ import { gsap, initGSAP, killTweens } from "../core/gsap.js";
15
+ import { guard } from "../core/guard.js";
16
+ import { toArray } from "../core/util.js";
17
+ import type { CommonOptions, Destroy, TargetLike } from "../core/types.js";
18
+
19
+ export interface MagneticOptions extends CommonOptions {
20
+ /** How far the element follows the pointer — fraction of its own box. @default 0.4 */
21
+ strength?: number;
22
+ /** Max tilt in degrees at full pull (0 disables rotation). @default 8 */
23
+ rotation?: number;
24
+ /** Scale held while the pointer is over the element (1 = none). @default 1 */
25
+ scale?: number;
26
+ /** Spring-back duration, seconds. @default 1.2 */
27
+ duration?: number;
28
+ /** Spring-back ease. @default "elastic.out(1, 0.35)" */
29
+ ease?: string;
30
+ }
31
+
32
+ export function magnetic(target: TargetLike, options: MagneticOptions = {}): Destroy {
33
+ initGSAP();
34
+
35
+ const els = toArray<HTMLElement>(target);
36
+ if (!els.length) return () => {};
37
+
38
+ const {
39
+ strength = 0.4,
40
+ rotation = 8,
41
+ scale = 1,
42
+ duration = 1.2,
43
+ ease = "elastic.out(1, 0.35)",
44
+ } = options;
45
+
46
+ return guard(options, () => {
47
+ const prevTransform = els.map((el) => el.style.transform);
48
+ const listeners: Array<{ el: HTMLElement; type: string; fn: EventListener }> = [];
49
+
50
+ els.forEach((el) => {
51
+ const onMove = (e: Event) => {
52
+ const ptr = e as PointerEvent;
53
+ const r = el.getBoundingClientRect();
54
+ const dx = ptr.clientX - (r.left + r.width / 2);
55
+ const dy = ptr.clientY - (r.top + r.height / 2);
56
+ const nx = r.width ? dx / (r.width / 2) : 0; // -1 … 1 across the box
57
+ gsap.to(el, {
58
+ x: dx * strength,
59
+ y: dy * strength,
60
+ rotation: rotation ? nx * rotation : 0,
61
+ scale,
62
+ duration: 0.4,
63
+ ease: "power3.out",
64
+ overwrite: "auto",
65
+ });
66
+ };
67
+ const onLeave = () => {
68
+ gsap.to(el, { x: 0, y: 0, rotation: 0, scale: 1, duration, ease, overwrite: "auto" });
69
+ };
70
+
71
+ el.addEventListener("pointermove", onMove);
72
+ el.addEventListener("pointerleave", onLeave);
73
+ listeners.push(
74
+ { el, type: "pointermove", fn: onMove },
75
+ { el, type: "pointerleave", fn: onLeave },
76
+ );
77
+ });
78
+
79
+ return () => {
80
+ listeners.forEach(({ el, type, fn }) => el.removeEventListener(type, fn));
81
+ killTweens(els);
82
+ els.forEach((el, i) => {
83
+ el.style.transform = prevTransform[i];
84
+ });
85
+ };
86
+ });
87
+ }
@@ -7,6 +7,7 @@
7
7
  *
8
8
  * mediaSettle("[data-settle]", { from: 1.3 }); // on enter
9
9
  * mediaSettle("[data-settle]", { scrub: 0.5 }); // scroll-bound
10
+ * mediaSettle("[data-settle]", { replay: true }); // reverse on leave-back, replay on re-enter
10
11
  *
11
12
  * destroy() kills the tween and restores the original scale.
12
13
  */
@@ -34,6 +35,8 @@ export interface MediaSettleOptions extends CommonOptions {
34
35
  start?: string;
35
36
  /** ScrollTrigger end position (scrub mode). @default "bottom top" */
36
37
  end?: string;
38
+ /** Re-settle when leaving / re-entering the viewport (enter mode). @default false */
39
+ replay?: boolean;
37
40
  /**
38
41
  * Bind the settle to scroll progress instead of playing it on enter —
39
42
  * number = scrub smoothing seconds, `true` = immediate. unset = one-shot.
@@ -54,6 +57,7 @@ export function mediaSettle(target: TargetLike, options: MediaSettleOptions = {}
54
57
  mode = "scroll",
55
58
  start = "top 75%",
56
59
  end = "bottom top",
60
+ replay = false,
57
61
  scrub,
58
62
  } = options;
59
63
 
@@ -79,7 +83,9 @@ export function mediaSettle(target: TargetLike, options: MediaSettleOptions = {}
79
83
  vars.duration = duration;
80
84
  vars.ease = ease;
81
85
  if (mode === "scroll") {
82
- vars.scrollTrigger = { trigger: els[0], start, once: true };
86
+ vars.scrollTrigger = replay
87
+ ? { trigger: els[0], start, toggleActions: "play reverse play reverse" }
88
+ : { trigger: els[0], start, once: true };
83
89
  }
84
90
  }
85
91
 
package/src/index.ts CHANGED
@@ -28,11 +28,13 @@
28
28
  *
29
29
  * Text extras:
30
30
  * scrambleText() decode / cipher reveal per character
31
+ * flipWords() words that glide between two layouts (FLIP)
31
32
  *
32
33
  * Micro-interactions:
33
34
  * liquidButton() SVG wave flood on hover
34
35
  * underlineLink() underline wipe on hover
35
36
  * cursorFollower() spring-follow cursor tag
37
+ * magnetic() elements that pull toward the pointer and spring back
36
38
  * counter() tabular number ticker (timed or scroll-progress)
37
39
  * audioBars() equaliser visualiser
38
40
  * themeReveal() View Transitions circular wipe
@@ -61,7 +63,7 @@ export { unfoldReveal } from "./effects/unfold-reveal.js";
61
63
  export type { UnfoldRevealOptions } from "./effects/unfold-reveal.js";
62
64
 
63
65
  export { clipWipe } from "./effects/clip-wipe.js";
64
- export type { ClipWipeOptions } from "./effects/clip-wipe.js";
66
+ export type { ClipWipeOptions, ClipWipeFrom } from "./effects/clip-wipe.js";
65
67
 
66
68
  export { parallax } from "./effects/parallax.js";
67
69
  export type { ParallaxOptions } from "./effects/parallax.js";
@@ -101,6 +103,9 @@ export type { DragStripOptions } from "./effects/drag-strip.js";
101
103
  export { scrambleText } from "./effects/scramble-text.js";
102
104
  export type { ScrambleTextOptions } from "./effects/scramble-text.js";
103
105
 
106
+ export { flipWords } from "./effects/flip-words.js";
107
+ export type { FlipWordsOptions } from "./effects/flip-words.js";
108
+
104
109
  /* ---- micro-interactions ---- */
105
110
  export { liquidButton, underlineLink } from "./effects/liquid-button.js";
106
111
  export type { LiquidButtonOptions } from "./effects/liquid-button.js";
@@ -108,6 +113,9 @@ export type { LiquidButtonOptions } from "./effects/liquid-button.js";
108
113
  export { cursorFollower } from "./effects/cursor-follower.js";
109
114
  export type { CursorFollowerOptions } from "./effects/cursor-follower.js";
110
115
 
116
+ export { magnetic } from "./effects/magnetic.js";
117
+ export type { MagneticOptions } from "./effects/magnetic.js";
118
+
111
119
  export { counter, audioBars } from "./effects/counter.js";
112
120
  export type { CounterOptions, AudioBarsOptions, AudioBarsHandle } from "./effects/counter.js";
113
121