@cosmictraveler002/anim-kit 1.0.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.
- package/README.md +275 -50
- package/dist/anim-kit.standalone.js +18 -8
- package/dist/anim-kit.standalone.js.map +1 -1
- package/dist/core/gsap.d.ts +2 -1
- package/dist/core/gsap.d.ts.map +1 -1
- package/dist/core/gsap.js +3 -2
- package/dist/core/gsap.js.map +1 -1
- package/dist/core/split.d.ts.map +1 -1
- package/dist/core/split.js +15 -2
- package/dist/core/split.js.map +1 -1
- package/dist/effects/clip-wipe.d.ts +31 -0
- package/dist/effects/clip-wipe.d.ts.map +1 -0
- package/dist/effects/clip-wipe.js +82 -0
- package/dist/effects/clip-wipe.js.map +1 -0
- package/dist/effects/counter.d.ts +9 -0
- package/dist/effects/counter.d.ts.map +1 -1
- package/dist/effects/counter.js +17 -7
- package/dist/effects/counter.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/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/line-reveal.d.ts +6 -2
- package/dist/effects/line-reveal.d.ts.map +1 -1
- package/dist/effects/line-reveal.js +8 -4
- package/dist/effects/line-reveal.js.map +1 -1
- 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 +30 -0
- package/dist/effects/media-settle.d.ts.map +1 -0
- package/dist/effects/media-settle.js +55 -0
- package/dist/effects/media-settle.js.map +1 -0
- package/dist/effects/roll-text.d.ts +13 -0
- package/dist/effects/roll-text.d.ts.map +1 -0
- package/dist/effects/roll-text.js +91 -0
- package/dist/effects/roll-text.js.map +1 -0
- package/dist/effects/scramble-text.d.ts +17 -0
- package/dist/effects/scramble-text.d.ts.map +1 -0
- package/dist/effects/scramble-text.js +93 -0
- package/dist/effects/scramble-text.js.map +1 -0
- package/dist/effects/unfold-reveal.d.ts +23 -0
- package/dist/effects/unfold-reveal.d.ts.map +1 -0
- package/dist/effects/unfold-reveal.js +53 -0
- package/dist/effects/unfold-reveal.js.map +1 -0
- package/dist/index.d.ts +25 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -2
- package/dist/index.js.map +1 -1
- package/dist/styles/anim-kit.css +22 -0
- package/package.json +6 -3
- package/src/core/gsap.ts +3 -2
- package/src/core/split.ts +16 -2
- package/src/effects/clip-wipe.ts +141 -0
- package/src/effects/counter.ts +26 -6
- package/src/effects/cursor-follower.ts +21 -4
- package/src/effects/flip-words.ts +148 -0
- package/src/effects/line-reveal.ts +12 -5
- package/src/effects/magnetic.ts +87 -0
- package/src/effects/media-settle.ts +106 -0
- package/src/effects/roll-text.ts +118 -0
- package/src/effects/scramble-text.ts +122 -0
- package/src/effects/unfold-reveal.ts +92 -0
- package/src/index.ts +33 -2
- package/src/styles/anim-kit.css +22 -0
|
@@ -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
|
+
}
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
* Line reveal — the masked, staggered text reveal used all over DZ!NR.
|
|
3
3
|
*
|
|
4
4
|
* Text is split into lines, each line gets an `overflow:hidden` mask, and the
|
|
5
|
-
* inner line slides up from `y:100%` to `y:0%`.
|
|
5
|
+
* inner line slides up from `y:100%` to `y:0%`. `split: "chars"` runs the
|
|
6
|
+
* same masked rise per character (`.ak-char-mask > .ak-char`) with a tighter
|
|
7
|
+
* default stagger — the per-letter headline reveal.
|
|
6
8
|
*
|
|
7
9
|
* Two modes:
|
|
8
10
|
* mode: "scroll" → plays when the element enters the viewport and reverses
|
|
@@ -20,7 +22,9 @@ import type { CommonOptions, Destroy, TargetLike } from "../core/types.js";
|
|
|
20
22
|
export interface LineRevealOptions extends CommonOptions {
|
|
21
23
|
/** "scroll" plays on enter/reverse on leave; "immediate" plays at once. */
|
|
22
24
|
mode?: "scroll" | "immediate";
|
|
23
|
-
/**
|
|
25
|
+
/** Split granularity — masked lines, or masked per-character stagger. @default "lines" */
|
|
26
|
+
split?: "lines" | "chars";
|
|
27
|
+
/** Stagger between lines/chars, seconds. @default 0.1 (0.03 for chars) */
|
|
24
28
|
stagger?: number;
|
|
25
29
|
/** Animation duration, seconds. @default 1 */
|
|
26
30
|
duration?: number;
|
|
@@ -39,13 +43,15 @@ export function lineReveal(target: TargetLike, options: LineRevealOptions = {}):
|
|
|
39
43
|
|
|
40
44
|
const {
|
|
41
45
|
mode = "scroll",
|
|
42
|
-
|
|
46
|
+
split: splitType = "lines",
|
|
47
|
+
stagger,
|
|
43
48
|
duration = 1,
|
|
44
49
|
ease = "power4.out",
|
|
45
50
|
delay = 0,
|
|
46
51
|
start = "top 90%",
|
|
47
52
|
end = "bottom 10%",
|
|
48
53
|
} = options;
|
|
54
|
+
const stag = stagger ?? (splitType === "chars" ? 0.03 : 0.1);
|
|
49
55
|
|
|
50
56
|
const els = toArray<HTMLElement>(target);
|
|
51
57
|
if (!els.length) return () => {};
|
|
@@ -58,9 +64,10 @@ export function lineReveal(target: TargetLike, options: LineRevealOptions = {}):
|
|
|
58
64
|
|
|
59
65
|
els.forEach((el) => {
|
|
60
66
|
const res = split(el, {
|
|
61
|
-
type:
|
|
67
|
+
type: splitType,
|
|
62
68
|
mask: true,
|
|
63
69
|
linesClass: "ak-line++",
|
|
70
|
+
charsClass: "ak-char++",
|
|
64
71
|
lineThreshold: 0.05,
|
|
65
72
|
});
|
|
66
73
|
splits.push(res);
|
|
@@ -75,7 +82,7 @@ export function lineReveal(target: TargetLike, options: LineRevealOptions = {}):
|
|
|
75
82
|
const vars: gsap.TweenVars = {
|
|
76
83
|
y: "0%",
|
|
77
84
|
duration,
|
|
78
|
-
stagger,
|
|
85
|
+
stagger: stag,
|
|
79
86
|
ease,
|
|
80
87
|
delay,
|
|
81
88
|
overwrite: "auto",
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Media settle — images that arrive slightly oversized and settle to size.
|
|
3
|
+
*
|
|
4
|
+
* The classic entrance for grids and heroes: media enters at `scale > 1`
|
|
5
|
+
* and eases down to 1 as the section arrives (or across the scroll range in
|
|
6
|
+
* `scrub` mode), so content lands instead of popping in.
|
|
7
|
+
*
|
|
8
|
+
* mediaSettle("[data-settle]", { from: 1.3 }); // on enter
|
|
9
|
+
* mediaSettle("[data-settle]", { scrub: 0.5 }); // scroll-bound
|
|
10
|
+
* mediaSettle("[data-settle]", { replay: true }); // reverse on leave-back, replay on re-enter
|
|
11
|
+
*
|
|
12
|
+
* destroy() kills the tween and restores the original scale.
|
|
13
|
+
*/
|
|
14
|
+
import { gsap, ScrollTrigger, initGSAP } 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 MediaSettleOptions extends CommonOptions {
|
|
20
|
+
/** Starting scale — settles down to 1. @default 1.15 */
|
|
21
|
+
from?: number;
|
|
22
|
+
/** Animation duration, seconds (enter mode). @default 1.5 */
|
|
23
|
+
duration?: number;
|
|
24
|
+
/** GSAP ease. @default "power2.out" */
|
|
25
|
+
ease?: string;
|
|
26
|
+
/** transformOrigin. @default "center" */
|
|
27
|
+
origin?: string;
|
|
28
|
+
/** Stagger between targets, seconds. @default 0.06 */
|
|
29
|
+
stagger?: number;
|
|
30
|
+
/** Delay before playing, seconds. @default 0 */
|
|
31
|
+
delay?: number;
|
|
32
|
+
/** "scroll" plays on enter, "immediate" plays at once. @default "scroll" */
|
|
33
|
+
mode?: "scroll" | "immediate";
|
|
34
|
+
/** ScrollTrigger start position. @default "top 75%" */
|
|
35
|
+
start?: string;
|
|
36
|
+
/** ScrollTrigger end position (scrub mode). @default "bottom top" */
|
|
37
|
+
end?: string;
|
|
38
|
+
/** Re-settle when leaving / re-entering the viewport (enter mode). @default false */
|
|
39
|
+
replay?: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Bind the settle to scroll progress instead of playing it on enter —
|
|
42
|
+
* number = scrub smoothing seconds, `true` = immediate. unset = one-shot.
|
|
43
|
+
*/
|
|
44
|
+
scrub?: number | boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function mediaSettle(target: TargetLike, options: MediaSettleOptions = {}): Destroy {
|
|
48
|
+
initGSAP();
|
|
49
|
+
|
|
50
|
+
const {
|
|
51
|
+
from = 1.15,
|
|
52
|
+
duration = 1.5,
|
|
53
|
+
ease = "power2.out",
|
|
54
|
+
origin = "center",
|
|
55
|
+
stagger = 0.06,
|
|
56
|
+
delay = 0,
|
|
57
|
+
mode = "scroll",
|
|
58
|
+
start = "top 75%",
|
|
59
|
+
end = "bottom top",
|
|
60
|
+
replay = false,
|
|
61
|
+
scrub,
|
|
62
|
+
} = options;
|
|
63
|
+
|
|
64
|
+
const els = toArray<HTMLElement>(target);
|
|
65
|
+
if (!els.length) return () => {};
|
|
66
|
+
|
|
67
|
+
return guard(options, run);
|
|
68
|
+
|
|
69
|
+
function run(): Destroy {
|
|
70
|
+
gsap.set(els, { scale: from, transformOrigin: origin });
|
|
71
|
+
|
|
72
|
+
const vars: gsap.TweenVars = { scale: 1, stagger, delay, overwrite: "auto" };
|
|
73
|
+
|
|
74
|
+
if (scrub !== undefined) {
|
|
75
|
+
vars.ease = "none";
|
|
76
|
+
vars.scrollTrigger = {
|
|
77
|
+
trigger: els[0],
|
|
78
|
+
start,
|
|
79
|
+
end,
|
|
80
|
+
scrub: scrub === true ? true : scrub,
|
|
81
|
+
};
|
|
82
|
+
} else {
|
|
83
|
+
vars.duration = duration;
|
|
84
|
+
vars.ease = ease;
|
|
85
|
+
if (mode === "scroll") {
|
|
86
|
+
vars.scrollTrigger = replay
|
|
87
|
+
? { trigger: els[0], start, toggleActions: "play reverse play reverse" }
|
|
88
|
+
: { trigger: els[0], start, once: true };
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const tween = gsap.fromTo(
|
|
93
|
+
els,
|
|
94
|
+
{ scale: from, transformOrigin: origin },
|
|
95
|
+
{ ...vars, immediateRender: true },
|
|
96
|
+
);
|
|
97
|
+
ScrollTrigger.refresh();
|
|
98
|
+
|
|
99
|
+
return () => {
|
|
100
|
+
tween.scrollTrigger?.kill();
|
|
101
|
+
tween.kill();
|
|
102
|
+
gsap.set(els, { clearProps: "transform,transformOrigin" });
|
|
103
|
+
ScrollTrigger.refresh();
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Roll text — the rolling word rotator.
|
|
3
|
+
*
|
|
4
|
+
* The target holds two or more rows; they are stacked into a hidden overflow
|
|
5
|
+
* box one row tall, and the box rolls to the next row on an interval — the
|
|
6
|
+
* first row is cloned at the end so the wrap is seamless (same trick as
|
|
7
|
+
* `marquee()`).
|
|
8
|
+
*
|
|
9
|
+
* <span class="ak-roll" data-roll>
|
|
10
|
+
* <span>Design</span><span>Code</span><span>Motion</span>
|
|
11
|
+
* </span>
|
|
12
|
+
*
|
|
13
|
+
* destroy() unwraps the rows, removes the clone and restores every inline
|
|
14
|
+
* style it touched — markup comes back byte-identical.
|
|
15
|
+
*/
|
|
16
|
+
import { gsap, initGSAP, killTweens } from "../core/gsap.js";
|
|
17
|
+
import { guard } from "../core/guard.js";
|
|
18
|
+
import { toArray } from "../core/util.js";
|
|
19
|
+
import type { CommonOptions, Destroy, TargetLike } from "../core/types.js";
|
|
20
|
+
|
|
21
|
+
export interface RollTextOptions extends CommonOptions {
|
|
22
|
+
/** Seconds each row is shown (including the roll). @default 2.2 */
|
|
23
|
+
interval?: number;
|
|
24
|
+
/** Roll duration, seconds. @default 0.6 */
|
|
25
|
+
duration?: number;
|
|
26
|
+
/** GSAP ease for the roll. @default "power4.inOut" */
|
|
27
|
+
ease?: string;
|
|
28
|
+
/** "up" rolls rows upward, "down" walks them in reverse. @default "up" */
|
|
29
|
+
direction?: "up" | "down";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function rollText(target: TargetLike, options: RollTextOptions = {}): Destroy {
|
|
33
|
+
initGSAP();
|
|
34
|
+
|
|
35
|
+
const { interval = 2.2, duration = 0.6, ease = "power4.inOut", direction = "up" } = options;
|
|
36
|
+
|
|
37
|
+
const els = toArray<HTMLElement>(target);
|
|
38
|
+
if (!els.length) return () => {};
|
|
39
|
+
|
|
40
|
+
return guard(options, () => {
|
|
41
|
+
const setups: Array<{
|
|
42
|
+
el: HTMLElement;
|
|
43
|
+
inner: HTMLElement;
|
|
44
|
+
rows: HTMLElement[];
|
|
45
|
+
snapshots: Array<{ el: HTMLElement; cssText: string }>;
|
|
46
|
+
timeline: gsap.core.Timeline | null;
|
|
47
|
+
}> = [];
|
|
48
|
+
|
|
49
|
+
els.forEach((el) => {
|
|
50
|
+
const rows = Array.from(el.children).filter(
|
|
51
|
+
(n): n is HTMLElement => n instanceof HTMLElement,
|
|
52
|
+
);
|
|
53
|
+
if (rows.length < 2) return;
|
|
54
|
+
|
|
55
|
+
// Snapshot inline styles BEFORE touching them (restored on destroy).
|
|
56
|
+
const snapshots: Array<{ el: HTMLElement; cssText: string }> = [
|
|
57
|
+
{ el, cssText: el.style.cssText },
|
|
58
|
+
...rows.map((r) => ({ el: r, cssText: r.style.cssText })),
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
// Rows must stack as blocks for the box to measure one row.
|
|
62
|
+
rows.forEach((r) => (r.style.display = "block"));
|
|
63
|
+
if (window.getComputedStyle(el).display === "inline") el.style.display = "inline-block";
|
|
64
|
+
|
|
65
|
+
const inner = document.createElement("div");
|
|
66
|
+
inner.className = "ak-roll__inner";
|
|
67
|
+
el.insertBefore(inner, rows[0]);
|
|
68
|
+
rows.forEach((r) => inner.appendChild(r));
|
|
69
|
+
const clone = rows[0].cloneNode(true) as HTMLElement;
|
|
70
|
+
clone.setAttribute("data-ak-roll-clone", "");
|
|
71
|
+
inner.appendChild(clone);
|
|
72
|
+
|
|
73
|
+
const rowH = rows[0].getBoundingClientRect().height;
|
|
74
|
+
el.style.overflow = "hidden";
|
|
75
|
+
el.style.height = `${rowH}px`;
|
|
76
|
+
el.dataset.akRoll = String(rows.length);
|
|
77
|
+
|
|
78
|
+
let timeline: gsap.core.Timeline | null = null;
|
|
79
|
+
if (rowH > 0) {
|
|
80
|
+
const n = rows.length;
|
|
81
|
+
const hold = Math.max(0.01, interval - duration);
|
|
82
|
+
const tl = gsap.timeline({ repeat: -1, defaults: { ease } });
|
|
83
|
+
if (direction === "up") {
|
|
84
|
+
gsap.set(inner, { y: 0 });
|
|
85
|
+
for (let i = 1; i <= n; i++) {
|
|
86
|
+
tl.to(inner, { y: -i * rowH, duration });
|
|
87
|
+
tl.to({}, { duration: hold });
|
|
88
|
+
}
|
|
89
|
+
tl.set(inner, { y: 0 });
|
|
90
|
+
} else {
|
|
91
|
+
gsap.set(inner, { y: -n * rowH });
|
|
92
|
+
for (let i = n - 1; i >= 0; i--) {
|
|
93
|
+
tl.to(inner, { y: -i * rowH, duration });
|
|
94
|
+
tl.to({}, { duration: hold });
|
|
95
|
+
}
|
|
96
|
+
tl.set(inner, { y: -n * rowH });
|
|
97
|
+
}
|
|
98
|
+
timeline = tl;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
setups.push({ el, inner, rows, snapshots, timeline });
|
|
102
|
+
// Clone lives inside inner — remember it for teardown via the attribute.
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
return () => {
|
|
106
|
+
setups.forEach(({ el, inner, rows, snapshots, timeline }) => {
|
|
107
|
+
timeline?.kill();
|
|
108
|
+
// Unwrap: rows home first, then drop the (now empty) inner box.
|
|
109
|
+
rows.forEach((r) => el.appendChild(r));
|
|
110
|
+
inner.remove();
|
|
111
|
+
killTweens(inner);
|
|
112
|
+
snapshots.forEach(({ el: node, cssText }) => (node.style.cssText = cssText));
|
|
113
|
+
delete el.dataset.akRoll;
|
|
114
|
+
});
|
|
115
|
+
setups.length = 0;
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scramble text — the decode / cipher reveal.
|
|
3
|
+
*
|
|
4
|
+
* Each character churns through the charset and settles on its final glyph,
|
|
5
|
+
* left to right; letters scramble, everything else (digits, punctuation,
|
|
6
|
+
* spaces) stays put, and case is preserved. Three triggers:
|
|
7
|
+
*
|
|
8
|
+
* mode: "scroll" → plays once when the element enters the viewport.
|
|
9
|
+
* mode: "immediate" → plays at once (above the fold).
|
|
10
|
+
* mode: "hover" → re-scrambles on every pointer enter.
|
|
11
|
+
*
|
|
12
|
+
* The element's textContent is restored exactly on destroy.
|
|
13
|
+
*/
|
|
14
|
+
import { gsap, ScrollTrigger, initGSAP } 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 ScrambleTextOptions extends CommonOptions {
|
|
20
|
+
/** "scroll" plays on enter, "immediate" now, "hover" on pointerenter. @default "scroll" */
|
|
21
|
+
mode?: "scroll" | "immediate" | "hover";
|
|
22
|
+
/** Glyphs each letter churns through. @default "abcdefghijklmnopqrstuvwxyz" */
|
|
23
|
+
charset?: string;
|
|
24
|
+
/** Seconds each character spends scrambling. @default 0.18 */
|
|
25
|
+
durationPerChar?: number;
|
|
26
|
+
/** Delay between character starts, seconds. @default 0.04 */
|
|
27
|
+
stagger?: number;
|
|
28
|
+
/** Delay before the timeline starts, seconds. @default 0 */
|
|
29
|
+
delay?: number;
|
|
30
|
+
/** ScrollTrigger start position (mode: "scroll"). @default "top 80%" */
|
|
31
|
+
start?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function scrambleText(target: TargetLike, options: ScrambleTextOptions = {}): Destroy {
|
|
35
|
+
initGSAP();
|
|
36
|
+
|
|
37
|
+
const {
|
|
38
|
+
mode = "scroll",
|
|
39
|
+
charset = "abcdefghijklmnopqrstuvwxyz",
|
|
40
|
+
durationPerChar = 0.18,
|
|
41
|
+
stagger = 0.04,
|
|
42
|
+
delay = 0,
|
|
43
|
+
start = "top 80%",
|
|
44
|
+
} = options;
|
|
45
|
+
|
|
46
|
+
const els = toArray<HTMLElement>(target);
|
|
47
|
+
if (!els.length) return () => {};
|
|
48
|
+
|
|
49
|
+
return guard(options, () => {
|
|
50
|
+
const originals = new Map<HTMLElement, string>();
|
|
51
|
+
const timelines: gsap.core.Timeline[] = [];
|
|
52
|
+
const cleanups: Array<() => void> = [];
|
|
53
|
+
|
|
54
|
+
/** Fill a timeline with one eased scramble tween per letter. */
|
|
55
|
+
const fill = (el: HTMLElement, original: string, tl: gsap.core.Timeline) => {
|
|
56
|
+
Array.from(original).forEach((ch, i) => {
|
|
57
|
+
if (!/[a-z]/i.test(ch)) return; // digits / punctuation / spaces stay put
|
|
58
|
+
const upper = ch === ch.toUpperCase();
|
|
59
|
+
const state = { p: 0 };
|
|
60
|
+
tl.to(
|
|
61
|
+
state,
|
|
62
|
+
{
|
|
63
|
+
p: 1,
|
|
64
|
+
duration: durationPerChar,
|
|
65
|
+
ease: "power3.out",
|
|
66
|
+
onUpdate: () => {
|
|
67
|
+
if (state.p >= 1) return;
|
|
68
|
+
const glyph = charset[Math.floor(Math.random() * charset.length)];
|
|
69
|
+
el.textContent =
|
|
70
|
+
original.slice(0, i) + (upper ? glyph.toUpperCase() : glyph) + original.slice(i + 1);
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
i * stagger,
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
tl.eventCallback("onComplete", () => (el.textContent = original));
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
els.forEach((el) => {
|
|
80
|
+
const original = el.textContent ?? "";
|
|
81
|
+
if (!original) return;
|
|
82
|
+
originals.set(el, original);
|
|
83
|
+
el.dataset.akScramble = "true";
|
|
84
|
+
|
|
85
|
+
if (mode === "scroll") {
|
|
86
|
+
const tl = gsap.timeline({
|
|
87
|
+
delay,
|
|
88
|
+
scrollTrigger: { trigger: el, start, once: true },
|
|
89
|
+
});
|
|
90
|
+
fill(el, original, tl);
|
|
91
|
+
timelines.push(tl);
|
|
92
|
+
} else if (mode === "immediate") {
|
|
93
|
+
const tl = gsap.timeline({ delay });
|
|
94
|
+
fill(el, original, tl);
|
|
95
|
+
timelines.push(tl);
|
|
96
|
+
} else {
|
|
97
|
+
const tl = gsap.timeline({ delay, paused: true });
|
|
98
|
+
fill(el, original, tl);
|
|
99
|
+
timelines.push(tl);
|
|
100
|
+
const onEnter = () => tl.restart();
|
|
101
|
+
el.addEventListener("pointerenter", onEnter);
|
|
102
|
+
cleanups.push(() => el.removeEventListener("pointerenter", onEnter));
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
ScrollTrigger.refresh();
|
|
107
|
+
|
|
108
|
+
return () => {
|
|
109
|
+
cleanups.forEach((fn) => fn());
|
|
110
|
+
timelines.forEach((tl) => {
|
|
111
|
+
tl.scrollTrigger?.kill();
|
|
112
|
+
tl.kill();
|
|
113
|
+
});
|
|
114
|
+
originals.forEach((text, el) => {
|
|
115
|
+
el.textContent = text;
|
|
116
|
+
delete el.dataset.akScramble;
|
|
117
|
+
});
|
|
118
|
+
originals.clear();
|
|
119
|
+
ScrollTrigger.refresh();
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
}
|