@cosmictraveler002/anim-kit 1.0.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 (127) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1033 -0
  3. package/dist/anim-kit.standalone.js +117 -0
  4. package/dist/anim-kit.standalone.js.map +1 -0
  5. package/dist/core/gsap.d.ts +44 -0
  6. package/dist/core/gsap.d.ts.map +1 -0
  7. package/dist/core/gsap.js +71 -0
  8. package/dist/core/gsap.js.map +1 -0
  9. package/dist/core/guard.d.ts +3 -0
  10. package/dist/core/guard.d.ts.map +1 -0
  11. package/dist/core/guard.js +18 -0
  12. package/dist/core/guard.js.map +1 -0
  13. package/dist/core/smooth-scroll.d.ts +45 -0
  14. package/dist/core/smooth-scroll.d.ts.map +1 -0
  15. package/dist/core/smooth-scroll.js +98 -0
  16. package/dist/core/smooth-scroll.js.map +1 -0
  17. package/dist/core/split.d.ts +24 -0
  18. package/dist/core/split.d.ts.map +1 -0
  19. package/dist/core/split.js +117 -0
  20. package/dist/core/split.js.map +1 -0
  21. package/dist/core/types.d.ts +21 -0
  22. package/dist/core/types.d.ts.map +1 -0
  23. package/dist/core/types.js +9 -0
  24. package/dist/core/types.js.map +1 -0
  25. package/dist/core/util.d.ts +19 -0
  26. package/dist/core/util.d.ts.map +1 -0
  27. package/dist/core/util.js +73 -0
  28. package/dist/core/util.js.map +1 -0
  29. package/dist/effects/counter.d.ts +52 -0
  30. package/dist/effects/counter.d.ts.map +1 -0
  31. package/dist/effects/counter.js +106 -0
  32. package/dist/effects/counter.js.map +1 -0
  33. package/dist/effects/cursor-follower.d.ts +19 -0
  34. package/dist/effects/cursor-follower.d.ts.map +1 -0
  35. package/dist/effects/cursor-follower.js +76 -0
  36. package/dist/effects/cursor-follower.js.map +1 -0
  37. package/dist/effects/drag-strip.d.ts +17 -0
  38. package/dist/effects/drag-strip.d.ts.map +1 -0
  39. package/dist/effects/drag-strip.js +186 -0
  40. package/dist/effects/drag-strip.js.map +1 -0
  41. package/dist/effects/hero-shrink.d.ts +15 -0
  42. package/dist/effects/hero-shrink.d.ts.map +1 -0
  43. package/dist/effects/hero-shrink.js +40 -0
  44. package/dist/effects/hero-shrink.js.map +1 -0
  45. package/dist/effects/horizontal-scroll.d.ts +17 -0
  46. package/dist/effects/horizontal-scroll.d.ts.map +1 -0
  47. package/dist/effects/horizontal-scroll.js +87 -0
  48. package/dist/effects/horizontal-scroll.js.map +1 -0
  49. package/dist/effects/line-reveal.d.ts +34 -0
  50. package/dist/effects/line-reveal.d.ts.map +1 -0
  51. package/dist/effects/line-reveal.js +71 -0
  52. package/dist/effects/line-reveal.js.map +1 -0
  53. package/dist/effects/liquid-button.d.ts +19 -0
  54. package/dist/effects/liquid-button.d.ts.map +1 -0
  55. package/dist/effects/liquid-button.js +52 -0
  56. package/dist/effects/liquid-button.js.map +1 -0
  57. package/dist/effects/logo-reveal.d.ts +15 -0
  58. package/dist/effects/logo-reveal.d.ts.map +1 -0
  59. package/dist/effects/logo-reveal.js +49 -0
  60. package/dist/effects/logo-reveal.js.map +1 -0
  61. package/dist/effects/marquee.d.ts +13 -0
  62. package/dist/effects/marquee.d.ts.map +1 -0
  63. package/dist/effects/marquee.js +143 -0
  64. package/dist/effects/marquee.js.map +1 -0
  65. package/dist/effects/mask-reveal.d.ts +36 -0
  66. package/dist/effects/mask-reveal.d.ts.map +1 -0
  67. package/dist/effects/mask-reveal.js +102 -0
  68. package/dist/effects/mask-reveal.js.map +1 -0
  69. package/dist/effects/menu-overlay.d.ts +32 -0
  70. package/dist/effects/menu-overlay.d.ts.map +1 -0
  71. package/dist/effects/menu-overlay.js +150 -0
  72. package/dist/effects/menu-overlay.js.map +1 -0
  73. package/dist/effects/nav-hide.d.ts +13 -0
  74. package/dist/effects/nav-hide.d.ts.map +1 -0
  75. package/dist/effects/nav-hide.js +68 -0
  76. package/dist/effects/nav-hide.js.map +1 -0
  77. package/dist/effects/parallax.d.ts +11 -0
  78. package/dist/effects/parallax.d.ts.map +1 -0
  79. package/dist/effects/parallax.js +48 -0
  80. package/dist/effects/parallax.js.map +1 -0
  81. package/dist/effects/preloader.d.ts +33 -0
  82. package/dist/effects/preloader.d.ts.map +1 -0
  83. package/dist/effects/preloader.js +128 -0
  84. package/dist/effects/preloader.js.map +1 -0
  85. package/dist/effects/scatter-text.d.ts +20 -0
  86. package/dist/effects/scatter-text.d.ts.map +1 -0
  87. package/dist/effects/scatter-text.js +87 -0
  88. package/dist/effects/scatter-text.js.map +1 -0
  89. package/dist/effects/stacked-cards.d.ts +24 -0
  90. package/dist/effects/stacked-cards.d.ts.map +1 -0
  91. package/dist/effects/stacked-cards.js +106 -0
  92. package/dist/effects/stacked-cards.js.map +1 -0
  93. package/dist/effects/theme-reveal.d.ts +35 -0
  94. package/dist/effects/theme-reveal.d.ts.map +1 -0
  95. package/dist/effects/theme-reveal.js +93 -0
  96. package/dist/effects/theme-reveal.js.map +1 -0
  97. package/dist/index.d.ts +77 -0
  98. package/dist/index.d.ts.map +1 -0
  99. package/dist/index.js +61 -0
  100. package/dist/index.js.map +1 -0
  101. package/dist/styles/anim-kit.css +361 -0
  102. package/package.json +80 -0
  103. package/src/core/gsap.ts +79 -0
  104. package/src/core/guard.ts +18 -0
  105. package/src/core/smooth-scroll.ts +143 -0
  106. package/src/core/split.ts +145 -0
  107. package/src/core/types.ts +30 -0
  108. package/src/core/util.ts +79 -0
  109. package/src/effects/counter.ts +192 -0
  110. package/src/effects/cursor-follower.ts +104 -0
  111. package/src/effects/drag-strip.ts +228 -0
  112. package/src/effects/hero-shrink.ts +69 -0
  113. package/src/effects/horizontal-scroll.ts +123 -0
  114. package/src/effects/line-reveal.ts +109 -0
  115. package/src/effects/liquid-button.ts +75 -0
  116. package/src/effects/logo-reveal.ts +76 -0
  117. package/src/effects/marquee.ts +157 -0
  118. package/src/effects/mask-reveal.ts +148 -0
  119. package/src/effects/menu-overlay.ts +218 -0
  120. package/src/effects/nav-hide.ts +90 -0
  121. package/src/effects/parallax.ts +68 -0
  122. package/src/effects/preloader.ts +187 -0
  123. package/src/effects/scatter-text.ts +129 -0
  124. package/src/effects/stacked-cards.ts +154 -0
  125. package/src/effects/theme-reveal.ts +144 -0
  126. package/src/index.ts +98 -0
  127. package/src/styles/anim-kit.css +361 -0
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Text splitting utilities.
3
+ *
4
+ * anim-kit prefers GSAP's SplitText when it is available (it ships with the
5
+ * free GSAP package), but keeps a dependency-free fallback so the library
6
+ * still works if SplitText is ever tree-shaken out or unavailable.
7
+ */
8
+ import { gsap, SplitText, initGSAP } from "./gsap.js";
9
+
10
+ export type SplitType = "chars" | "words" | "lines";
11
+
12
+ export interface SplitResult {
13
+ /** The elements that were produced (chars, words or lines). */
14
+ elements: HTMLElement[];
15
+ /** Undo the split and restore the original markup. */
16
+ revert: () => void;
17
+ }
18
+
19
+ export interface SplitOptions {
20
+ type: SplitType | Array<SplitType>;
21
+ /** Wrap each line in an overflow-hidden mask so it can slide up. */
22
+ mask?: boolean;
23
+ /** Class template for generated elements — `++` becomes the index. */
24
+ linesClass?: string;
25
+ wordsClass?: string;
26
+ charsClass?: string;
27
+ /** Ignore lines shorter than this fraction of the container. @default 0.05 */
28
+ lineThreshold?: number;
29
+ }
30
+
31
+ /**
32
+ * Split an element's text into chars/words/lines.
33
+ * Always returns a `revert()` that restores the original DOM.
34
+ */
35
+ export function split(target: Element | string, options: SplitOptions): SplitResult {
36
+ initGSAP();
37
+
38
+ const el =
39
+ typeof target === "string"
40
+ ? (document.querySelector<HTMLElement>(target) ?? null)
41
+ : (target as HTMLElement);
42
+
43
+ if (!el) return { elements: [], revert: () => {} };
44
+
45
+ // Prefer SplitText: it handles nested markup, <br> and font metrics properly.
46
+ if (typeof SplitText !== "undefined" && SplitText?.create) {
47
+ try {
48
+ const st = SplitText.create(el, {
49
+ type: options.type,
50
+ mask: options.mask ? "lines" : undefined,
51
+ linesClass: options.linesClass ?? "ak-line++",
52
+ wordsClass: options.wordsClass ?? "ak-word++",
53
+ charsClass: options.charsClass ?? "ak-char++",
54
+ lineThreshold: options.lineThreshold ?? 0.05,
55
+ } as ConstructorParameters<typeof SplitText>[1]);
56
+
57
+ const elements = collect(st, options.type);
58
+ return {
59
+ elements,
60
+ revert: () => {
61
+ try {
62
+ st.revert();
63
+ } catch {
64
+ /* already reverted */
65
+ }
66
+ },
67
+ };
68
+ } catch {
69
+ /* fall through to the manual splitter */
70
+ }
71
+ }
72
+
73
+ return manualSplit(el, options);
74
+ }
75
+
76
+ function collect(st: SplitText, type: SplitType | SplitType[]): HTMLElement[] {
77
+ const wanted = Array.isArray(type) ? type : [type];
78
+ const out: HTMLElement[] = [];
79
+ if (wanted.includes("lines") && st.lines) out.push(...(st.lines as HTMLElement[]));
80
+ else if (wanted.includes("words") && st.words) out.push(...(st.words as HTMLElement[]));
81
+ else if (wanted.includes("chars") && st.chars) out.push(...(st.chars as HTMLElement[]));
82
+ return out;
83
+ }
84
+
85
+ /* ------------------------------------------------------------------ */
86
+ /* Fallback: char / word splitting only (line detection is best-effort) */
87
+ /* ------------------------------------------------------------------ */
88
+
89
+ function manualSplit(el: HTMLElement, options: SplitOptions): SplitResult {
90
+ const original = el.innerHTML;
91
+ const type = Array.isArray(options.type) ? options.type[0] : options.type;
92
+ const nodes = Array.from(el.childNodes);
93
+
94
+ const generated: HTMLElement[] = [];
95
+
96
+ const wrap = (text: string, cls: string, i: number) => {
97
+ const span = document.createElement("span");
98
+ span.className = cls.replace("++", String(i));
99
+ span.textContent = text;
100
+ generated.push(span);
101
+ return span;
102
+ };
103
+
104
+ const frag = document.createDocumentFragment();
105
+ let index = 0;
106
+
107
+ for (const node of nodes) {
108
+ if (node.nodeType === Node.TEXT_NODE) {
109
+ const text = node.textContent ?? "";
110
+ if (type === "chars") {
111
+ for (const ch of text) {
112
+ frag.appendChild(
113
+ ch === " "
114
+ ? document.createTextNode(" ")
115
+ : wrap(ch, options.charsClass ?? "ak-char++", index++),
116
+ );
117
+ }
118
+ } else {
119
+ const parts = text.split(/(\s+)/);
120
+ for (const part of parts) {
121
+ if (!part) continue;
122
+ if (/^\s+$/.test(part)) frag.appendChild(document.createTextNode(part));
123
+ else frag.appendChild(wrap(part, options.wordsClass ?? "ak-word++", index++));
124
+ }
125
+ }
126
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
127
+ frag.appendChild(node.cloneNode(true));
128
+ }
129
+ }
130
+
131
+ el.innerHTML = "";
132
+ el.appendChild(frag);
133
+
134
+ if (options.mask) {
135
+ gsap.set(generated, { display: "inline-block" });
136
+ }
137
+
138
+ return {
139
+ elements: generated,
140
+ revert: () => {
141
+ el.innerHTML = original;
142
+ generated.length = 0;
143
+ },
144
+ };
145
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Shared types for anim-kit.
3
+ *
4
+ * Every effect in this library follows the same contract:
5
+ * effect(target, options) => destroy
6
+ * so effects can be composed, torn down and re-created safely.
7
+ */
8
+
9
+ /** Anything that can be resolved to a list of elements. */
10
+ export type TargetLike =
11
+ | string
12
+ | Element
13
+ | Element[]
14
+ | NodeListOf<Element>
15
+ | null
16
+ | undefined;
17
+
18
+ /** Dispose function returned by every effect. */
19
+ export type Destroy = () => void;
20
+
21
+ export interface CommonOptions {
22
+ /**
23
+ * Run the effect even when the user has `prefers-reduced-motion: reduce`.
24
+ * @default false
25
+ */
26
+ force?: boolean;
27
+ }
28
+
29
+ /** The animation state an effect should snap to when motion is reduced. */
30
+ export type ReducedState = Record<string, string | number>;
@@ -0,0 +1,79 @@
1
+ import type { Destroy, TargetLike } from "./types.js";
2
+
3
+ /**
4
+ * Resolve a selector / element / list into a real array of elements.
5
+ *
6
+ * When `scope` is given, string targets are queried inside it — this is what
7
+ * lets effects take both `".card"` and `".card" within container`.
8
+ */
9
+ export function toArray<T extends Element = HTMLElement>(
10
+ target: TargetLike,
11
+ scope?: ParentNode | null,
12
+ ): T[] {
13
+ if (!target) return [];
14
+ if (typeof target === "string") {
15
+ const root: ParentNode = scope ?? document;
16
+ return Array.from(root.querySelectorAll<T>(target));
17
+ }
18
+ if (target instanceof Element) return [target as T];
19
+ if (target instanceof NodeList || Array.isArray(target)) {
20
+ return Array.from(target) as T[];
21
+ }
22
+ return [];
23
+ }
24
+
25
+ /** Resolve to a single element, or null. */
26
+ export function one<T extends Element = HTMLElement>(
27
+ target: TargetLike,
28
+ scope?: ParentNode | null,
29
+ ): T | null {
30
+ return toArray<T>(target, scope)[0] ?? null;
31
+ }
32
+
33
+ /** Run `fn` once the DOM is ready (no-op if it is already interactive/complete). */
34
+ export function onReady(fn: () => void): void {
35
+ if (typeof document === "undefined") return;
36
+ if (document.readyState === "loading") {
37
+ document.addEventListener("DOMContentLoaded", fn, { once: true });
38
+ } else {
39
+ fn();
40
+ }
41
+ }
42
+
43
+ /** Compose several destroy functions into one. */
44
+ export function compose(...fns: Array<Destroy | undefined | null>): Destroy {
45
+ return () => {
46
+ for (let i = fns.length - 1; i >= 0; i--) {
47
+ try {
48
+ fns[i]?.();
49
+ } catch {
50
+ /* destroying twice must never throw */
51
+ }
52
+ }
53
+ };
54
+ }
55
+
56
+ /** Schedule a callback on the next animation frame; returns a cancel fn. */
57
+ export function raf(fn: () => void): () => void {
58
+ let id = 0;
59
+ let pending = false;
60
+ const run = () => {
61
+ pending = false;
62
+ fn();
63
+ };
64
+ const schedule = () => {
65
+ if (pending) return;
66
+ pending = true;
67
+ id = requestAnimationFrame(run);
68
+ };
69
+ schedule();
70
+ return () => cancelAnimationFrame(id);
71
+ }
72
+
73
+ /** Read the user's motion preference (safe outside the browser). */
74
+ export function prefersReducedMotion(): boolean {
75
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
76
+ return false;
77
+ }
78
+ return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
79
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * Counter — the big tabular-nums number that ticks up.
3
+ *
4
+ * Drives an element's `textContent` from `from` to `to` with an easing curve,
5
+ * optionally triggered by ScrollTrigger. Uses integer stepping so there is no
6
+ * fractional flicker, and pairs with `tabular-nums` for stable layout.
7
+ *
8
+ * counter("[data-count]", { to: 100, duration: 4, ease: "power1.inOut" })
9
+ */
10
+ import { gsap, ScrollTrigger, initGSAP, killTweens } from "../core/gsap.js";
11
+ import { guard } from "../core/guard.js";
12
+ import { toArray } from "../core/util.js";
13
+ import type { CommonOptions, Destroy, TargetLike } from "../core/types.js";
14
+
15
+ export interface CounterOptions extends CommonOptions {
16
+ /** Start value. @default 0 */
17
+ from?: number;
18
+ /** End value. @default 100 */
19
+ to?: number;
20
+ /** Duration in seconds. @default 4 */
21
+ duration?: number;
22
+ /** GSAP ease. @default "power1.inOut" */
23
+ ease?: string;
24
+ /** Pad with leading zeros to this width. @default 0 (no padding) */
25
+ pad?: number;
26
+ /** Suffix appended to the number, e.g. "+". @default "" */
27
+ suffix?: string;
28
+ /** Animate when scrolled into view instead of immediately. @default false */
29
+ onScroll?: boolean;
30
+ start?: string;
31
+ onComplete?: (value: number) => void;
32
+ }
33
+
34
+ export function counter(target: TargetLike, options: CounterOptions = {}): Destroy {
35
+ initGSAP();
36
+
37
+ const els = toArray<HTMLElement>(target);
38
+ if (!els.length) return () => {};
39
+
40
+ const {
41
+ from = 0,
42
+ to = 100,
43
+ duration = 4,
44
+ ease = "power1.inOut",
45
+ pad = 0,
46
+ suffix = "",
47
+ onScroll = false,
48
+ start = "top 90%",
49
+ onComplete,
50
+ } = options;
51
+
52
+ return guard(options, run);
53
+
54
+ function run(): Destroy {
55
+ const tweens: gsap.core.Tween[] = [];
56
+
57
+ els.forEach((el) => {
58
+ const state = { v: from };
59
+ const render = () => {
60
+ const n = Math.round(state.v);
61
+ el.textContent = (pad ? String(n).padStart(pad, "0") : String(n)) + suffix;
62
+ };
63
+ render();
64
+
65
+ const vars: gsap.TweenVars = {
66
+ v: to,
67
+ duration,
68
+ ease,
69
+ onUpdate: render,
70
+ onComplete: () => {
71
+ render();
72
+ onComplete?.(Math.round(state.v));
73
+ },
74
+ };
75
+
76
+ if (onScroll) {
77
+ vars.scrollTrigger = { trigger: el, start, once: true };
78
+ }
79
+
80
+ tweens.push(gsap.to(state, vars));
81
+ });
82
+
83
+ if (onScroll) ScrollTrigger.refresh();
84
+
85
+ return () => {
86
+ tweens.forEach((t) => {
87
+ t.scrollTrigger?.kill();
88
+ t.kill();
89
+ });
90
+ };
91
+ }
92
+ }
93
+
94
+ /**
95
+ * Audio bars — the 5-bar equaliser inside the background-music button.
96
+ *
97
+ * Heights re-randomise on an interval and each bar springs to its new height,
98
+ * giving that loose, twitchy look. `stop()` collapses the bars back down.
99
+ *
100
+ * const eq = audioBars(".ak-eq");
101
+ * eq.start(); // on play
102
+ * eq.stop(); // on pause
103
+ */
104
+ export interface AudioBarsOptions extends CommonOptions {
105
+ /** Bars inside the container. @default ":scope > *" */
106
+ bar?: string;
107
+ /** Re-randomise interval in ms. @default 100 */
108
+ interval?: number;
109
+ /** Min bar height px. @default 4 */
110
+ minHeight?: number;
111
+ /** Max bar height px. @default 14 */
112
+ maxHeight?: number;
113
+ /**
114
+ * Spring feel, 0..1. Higher = bouncier. Used to pick the overshoot ease.
115
+ * @default 0.75
116
+ */
117
+ bounce?: number;
118
+ }
119
+
120
+ export interface AudioBarsHandle {
121
+ start: () => void;
122
+ stop: () => void;
123
+ destroy: Destroy;
124
+ }
125
+
126
+ export function audioBars(
127
+ target: TargetLike,
128
+ options: AudioBarsOptions = {},
129
+ ): AudioBarsHandle {
130
+ initGSAP();
131
+
132
+ const inert: AudioBarsHandle = { start: () => {}, stop: () => {}, destroy: () => {} };
133
+ const root = toArray<HTMLElement>(target)[0];
134
+ if (!root) return inert;
135
+
136
+ const {
137
+ bar = ":scope > *",
138
+ interval = 100,
139
+ minHeight = 4,
140
+ maxHeight = 14,
141
+ bounce = 0.75,
142
+ } = options;
143
+
144
+ const bars = toArray<HTMLElement>(bar, root);
145
+ if (!bars.length) return inert;
146
+
147
+ let timer = 0;
148
+
149
+ const start = () => {
150
+ if (timer) return;
151
+ randomise();
152
+ timer = window.setInterval(randomise, interval);
153
+ };
154
+
155
+ const stop = () => {
156
+ if (timer) window.clearInterval(timer);
157
+ timer = 0;
158
+ bars.forEach((b) =>
159
+ gsap.to(b, { height: minHeight, duration: 0.4, ease: "power2.out", overwrite: "auto" }),
160
+ );
161
+ };
162
+
163
+ const destroy = guard(options, () => {
164
+ gsap.set(bars, { height: minHeight, force3D: true });
165
+ start();
166
+ return () => {
167
+ if (timer) window.clearInterval(timer); // match window.setInterval in start()
168
+ timer = 0;
169
+ // Kill in-flight bar tweens first — otherwise their next frame would
170
+ // rewrite `height` *after* clearProps, mutating the DOM post-destroy.
171
+ // killTweens() (not killTweensOf) also catches batches created since
172
+ // the last ticker frame — those are still lazy and survive killTweensOf.
173
+ killTweens(bars);
174
+ gsap.set(bars, { clearProps: "height" });
175
+ };
176
+ });
177
+
178
+ return { start, stop, destroy };
179
+
180
+ function randomise() {
181
+ bars.forEach((b) => {
182
+ const v = Math.random() * 0.8 + 0.2;
183
+ gsap.to(b, {
184
+ height: Math.max(minHeight, maxHeight * v),
185
+ duration: 0.45,
186
+ // Underdamped springs overshoot; map the bounce option onto back.out.
187
+ ease: `back.out(${(bounce * 1.8).toFixed(2)})`,
188
+ overwrite: "auto",
189
+ });
190
+ });
191
+ }
192
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Cursor follower — the "Play Showreel" tag that trails the pointer.
3
+ *
4
+ * Two springs drive X and Y independently (mass 0.1, as on the site), and the
5
+ * whole thing fades/blurs in when the pointer enters the hot zone. The label
6
+ * uses `mix-blend-mode: exclusion` so it inverts over any background.
7
+ *
8
+ * cursorFollower(zone, { follower: ".ak-cursor" })
9
+ */
10
+ import { gsap, initGSAP, killTweens } from "../core/gsap.js";
11
+ import { guard } from "../core/guard.js";
12
+ import { one } from "../core/util.js";
13
+ import type { CommonOptions, Destroy, TargetLike } from "../core/types.js";
14
+
15
+ export interface CursorFollowerOptions extends CommonOptions {
16
+ /** The floating element. Defaults to the first `[data-cursor]` in the document. */
17
+ follower?: TargetLike;
18
+ /** Offset added to the pointer position, px. @default 14 */
19
+ offset?: number;
20
+ /** Spring config for the follower. @default { mass: 0.1, stiffness: 120 } */
21
+ spring?: { mass?: number; stiffness?: number; damping?: number };
22
+ /** Blend mode applied to the follower. @default "exclusion" */
23
+ blendMode?: string;
24
+ /** Fade the follower in/out with the pointer. @default true */
25
+ fade?: boolean;
26
+ }
27
+
28
+ export function cursorFollower(
29
+ zone: TargetLike,
30
+ options: CursorFollowerOptions = {},
31
+ ): Destroy {
32
+ initGSAP();
33
+
34
+ const el =
35
+ (options.follower ? one<HTMLElement>(options.follower) : null) ??
36
+ document.querySelector<HTMLElement>("[data-cursor]");
37
+ const area = one<HTMLElement>(zone);
38
+
39
+ if (!el || !area) return () => {};
40
+
41
+ const { offset = 14, spring = { mass: 0.1, stiffness: 120 }, blendMode = "exclusion", fade = true } =
42
+ options;
43
+
44
+ return guard(options, () => {
45
+ const prevStyle = {
46
+ mixBlendMode: el.style.mixBlendMode,
47
+ position: el.style.position,
48
+ left: el.style.left,
49
+ top: el.style.top,
50
+ pointerEvents: el.style.pointerEvents,
51
+ };
52
+
53
+ el.style.mixBlendMode = blendMode as never;
54
+ el.style.position = "absolute";
55
+ el.style.left = "0";
56
+ el.style.top = "0";
57
+ el.style.pointerEvents = "none";
58
+
59
+ gsap.set(el, { opacity: fade ? 0 : 1, x: 0, y: 0 });
60
+
61
+ const xTo = gsap.quickTo(el, "x", { ...spring, duration: 0.4, ease: "power3" });
62
+ const yTo = gsap.quickTo(el, "y", { ...spring, duration: 0.4, ease: "power3" });
63
+
64
+ const onMove = (e: PointerEvent) => {
65
+ const r = area.getBoundingClientRect();
66
+ xTo(e.clientX - r.left + offset);
67
+ yTo(e.clientY - r.top + offset);
68
+ if (fade) gsap.to(el, { opacity: 1, duration: 0.25, overwrite: "auto" });
69
+ };
70
+
71
+ const onLeave = () => {
72
+ if (fade) gsap.to(el, { opacity: 0, duration: 0.25, overwrite: "auto" });
73
+ };
74
+
75
+ const onKey = (e: KeyboardEvent) => {
76
+ if (e.key === "Enter" || e.key === " ") {
77
+ /* host app handles activation; keep zone keyboard-reachable */
78
+ }
79
+ };
80
+
81
+ area.addEventListener("pointermove", onMove);
82
+ area.addEventListener("pointerleave", onLeave);
83
+ area.addEventListener("keydown", onKey);
84
+
85
+ return () => {
86
+ area.removeEventListener("pointermove", onMove);
87
+ area.removeEventListener("pointerleave", onLeave);
88
+ area.removeEventListener("keydown", onKey);
89
+ // quickTo tweens are paused at creation and would otherwise sit on the
90
+ // global timeline forever; fade tweens may still be in flight.
91
+ xTo.tween.kill();
92
+ yTo.tween.kill();
93
+ killTweens(el);
94
+ // Restore the inline styles we overwrote, clear only what gsap set
95
+ // (the old clearProps:"all" also wiped the consumer's own styles).
96
+ el.style.mixBlendMode = prevStyle.mixBlendMode;
97
+ el.style.position = prevStyle.position;
98
+ el.style.left = prevStyle.left;
99
+ el.style.top = prevStyle.top;
100
+ el.style.pointerEvents = prevStyle.pointerEvents;
101
+ gsap.set(el, { clearProps: "opacity,transform" });
102
+ };
103
+ });
104
+ }