@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.
- package/README.md +293 -36
- package/dist/anim-kit.standalone.js +18 -8
- package/dist/anim-kit.standalone.js.map +1 -1
- package/dist/core/gsap.d.ts +3 -2
- package/dist/core/gsap.d.ts.map +1 -1
- package/dist/core/gsap.js +4 -3
- package/dist/core/gsap.js.map +1 -1
- package/dist/core/smooth-scroll.d.ts +1 -1
- package/dist/core/smooth-scroll.js +1 -1
- package/dist/effects/clip-wipe.d.ts +10 -2
- package/dist/effects/clip-wipe.d.ts.map +1 -1
- package/dist/effects/clip-wipe.js +30 -9
- package/dist/effects/clip-wipe.js.map +1 -1
- package/dist/effects/cursor-follower.d.ts.map +1 -1
- package/dist/effects/cursor-follower.js +22 -4
- package/dist/effects/cursor-follower.js.map +1 -1
- package/dist/effects/drag-rail.d.ts +23 -0
- package/dist/effects/drag-rail.d.ts.map +1 -0
- package/dist/effects/drag-rail.js +196 -0
- package/dist/effects/drag-rail.js.map +1 -0
- package/dist/effects/flip-words.d.ts +31 -0
- package/dist/effects/flip-words.d.ts.map +1 -0
- package/dist/effects/flip-words.js +104 -0
- package/dist/effects/flip-words.js.map +1 -0
- package/dist/effects/magnetic.d.ts +15 -0
- package/dist/effects/magnetic.d.ts.map +1 -0
- package/dist/effects/magnetic.js +59 -0
- package/dist/effects/magnetic.js.map +1 -0
- package/dist/effects/media-settle.d.ts +2 -0
- package/dist/effects/media-settle.d.ts.map +1 -1
- package/dist/effects/media-settle.js +5 -2
- package/dist/effects/media-settle.js.map +1 -1
- package/dist/effects/preloader.js +1 -1
- package/dist/effects/reel-text.d.ts +19 -0
- package/dist/effects/reel-text.d.ts.map +1 -0
- package/dist/effects/reel-text.js +163 -0
- package/dist/effects/reel-text.js.map +1 -0
- package/dist/index.d.ts +14 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -1
- package/dist/index.js.map +1 -1
- package/dist/three/index.d.ts +13 -0
- package/dist/three/index.d.ts.map +1 -0
- package/dist/three/index.js +12 -0
- package/dist/three/index.js.map +1 -0
- package/dist/three/webgl-media.d.ts +21 -0
- package/dist/three/webgl-media.d.ts.map +1 -0
- package/dist/three/webgl-media.js +268 -0
- package/dist/three/webgl-media.js.map +1 -0
- package/package.json +17 -1
- package/src/core/gsap.ts +4 -3
- package/src/core/smooth-scroll.ts +1 -1
- package/src/effects/clip-wipe.ts +50 -10
- package/src/effects/cursor-follower.ts +21 -4
- package/src/effects/drag-rail.ts +236 -0
- package/src/effects/flip-words.ts +148 -0
- package/src/effects/magnetic.ts +87 -0
- package/src/effects/media-settle.ts +7 -1
- package/src/effects/preloader.ts +1 -1
- package/src/effects/reel-text.ts +212 -0
- package/src/index.ts +18 -2
- package/src/three/index.ts +12 -0
- 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 =
|
|
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/effects/preloader.ts
CHANGED