@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
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Drag rail — the bounded, inertia-driven horizontal rail.
3
+ *
4
+ * A grab-and-throw rail with real physics, extracted from the drag-driven
5
+ * portfolio rail pattern: pointer drag, wheel and trackpad all feed one
6
+ * *intent* value, and the track follows it with a per-frame lerp. Why it is
7
+ * shaped this way (audit notes):
8
+ *
9
+ * - **Two values, not one.** `intent` is where the rail wants to be (drag
10
+ * delta, wheel delta, throw momentum); `pos` is what is rendered. The tick
11
+ * lerps intent → pos (`lerp * deltaRatio`), which is what gives the
12
+ * buttery catch-up after a hard throw. Writing the transform directly from
13
+ * pointermove (v1) felt like dragging a brick.
14
+ * - **tanh rubber-band.** Past either end, intent is squashed through
15
+ * `edge * tanh(overshoot / edge)` — the further you pull, the less each
16
+ * pixel buys you, but it never hard-stops. On release a restore force pulls
17
+ * the overscrolled intent back inside the bounds while the lerp chases it,
18
+ * producing the settle. This is the same soft-edge maths as the original
19
+ * rail's `a.t = a.a + tanh(l / t) * t`.
20
+ * - **Release throw.** Velocity is sampled as a smoothed per-frame pointer
21
+ * delta (0.7 smoothing) and multiplied by `throwScale` into intent — a flick
22
+ * coasts, a slow release barely moves. No InertiaPlugin dependency; the
23
+ * ticker *is* the inertia.
24
+ * - **Wheel coexistence.** While the rail can still move in the wheel's
25
+ * direction the event is consumed (`preventDefault` + `stopPropagation` —
26
+ * the stop matters: Lenis listens above us and would scroll the page in
27
+ * parallel). Once overscrolled a full `edge`, the wheel passes through to
28
+ * the page again, so the section never traps the reader.
29
+ * - **Velocity-reactive items.** `tilt` rotates children proportionally to the
30
+ * rendered per-frame velocity (the cards lean into motion and spring flat at
31
+ * rest); `onTick` exposes pos/velocity for WebGL or custom consumers.
32
+ * - **Fidelity.** `destroy()` removes every listener + the ticker, kills the
33
+ * tilt state and restores the inline cursor/user-select/touch-action the
34
+ * effect had overwritten.
35
+ *
36
+ * dragRail(".ak-rail-track", { tilt: 0.05 })
37
+ *
38
+ * CSS: viewport `overflow: hidden`; track `display: flex; width: max-content;
39
+ * gap: 1rem; cursor: grab; user-select: none; touch-action: pan-y`.
40
+ */
41
+ import { gsap, initGSAP } from "../core/gsap.js";
42
+ import { guard } from "../core/guard.js";
43
+ import { one, toArray } from "../core/util.js";
44
+ import type { CommonOptions, Destroy, TargetLike } from "../core/types.js";
45
+
46
+ export interface DragRailOptions extends CommonOptions {
47
+ /** Scroll viewport around the track — defaults to `track.parentElement`. */
48
+ viewport?: TargetLike;
49
+ /** Children that tilt with velocity. @default `":scope > *"` */
50
+ item?: string;
51
+ /** Follow speed toward the intent, per 60fps frame (0..1). @default 0.1 */
52
+ lerp?: number;
53
+ /** Rubber-band resistance distance past the ends, px. @default 140 */
54
+ edge?: number;
55
+ /** Momentum multiplier on release (px of coast per px/frame of flick). @default 14 */
56
+ throwScale?: number;
57
+ /** Wheel / trackpad drives the rail (consumed while it can still move). @default true */
58
+ wheel?: boolean;
59
+ /** Degrees of tilt per px/frame of velocity — `0` disables. @default 0 */
60
+ tilt?: number;
61
+ /** Tilt clamp, degrees. @default 8 */
62
+ tiltMax?: number;
63
+ /** `(pos, velocity) => {}` called every rendered frame while mounted. */
64
+ onTick?: (pos: number, velocity: number) => void;
65
+ }
66
+
67
+ export function dragRail(target: TargetLike, options: DragRailOptions = {}): Destroy {
68
+ initGSAP();
69
+
70
+ const track = one<HTMLElement>(target);
71
+ if (!track) return () => {};
72
+
73
+ const {
74
+ viewport,
75
+ item = ":scope > *",
76
+ lerp = 0.1,
77
+ edge = 140,
78
+ throwScale = 14,
79
+ wheel = true,
80
+ tilt = 0,
81
+ tiltMax = 8,
82
+ onTick,
83
+ } = options;
84
+
85
+ return guard(options, () => {
86
+ const vp = one<HTMLElement>(viewport) ?? track.parentElement ?? track;
87
+ const prevStyle = {
88
+ cursor: track.style.cursor,
89
+ userSelect: track.style.userSelect,
90
+ touchAction: track.style.touchAction,
91
+ };
92
+
93
+ /* ---------------- bounds ---------------- */
94
+
95
+ let min = 0; // most-negative resting x (content overflows left)
96
+ let max = 0; // start of the rail
97
+ let pos = 0; // rendered x
98
+ let intent = 0; // wanted x (drag/wheel/throw feed this)
99
+
100
+ const measure = () => {
101
+ const overflow = Math.max(0, track.scrollWidth - vp.clientWidth);
102
+ min = -overflow;
103
+ max = 0;
104
+ // Fold an out-of-range position back through the soft edge (no jump).
105
+ intent = soft(intent);
106
+ pos = soft(pos);
107
+ };
108
+
109
+ /** tanh soft-edge: unbounded input, saturating output around [min, max]. */
110
+ function soft(v: number): number {
111
+ if (v > max) return max + edge * Math.tanh((v - max) / edge);
112
+ if (v < min) return min + edge * Math.tanh((v - min) / edge);
113
+ return v;
114
+ }
115
+
116
+ /* ---------------- pointer drag ---------------- */
117
+
118
+ let dragging = false;
119
+ let lastX = 0;
120
+ let velocity = 0; // smoothed pointer delta (px/frame-ish)
121
+ let renderedVel = 0; // rendered per-frame velocity (drives tilt/onTick)
122
+
123
+ const onPointerDown = (e: PointerEvent) => {
124
+ if (e.pointerType === "mouse" && e.button !== 0) return;
125
+ dragging = true;
126
+ lastX = e.clientX;
127
+ velocity = 0;
128
+ try {
129
+ vp.setPointerCapture(e.pointerId);
130
+ } catch {
131
+ /* capture is best-effort — moves still land while the pointer is down */
132
+ }
133
+ track.style.cursor = "grabbing";
134
+ };
135
+
136
+ const onPointerMove = (e: PointerEvent) => {
137
+ if (!dragging) return;
138
+ const dx = e.clientX - lastX;
139
+ lastX = e.clientX;
140
+ velocity = velocity * 0.7 + dx * 0.3; // smooth out event-rate jitter
141
+ intent = soft(intent + dx);
142
+ };
143
+
144
+ const endDrag = (e?: PointerEvent) => {
145
+ if (!dragging) return;
146
+ dragging = false;
147
+ track.style.cursor = prevStyle.cursor || "grab";
148
+ if (e) {
149
+ try {
150
+ vp.releasePointerCapture(e.pointerId);
151
+ } catch {
152
+ /* already released */
153
+ }
154
+ }
155
+ // A flick coasts on its sampled momentum, then the soft edge reclaims it.
156
+ intent = soft(intent + velocity * throwScale);
157
+ };
158
+
159
+ const onPointerUp = (e: PointerEvent) => endDrag(e);
160
+ const onPointerCancel = (e: PointerEvent) => endDrag(e);
161
+ const onDragStart = (e: Event) => e.preventDefault(); // no native img drag
162
+
163
+ /* ---------------- wheel / trackpad ---------------- */
164
+
165
+ const onWheel = (e: WheelEvent) => {
166
+ if (!wheel) return;
167
+ const delta = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
168
+ const bound = delta > 0 ? max : min;
169
+ const outward = (intent - bound) * (delta > 0 ? 1 : -1);
170
+ if (outward > edge) return; // over-extended — hand the scroll back to the page
171
+ e.preventDefault();
172
+ e.stopPropagation(); // Lenis listens above us; it must not scroll in parallel
173
+ intent = soft(intent + delta);
174
+ };
175
+
176
+ /* ---------------- tick ---------------- */
177
+
178
+ const items = tilt ? toArray<HTMLElement>(item, track) : [];
179
+ if (items.length) gsap.set(items, { transformOrigin: "50% 50%" });
180
+
181
+ const tick = () => {
182
+ const ratio = gsap.ticker.deltaRatio(60); // frames since last tick, clamped
183
+ // Release restore: once input stops, overscrolled intent springs home.
184
+ if (!dragging) {
185
+ const bound = intent < min ? min : intent > max ? max : null;
186
+ if (bound !== null) intent += (bound - intent) * Math.min(1, 0.18 * ratio);
187
+ }
188
+ const prev = pos;
189
+ pos += (intent - pos) * Math.min(1, lerp * ratio);
190
+ renderedVel = pos - prev;
191
+ if (renderedVel === 0 && pos === intent) return; // idle: skip the write
192
+ gsap.set(track, { x: pos });
193
+ if (items.length) {
194
+ gsap.set(items, {
195
+ rotation: gsap.utils.clamp(-tiltMax, tiltMax, renderedVel * tilt),
196
+ });
197
+ }
198
+ onTick?.(pos, renderedVel);
199
+ };
200
+
201
+ /* ---------------- wiring ---------------- */
202
+
203
+ measure();
204
+ track.dataset.akRail = "true";
205
+ track.style.cursor = "grab";
206
+ track.style.userSelect = "none";
207
+ track.style.touchAction = "pan-y";
208
+
209
+ vp.addEventListener("pointerdown", onPointerDown);
210
+ vp.addEventListener("pointermove", onPointerMove);
211
+ vp.addEventListener("pointerup", onPointerUp);
212
+ vp.addEventListener("pointercancel", onPointerCancel);
213
+ vp.addEventListener("wheel", onWheel, { passive: false });
214
+ track.addEventListener("dragstart", onDragStart);
215
+ const onResize = () => measure();
216
+ window.addEventListener("resize", onResize);
217
+ gsap.ticker.add(tick);
218
+
219
+ return () => {
220
+ window.removeEventListener("resize", onResize);
221
+ vp.removeEventListener("pointerdown", onPointerDown);
222
+ vp.removeEventListener("pointermove", onPointerMove);
223
+ vp.removeEventListener("pointerup", onPointerUp);
224
+ vp.removeEventListener("pointercancel", onPointerCancel);
225
+ vp.removeEventListener("wheel", onWheel);
226
+ track.removeEventListener("dragstart", onDragStart);
227
+ gsap.ticker.remove(tick);
228
+ delete track.dataset.akRail;
229
+ track.style.cursor = prevStyle.cursor;
230
+ track.style.userSelect = prevStyle.userSelect;
231
+ track.style.touchAction = prevStyle.touchAction;
232
+ gsap.set(track, { clearProps: "transform" });
233
+ if (items.length) gsap.set(items, { clearProps: "rotation,transformOrigin" });
234
+ };
235
+ });
236
+ }
@@ -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
 
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Preloader — the full-screen intro from dzinrstudio.com.
2
+ * Preloader — the full-screen intro from kalakritico.in.
3
3
  *
4
4
  * What it does on the real site:
5
5
  * 1. A big number counts 0 → 100 in the bottom-left corner (steps of 5,