@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.
- package/LICENSE +21 -0
- package/README.md +1033 -0
- package/dist/anim-kit.standalone.js +117 -0
- package/dist/anim-kit.standalone.js.map +1 -0
- package/dist/core/gsap.d.ts +44 -0
- package/dist/core/gsap.d.ts.map +1 -0
- package/dist/core/gsap.js +71 -0
- package/dist/core/gsap.js.map +1 -0
- package/dist/core/guard.d.ts +3 -0
- package/dist/core/guard.d.ts.map +1 -0
- package/dist/core/guard.js +18 -0
- package/dist/core/guard.js.map +1 -0
- package/dist/core/smooth-scroll.d.ts +45 -0
- package/dist/core/smooth-scroll.d.ts.map +1 -0
- package/dist/core/smooth-scroll.js +98 -0
- package/dist/core/smooth-scroll.js.map +1 -0
- package/dist/core/split.d.ts +24 -0
- package/dist/core/split.d.ts.map +1 -0
- package/dist/core/split.js +117 -0
- package/dist/core/split.js.map +1 -0
- package/dist/core/types.d.ts +21 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +9 -0
- package/dist/core/types.js.map +1 -0
- package/dist/core/util.d.ts +19 -0
- package/dist/core/util.d.ts.map +1 -0
- package/dist/core/util.js +73 -0
- package/dist/core/util.js.map +1 -0
- package/dist/effects/counter.d.ts +52 -0
- package/dist/effects/counter.d.ts.map +1 -0
- package/dist/effects/counter.js +106 -0
- package/dist/effects/counter.js.map +1 -0
- package/dist/effects/cursor-follower.d.ts +19 -0
- package/dist/effects/cursor-follower.d.ts.map +1 -0
- package/dist/effects/cursor-follower.js +76 -0
- package/dist/effects/cursor-follower.js.map +1 -0
- package/dist/effects/drag-strip.d.ts +17 -0
- package/dist/effects/drag-strip.d.ts.map +1 -0
- package/dist/effects/drag-strip.js +186 -0
- package/dist/effects/drag-strip.js.map +1 -0
- package/dist/effects/hero-shrink.d.ts +15 -0
- package/dist/effects/hero-shrink.d.ts.map +1 -0
- package/dist/effects/hero-shrink.js +40 -0
- package/dist/effects/hero-shrink.js.map +1 -0
- package/dist/effects/horizontal-scroll.d.ts +17 -0
- package/dist/effects/horizontal-scroll.d.ts.map +1 -0
- package/dist/effects/horizontal-scroll.js +87 -0
- package/dist/effects/horizontal-scroll.js.map +1 -0
- package/dist/effects/line-reveal.d.ts +34 -0
- package/dist/effects/line-reveal.d.ts.map +1 -0
- package/dist/effects/line-reveal.js +71 -0
- package/dist/effects/line-reveal.js.map +1 -0
- package/dist/effects/liquid-button.d.ts +19 -0
- package/dist/effects/liquid-button.d.ts.map +1 -0
- package/dist/effects/liquid-button.js +52 -0
- package/dist/effects/liquid-button.js.map +1 -0
- package/dist/effects/logo-reveal.d.ts +15 -0
- package/dist/effects/logo-reveal.d.ts.map +1 -0
- package/dist/effects/logo-reveal.js +49 -0
- package/dist/effects/logo-reveal.js.map +1 -0
- package/dist/effects/marquee.d.ts +13 -0
- package/dist/effects/marquee.d.ts.map +1 -0
- package/dist/effects/marquee.js +143 -0
- package/dist/effects/marquee.js.map +1 -0
- package/dist/effects/mask-reveal.d.ts +36 -0
- package/dist/effects/mask-reveal.d.ts.map +1 -0
- package/dist/effects/mask-reveal.js +102 -0
- package/dist/effects/mask-reveal.js.map +1 -0
- package/dist/effects/menu-overlay.d.ts +32 -0
- package/dist/effects/menu-overlay.d.ts.map +1 -0
- package/dist/effects/menu-overlay.js +150 -0
- package/dist/effects/menu-overlay.js.map +1 -0
- package/dist/effects/nav-hide.d.ts +13 -0
- package/dist/effects/nav-hide.d.ts.map +1 -0
- package/dist/effects/nav-hide.js +68 -0
- package/dist/effects/nav-hide.js.map +1 -0
- package/dist/effects/parallax.d.ts +11 -0
- package/dist/effects/parallax.d.ts.map +1 -0
- package/dist/effects/parallax.js +48 -0
- package/dist/effects/parallax.js.map +1 -0
- package/dist/effects/preloader.d.ts +33 -0
- package/dist/effects/preloader.d.ts.map +1 -0
- package/dist/effects/preloader.js +128 -0
- package/dist/effects/preloader.js.map +1 -0
- package/dist/effects/scatter-text.d.ts +20 -0
- package/dist/effects/scatter-text.d.ts.map +1 -0
- package/dist/effects/scatter-text.js +87 -0
- package/dist/effects/scatter-text.js.map +1 -0
- package/dist/effects/stacked-cards.d.ts +24 -0
- package/dist/effects/stacked-cards.d.ts.map +1 -0
- package/dist/effects/stacked-cards.js +106 -0
- package/dist/effects/stacked-cards.js.map +1 -0
- package/dist/effects/theme-reveal.d.ts +35 -0
- package/dist/effects/theme-reveal.d.ts.map +1 -0
- package/dist/effects/theme-reveal.js +93 -0
- package/dist/effects/theme-reveal.js.map +1 -0
- package/dist/index.d.ts +77 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +61 -0
- package/dist/index.js.map +1 -0
- package/dist/styles/anim-kit.css +361 -0
- package/package.json +80 -0
- package/src/core/gsap.ts +79 -0
- package/src/core/guard.ts +18 -0
- package/src/core/smooth-scroll.ts +143 -0
- package/src/core/split.ts +145 -0
- package/src/core/types.ts +30 -0
- package/src/core/util.ts +79 -0
- package/src/effects/counter.ts +192 -0
- package/src/effects/cursor-follower.ts +104 -0
- package/src/effects/drag-strip.ts +228 -0
- package/src/effects/hero-shrink.ts +69 -0
- package/src/effects/horizontal-scroll.ts +123 -0
- package/src/effects/line-reveal.ts +109 -0
- package/src/effects/liquid-button.ts +75 -0
- package/src/effects/logo-reveal.ts +76 -0
- package/src/effects/marquee.ts +157 -0
- package/src/effects/mask-reveal.ts +148 -0
- package/src/effects/menu-overlay.ts +218 -0
- package/src/effects/nav-hide.ts +90 -0
- package/src/effects/parallax.ts +68 -0
- package/src/effects/preloader.ts +187 -0
- package/src/effects/scatter-text.ts +129 -0
- package/src/effects/stacked-cards.ts +154 -0
- package/src/effects/theme-reveal.ts +144 -0
- package/src/index.ts +98 -0
- package/src/styles/anim-kit.css +361 -0
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marquee — the infinite, dual-row client-logo band.
|
|
3
|
+
*
|
|
4
|
+
* Two rows scroll in opposite directions at a constant px/s using a single
|
|
5
|
+
* rAF loop. Each row's content is duplicated once; when the offset passes one
|
|
6
|
+
* copy's width it wraps by exactly that width, so the seam is invisible.
|
|
7
|
+
*
|
|
8
|
+
* <div class="ak-marquee">
|
|
9
|
+
* <div class="ak-marquee__viewport">
|
|
10
|
+
* <div class="ak-marquee__track" data-dir="left">…items…</div>
|
|
11
|
+
* </div>
|
|
12
|
+
* </div>
|
|
13
|
+
*
|
|
14
|
+
* Call `marquee()` once per track, or pass a container with `[data-marquee]`.
|
|
15
|
+
*/
|
|
16
|
+
import { gsap, initGSAP } 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 MarqueeOptions extends CommonOptions {
|
|
22
|
+
/** Pixels per second. @default 40 (the studio's rate) */
|
|
23
|
+
speed?: number;
|
|
24
|
+
/** Force a direction for every track. Otherwise read `data-dir`. */
|
|
25
|
+
direction?: "left" | "right";
|
|
26
|
+
/** Duplicate the content automatically if it isn't already doubled. @default true */
|
|
27
|
+
clone?: boolean;
|
|
28
|
+
/** Pause the loop on pointer enter. @default false */
|
|
29
|
+
pauseOnHover?: boolean;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function marquee(target: TargetLike, options: MarqueeOptions = {}): Destroy {
|
|
33
|
+
initGSAP();
|
|
34
|
+
|
|
35
|
+
const roots = toArray<HTMLElement>(target);
|
|
36
|
+
if (!roots.length) return () => {};
|
|
37
|
+
|
|
38
|
+
const { speed = 40, clone = true, pauseOnHover = false } = options;
|
|
39
|
+
|
|
40
|
+
return guard(options, () => {
|
|
41
|
+
const tracks: HTMLElement[] = [];
|
|
42
|
+
roots.forEach((root) => {
|
|
43
|
+
if (root.matches("[data-marquee-track]")) tracks.push(root);
|
|
44
|
+
else root.querySelectorAll<HTMLElement>("[data-marquee-track]").forEach((t) => tracks.push(t));
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const states = tracks.map((track) => {
|
|
48
|
+
const originalCount = track.children.length;
|
|
49
|
+
if (clone) duplicateOnce(track);
|
|
50
|
+
const dir =
|
|
51
|
+
options.direction ?? (track.dataset.dir === "right" ? "right" : "left");
|
|
52
|
+
return {
|
|
53
|
+
track,
|
|
54
|
+
dir,
|
|
55
|
+
offset: 0,
|
|
56
|
+
width: 0,
|
|
57
|
+
hovering: false,
|
|
58
|
+
originalCount,
|
|
59
|
+
// Only OUR duplication gets removed on destroy — a consumer who
|
|
60
|
+
// pre-tiled the track (data-marquee-cloned) keeps their markup.
|
|
61
|
+
cloned: track.children.length > originalCount,
|
|
62
|
+
};
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const measure = () => {
|
|
66
|
+
states.forEach((s) => {
|
|
67
|
+
s.width = measureCopy(s.track);
|
|
68
|
+
if (s.dir === "right" && s.offset === 0) s.offset = -s.width;
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
measure();
|
|
73
|
+
|
|
74
|
+
let last = performance.now();
|
|
75
|
+
let rafId = 0;
|
|
76
|
+
let running = true;
|
|
77
|
+
|
|
78
|
+
const loop = (now: number) => {
|
|
79
|
+
if (!running) return;
|
|
80
|
+
const dt = Math.min(64, now - last) / 1000;
|
|
81
|
+
last = now;
|
|
82
|
+
|
|
83
|
+
for (const s of states) {
|
|
84
|
+
if (!s.width) continue;
|
|
85
|
+
if (pauseOnHover && s.hovering) continue;
|
|
86
|
+
const delta = speed * dt * (s.dir === "right" ? 1 : -1);
|
|
87
|
+
s.offset += delta;
|
|
88
|
+
// Wrap by exactly one copy width — seamless.
|
|
89
|
+
if (s.offset <= -s.width) s.offset += s.width;
|
|
90
|
+
if (s.offset >= 0) s.offset -= s.width;
|
|
91
|
+
gsap.set(s.track, { x: s.offset });
|
|
92
|
+
}
|
|
93
|
+
rafId = requestAnimationFrame(loop);
|
|
94
|
+
};
|
|
95
|
+
rafId = requestAnimationFrame(loop);
|
|
96
|
+
|
|
97
|
+
const onResize = () => {
|
|
98
|
+
measure();
|
|
99
|
+
};
|
|
100
|
+
window.addEventListener("resize", onResize);
|
|
101
|
+
|
|
102
|
+
const cleanups: Array<() => void> = [];
|
|
103
|
+
if (pauseOnHover) {
|
|
104
|
+
states.forEach((s) => {
|
|
105
|
+
const enter = () => (s.hovering = true);
|
|
106
|
+
const leave = () => (s.hovering = false);
|
|
107
|
+
s.track.addEventListener("pointerenter", enter);
|
|
108
|
+
s.track.addEventListener("pointerleave", leave);
|
|
109
|
+
cleanups.push(() => {
|
|
110
|
+
s.track.removeEventListener("pointerenter", enter);
|
|
111
|
+
s.track.removeEventListener("pointerleave", leave);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return () => {
|
|
117
|
+
running = false;
|
|
118
|
+
cancelAnimationFrame(rafId);
|
|
119
|
+
window.removeEventListener("resize", onResize);
|
|
120
|
+
cleanups.forEach((fn) => fn());
|
|
121
|
+
states.forEach((s) => {
|
|
122
|
+
gsap.set(s.track, { clearProps: "transform" });
|
|
123
|
+
// Contract: destroy restores the original markup — drop our copy.
|
|
124
|
+
if (s.cloned) {
|
|
125
|
+
while (s.track.children.length > s.originalCount) {
|
|
126
|
+
s.track.removeChild(s.track.lastElementChild!);
|
|
127
|
+
}
|
|
128
|
+
delete s.track.dataset.marqueeCloned;
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
};
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function duplicateOnce(track: HTMLElement): void {
|
|
136
|
+
if (track.dataset.marqueeCloned === "true") return;
|
|
137
|
+
const copy = track.innerHTML;
|
|
138
|
+
track.insertAdjacentHTML("beforeend", copy);
|
|
139
|
+
track.dataset.marqueeCloned = "true";
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Width of a single copy of the track content, in px. */
|
|
143
|
+
function measureCopy(track: HTMLElement): number {
|
|
144
|
+
if (track.dataset.marqueeCloned !== "true") return track.scrollWidth;
|
|
145
|
+
// Content was doubled, so one copy is half — but gaps can make it uneven,
|
|
146
|
+
// so measure the first half of the children instead.
|
|
147
|
+
const children = Array.from(track.children) as HTMLElement[];
|
|
148
|
+
if (!children.length) return track.scrollWidth / 2;
|
|
149
|
+
const half = Math.floor(children.length / 2);
|
|
150
|
+
let width = 0;
|
|
151
|
+
for (let i = 0; i < half; i++) {
|
|
152
|
+
const r = children[i].getBoundingClientRect();
|
|
153
|
+
const next = children[i + 1]?.getBoundingClientRect();
|
|
154
|
+
width += next ? next.left - r.left : r.width;
|
|
155
|
+
}
|
|
156
|
+
return width > 0 ? width : track.scrollWidth / 2;
|
|
157
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mask reveal — the inline `overflow:hidden` + `y:100%` heading reveal.
|
|
3
|
+
*
|
|
4
|
+
* This is the studio's lighter alternative to `lineReveal`: you write
|
|
5
|
+
* `overflow-hidden` wrappers yourself and animate the inner spans.
|
|
6
|
+
*
|
|
7
|
+
* <h1>
|
|
8
|
+
* <span class="ak-mask"><span class="ak-mask__inner">Se</span></span>
|
|
9
|
+
* </h1>
|
|
10
|
+
*
|
|
11
|
+
* maskReveal("h1 .ak-mask__inner", { start: "top 90%" })
|
|
12
|
+
*
|
|
13
|
+
* On the site it runs at `duration:.5, ease:[.165,.84,.44,1]` (their `ak-reveal`)
|
|
14
|
+
* and the second line settles at `y:"-8%"` rather than 0 — configurable here.
|
|
15
|
+
*/
|
|
16
|
+
import { gsap, ScrollTrigger, initGSAP, EASES } 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 MaskRevealOptions extends CommonOptions {
|
|
22
|
+
/** Where the inner span starts. @default "100%" (fully below the mask) */
|
|
23
|
+
from?: string;
|
|
24
|
+
/** Where it settles. @default "0%" */
|
|
25
|
+
to?: string;
|
|
26
|
+
/** Duration, seconds. @default 0.5 */
|
|
27
|
+
duration?: number;
|
|
28
|
+
/** Ease name. @default EASES.reveal (".165,.84,.44,1") */
|
|
29
|
+
ease?: string;
|
|
30
|
+
/** Stagger across matched elements, seconds. @default 0.1 */
|
|
31
|
+
stagger?: number;
|
|
32
|
+
/** Delay before playing, seconds. @default 0 */
|
|
33
|
+
delay?: number;
|
|
34
|
+
/** ScrollTrigger start. @default "top 90%" */
|
|
35
|
+
start?: string;
|
|
36
|
+
/** ScrollTrigger end. @default "bottom 10%" */
|
|
37
|
+
end?: string;
|
|
38
|
+
/** "scroll" reverses on leave; "immediate" plays at once. @default "scroll" */
|
|
39
|
+
mode?: "scroll" | "immediate";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function maskReveal(target: TargetLike, options: MaskRevealOptions = {}): Destroy {
|
|
43
|
+
initGSAP();
|
|
44
|
+
|
|
45
|
+
const els = toArray<HTMLElement>(target);
|
|
46
|
+
if (!els.length) return () => {};
|
|
47
|
+
|
|
48
|
+
const {
|
|
49
|
+
from = "100%",
|
|
50
|
+
to = "0%",
|
|
51
|
+
duration = 0.5,
|
|
52
|
+
ease = EASES.reveal,
|
|
53
|
+
stagger = 0.1,
|
|
54
|
+
delay = 0,
|
|
55
|
+
start = "top 90%",
|
|
56
|
+
end = "bottom 10%",
|
|
57
|
+
mode = "scroll",
|
|
58
|
+
} = options;
|
|
59
|
+
|
|
60
|
+
return guard(options, run);
|
|
61
|
+
|
|
62
|
+
function run(): Destroy {
|
|
63
|
+
// Group by parent so siblings stagger together rather than individually.
|
|
64
|
+
const groups = new Map<Element, HTMLElement[]>();
|
|
65
|
+
els.forEach((el) => {
|
|
66
|
+
const parent = el.parentElement ?? el;
|
|
67
|
+
const list = groups.get(parent) ?? [];
|
|
68
|
+
list.push(el);
|
|
69
|
+
groups.set(parent, list);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const tweens: gsap.core.Tween[] = [];
|
|
73
|
+
|
|
74
|
+
gsap.set(els, { y: from });
|
|
75
|
+
|
|
76
|
+
groups.forEach((members) => {
|
|
77
|
+
const vars: gsap.TweenVars = {
|
|
78
|
+
y: to,
|
|
79
|
+
duration,
|
|
80
|
+
stagger,
|
|
81
|
+
delay,
|
|
82
|
+
ease,
|
|
83
|
+
overwrite: "auto",
|
|
84
|
+
};
|
|
85
|
+
if (mode === "scroll") {
|
|
86
|
+
vars.scrollTrigger = {
|
|
87
|
+
trigger: members[0].parentElement ?? members[0],
|
|
88
|
+
start,
|
|
89
|
+
end,
|
|
90
|
+
toggleActions: "play reverse play reverse",
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
tweens.push(gsap.to(members, vars));
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
ScrollTrigger.refresh();
|
|
97
|
+
|
|
98
|
+
return () => {
|
|
99
|
+
tweens.forEach((t) => {
|
|
100
|
+
t.scrollTrigger?.kill();
|
|
101
|
+
t.kill();
|
|
102
|
+
});
|
|
103
|
+
gsap.set(els, { clearProps: "transform" });
|
|
104
|
+
ScrollTrigger.refresh();
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Rule reveal — the thin line that draws itself out to full width.
|
|
111
|
+
*
|
|
112
|
+
* <div class="ak-rule"></div>
|
|
113
|
+
* revealRule(".ak-rule")
|
|
114
|
+
*
|
|
115
|
+
* `initial:{width:0}` → `whileInView:{width:"100%"}`, duration 1s, same ease.
|
|
116
|
+
*/
|
|
117
|
+
export function revealRule(
|
|
118
|
+
target: TargetLike,
|
|
119
|
+
options: CommonOptions & { duration?: number; delay?: number; ease?: string } = {},
|
|
120
|
+
): Destroy {
|
|
121
|
+
initGSAP();
|
|
122
|
+
const els = toArray<HTMLElement>(target);
|
|
123
|
+
if (!els.length) return () => {};
|
|
124
|
+
|
|
125
|
+
const { duration = 1, delay = 0, ease = EASES.reveal } = options;
|
|
126
|
+
|
|
127
|
+
return guard(options, () => {
|
|
128
|
+
gsap.set(els, { width: 0 });
|
|
129
|
+
const tween = gsap.to(els, {
|
|
130
|
+
width: "100%",
|
|
131
|
+
duration,
|
|
132
|
+
delay,
|
|
133
|
+
ease,
|
|
134
|
+
stagger: 0.1,
|
|
135
|
+
scrollTrigger: {
|
|
136
|
+
trigger: els[0],
|
|
137
|
+
start: "top 92%",
|
|
138
|
+
toggleActions: "play none none reverse",
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
return () => {
|
|
143
|
+
tween.scrollTrigger?.kill();
|
|
144
|
+
tween.kill();
|
|
145
|
+
gsap.set(els, { clearProps: "width" });
|
|
146
|
+
};
|
|
147
|
+
});
|
|
148
|
+
}
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Menu overlay — the full-screen curtain menu.
|
|
3
|
+
*
|
|
4
|
+
* The panel is a `position:fixed` layer whose `clip-path` polygon animates
|
|
5
|
+
* between collapsed-at-bottom and fully-open, using the studio's `ak-curtain`
|
|
6
|
+
* ease (`.76,0,.2,1`). Menu links slide in/out of their own masks with a
|
|
7
|
+
* stagger, and the nav logo + toggle lift out of the way first.
|
|
8
|
+
*
|
|
9
|
+
* menuOverlay({ overlay: ".menu-overlay", open: ".menu-toggle-open", close: … })
|
|
10
|
+
*
|
|
11
|
+
* Requires the markup contract in styles/anim-kit.css (`.menu-overlay`,
|
|
12
|
+
* `.menu-link`, `.menu-overlay-bar`).
|
|
13
|
+
*/
|
|
14
|
+
import { gsap, ScrollTrigger, initGSAP, EASES } from "../core/gsap.js";
|
|
15
|
+
import { one, toArray, prefersReducedMotion } from "../core/util.js";
|
|
16
|
+
import type { CommonOptions, Destroy, TargetLike } from "../core/types.js";
|
|
17
|
+
|
|
18
|
+
export interface MenuOverlayOptions extends CommonOptions {
|
|
19
|
+
/** The fixed full-screen panel. */
|
|
20
|
+
overlay: TargetLike;
|
|
21
|
+
/** Element that opens the menu (or a boolean-ish toggle target). */
|
|
22
|
+
openTrigger?: TargetLike;
|
|
23
|
+
/** Element that closes the menu. */
|
|
24
|
+
closeTrigger?: TargetLike;
|
|
25
|
+
/** Nav bar that slides away while the menu is open. */
|
|
26
|
+
nav?: TargetLike;
|
|
27
|
+
/** Selector for the menu links inside `overlay`. @default ".menu-link a" */
|
|
28
|
+
link?: string;
|
|
29
|
+
/** Elements in the overlay chrome that rise into view. @default "[data-menu-chrome]" */
|
|
30
|
+
chrome?: string;
|
|
31
|
+
/** Duration of the curtain, seconds. @default 1 */
|
|
32
|
+
duration?: number;
|
|
33
|
+
/** Stagger between links, seconds. @default 0.1 */
|
|
34
|
+
stagger?: number;
|
|
35
|
+
/** Start open. @default false */
|
|
36
|
+
initialOpen?: boolean;
|
|
37
|
+
onOpen?: () => void;
|
|
38
|
+
onClose?: () => void;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface MenuOverlayHandle {
|
|
42
|
+
open: () => void;
|
|
43
|
+
close: () => void;
|
|
44
|
+
toggle: () => void;
|
|
45
|
+
isOpen: () => boolean;
|
|
46
|
+
destroy: Destroy;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const COLLAPSED_BOTTOM = "polygon(0% 100%, 100% 100%, 100% 100%, 0% 100%)";
|
|
50
|
+
const COLLAPSED_TOP = "polygon(0% 0%, 100% 0%, 100% 0%, 0% 0%)";
|
|
51
|
+
const OPEN = "polygon(0% 0%, 100% 0%, 100% 100%, 0% 100%)";
|
|
52
|
+
|
|
53
|
+
export function menuOverlay(
|
|
54
|
+
options: MenuOverlayOptions,
|
|
55
|
+
): MenuOverlayHandle {
|
|
56
|
+
initGSAP();
|
|
57
|
+
|
|
58
|
+
const {
|
|
59
|
+
overlay: overlayTarget,
|
|
60
|
+
openTrigger,
|
|
61
|
+
closeTrigger,
|
|
62
|
+
nav: navTarget,
|
|
63
|
+
link = ".menu-link a",
|
|
64
|
+
chrome = "[data-menu-chrome]",
|
|
65
|
+
duration = 1,
|
|
66
|
+
stagger = 0.1,
|
|
67
|
+
initialOpen = false,
|
|
68
|
+
onOpen,
|
|
69
|
+
onClose,
|
|
70
|
+
} = options;
|
|
71
|
+
|
|
72
|
+
const noop = { open: () => {}, close: () => {}, toggle: () => {}, isOpen: () => false, destroy: () => {} };
|
|
73
|
+
|
|
74
|
+
const overlay = one<HTMLElement>(overlayTarget);
|
|
75
|
+
if (!overlay) return noop;
|
|
76
|
+
|
|
77
|
+
const nav = navTarget ? one<HTMLElement>(navTarget) : null;
|
|
78
|
+
const openBtns = toArray<HTMLElement>(openTrigger);
|
|
79
|
+
const closeBtns = toArray<HTMLElement>(closeTrigger);
|
|
80
|
+
const links = toArray<HTMLElement>(link, overlay);
|
|
81
|
+
const chromeEls = toArray<HTMLElement>(chrome, overlay);
|
|
82
|
+
|
|
83
|
+
// Reduced motion: leave the overlay collapsed and expose inert controls.
|
|
84
|
+
if (prefersReducedMotion() && !options.force) {
|
|
85
|
+
gsap.set(overlay, { clipPath: COLLAPSED_BOTTOM, pointerEvents: "none" });
|
|
86
|
+
return noop;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return setup();
|
|
90
|
+
|
|
91
|
+
function setup(): MenuOverlayHandle {
|
|
92
|
+
let open = initialOpen;
|
|
93
|
+
const ease = EASES.curtain;
|
|
94
|
+
|
|
95
|
+
// Initial state: curtain collapsed at the bottom edge, links waiting below.
|
|
96
|
+
gsap.set(overlay, {
|
|
97
|
+
clipPath: COLLAPSED_BOTTOM,
|
|
98
|
+
pointerEvents: "none",
|
|
99
|
+
});
|
|
100
|
+
if (links.length) gsap.set(links, { y: "100%" });
|
|
101
|
+
if (chromeEls.length) gsap.set(chromeEls, { y: 20 });
|
|
102
|
+
|
|
103
|
+
const tweens: gsap.core.Tween[] = [];
|
|
104
|
+
|
|
105
|
+
const openMenu = () => {
|
|
106
|
+
if (open) return;
|
|
107
|
+
open = true;
|
|
108
|
+
const tl = gsap.timeline({ onComplete: () => onOpen?.() });
|
|
109
|
+
|
|
110
|
+
// Curtain sweeps up to fill the viewport.
|
|
111
|
+
tl.to(overlay, {
|
|
112
|
+
clipPath: OPEN,
|
|
113
|
+
duration,
|
|
114
|
+
ease,
|
|
115
|
+
onStart: () => {
|
|
116
|
+
gsap.set(overlay, { pointerEvents: "all" });
|
|
117
|
+
if (nav) gsap.set(nav, { pointerEvents: "none" });
|
|
118
|
+
},
|
|
119
|
+
}, 0);
|
|
120
|
+
|
|
121
|
+
// Nav chrome lifts out of the way ahead of the curtain.
|
|
122
|
+
if (nav) {
|
|
123
|
+
const navBits = toArray<HTMLElement>("a, p", nav);
|
|
124
|
+
if (navBits.length) {
|
|
125
|
+
gsap.set(navBits, { y: 0 });
|
|
126
|
+
tl.to(navBits, { y: -20, duration, stagger, ease }, 0);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Links slide up into place.
|
|
131
|
+
if (links.length) {
|
|
132
|
+
tl.fromTo(
|
|
133
|
+
links,
|
|
134
|
+
{ y: "100%" },
|
|
135
|
+
{ y: "0%", duration, stagger: stagger / 2, ease: "power3.out" },
|
|
136
|
+
duration * 0.5,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
if (chromeEls.length) {
|
|
140
|
+
tl.to(chromeEls, { y: 0, duration, stagger, ease }, duration * 0.5);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
tweens.push(tl as unknown as gsap.core.Tween);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
const closeMenu = () => {
|
|
147
|
+
if (!open) return;
|
|
148
|
+
open = false;
|
|
149
|
+
const tl = gsap.timeline({ onComplete: () => onClose?.() });
|
|
150
|
+
|
|
151
|
+
if (chromeEls.length) {
|
|
152
|
+
tl.to(chromeEls, { y: -20, duration: duration * 0.75, stagger: stagger / 2, ease }, 0);
|
|
153
|
+
}
|
|
154
|
+
if (links.length) {
|
|
155
|
+
tl.to(links, {
|
|
156
|
+
y: "100%",
|
|
157
|
+
duration: duration * 0.75,
|
|
158
|
+
stagger: 0.05,
|
|
159
|
+
ease: "power4.in",
|
|
160
|
+
}, 0);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
tl.to(
|
|
164
|
+
overlay,
|
|
165
|
+
{
|
|
166
|
+
clipPath: COLLAPSED_TOP,
|
|
167
|
+
duration,
|
|
168
|
+
delay: 0.5,
|
|
169
|
+
ease,
|
|
170
|
+
onComplete: () => {
|
|
171
|
+
gsap.set(overlay, { pointerEvents: "none" });
|
|
172
|
+
if (nav) gsap.set(nav, { pointerEvents: "all" });
|
|
173
|
+
// Reset for next open so the curtain always starts at the bottom.
|
|
174
|
+
gsap.set(overlay, { clipPath: COLLAPSED_BOTTOM });
|
|
175
|
+
if (links.length) gsap.set(links, { y: "100%" });
|
|
176
|
+
if (chromeEls.length) gsap.set(chromeEls, { y: 20 });
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
0,
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
if (nav) {
|
|
183
|
+
const navBits = toArray<HTMLElement>("a, p", nav);
|
|
184
|
+
if (navBits.length) tl.to(navBits, { y: 0, duration, stagger, ease }, duration * 0.5);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
tweens.push(tl as unknown as gsap.core.Tween);
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const toggle = () => (open ? closeMenu() : openMenu());
|
|
191
|
+
|
|
192
|
+
const listeners: Array<[HTMLElement, string, () => void]> = [];
|
|
193
|
+
openBtns.forEach((el) => {
|
|
194
|
+
el.addEventListener("click", openMenu);
|
|
195
|
+
listeners.push([el, "click", openMenu]);
|
|
196
|
+
});
|
|
197
|
+
closeBtns.forEach((el) => {
|
|
198
|
+
el.addEventListener("click", closeMenu);
|
|
199
|
+
listeners.push([el, "click", closeMenu]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
if (initialOpen) openMenu();
|
|
203
|
+
|
|
204
|
+
return {
|
|
205
|
+
open: openMenu,
|
|
206
|
+
close: closeMenu,
|
|
207
|
+
toggle,
|
|
208
|
+
isOpen: () => open,
|
|
209
|
+
destroy: () => {
|
|
210
|
+
listeners.forEach(([el, type, fn]) => el.removeEventListener(type, fn));
|
|
211
|
+
tweens.forEach((t) => t.kill());
|
|
212
|
+
gsap.set(overlay, { clearProps: "all" });
|
|
213
|
+
gsap.set([...links, ...chromeEls], { clearProps: "all" });
|
|
214
|
+
void ScrollTrigger;
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hide-on-scroll nav — the header that slides away when you scroll down and
|
|
3
|
+
* snaps back when you scroll up.
|
|
4
|
+
*
|
|
5
|
+
* Their exact rules:
|
|
6
|
+
* - within `threshold` px of the top → hide (slow, 1s power2.out)
|
|
7
|
+
* - scrolling down → hide (fast, 0.3s)
|
|
8
|
+
* - scrolling up → show (fast, 0.3s)
|
|
9
|
+
* On viewports under 768px the nav is always visible.
|
|
10
|
+
*/
|
|
11
|
+
import { gsap, initGSAP, killTweens } from "../core/gsap.js";
|
|
12
|
+
import { guard } from "../core/guard.js";
|
|
13
|
+
import { one } from "../core/util.js";
|
|
14
|
+
import type { CommonOptions, Destroy, TargetLike } from "../core/types.js";
|
|
15
|
+
|
|
16
|
+
export interface NavHideOptions extends CommonOptions {
|
|
17
|
+
/** Distance from the top under which the nav is hidden. @default 200 */
|
|
18
|
+
threshold?: number;
|
|
19
|
+
/** Hide offset in px. @default -100 */
|
|
20
|
+
hideY?: number;
|
|
21
|
+
/** Below this width the nav is pinned visible. @default 768 */
|
|
22
|
+
mobileBreakpoint?: number;
|
|
23
|
+
/** Start hidden. @default true */
|
|
24
|
+
startHidden?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function navHide(target: TargetLike, options: NavHideOptions = {}): Destroy {
|
|
28
|
+
initGSAP();
|
|
29
|
+
|
|
30
|
+
const nav = one<HTMLElement>(target);
|
|
31
|
+
if (!nav) return () => {};
|
|
32
|
+
|
|
33
|
+
const {
|
|
34
|
+
threshold = 200,
|
|
35
|
+
hideY = -100,
|
|
36
|
+
mobileBreakpoint = 768,
|
|
37
|
+
startHidden = true,
|
|
38
|
+
} = options;
|
|
39
|
+
|
|
40
|
+
return guard(options, () => {
|
|
41
|
+
let last = window.scrollY;
|
|
42
|
+
let visible = !startHidden;
|
|
43
|
+
|
|
44
|
+
const isMobile = () => window.innerWidth < mobileBreakpoint;
|
|
45
|
+
|
|
46
|
+
const setHidden = (hidden: boolean, duration: number) => {
|
|
47
|
+
if (hidden === !visible) return;
|
|
48
|
+
visible = !hidden;
|
|
49
|
+
gsap.to(nav, {
|
|
50
|
+
y: hidden ? hideY : 0,
|
|
51
|
+
duration,
|
|
52
|
+
ease: "power2.out",
|
|
53
|
+
overwrite: "auto",
|
|
54
|
+
});
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Initial placement — no animation, just the right resting state.
|
|
58
|
+
gsap.set(nav, { y: isMobile() || !startHidden ? 0 : hideY });
|
|
59
|
+
visible = !isMobile() && !startHidden ? true : startHidden ? false : true;
|
|
60
|
+
|
|
61
|
+
const onScroll = () => {
|
|
62
|
+
const y = window.scrollY;
|
|
63
|
+
|
|
64
|
+
if (isMobile()) {
|
|
65
|
+
setHidden(false, 0.3);
|
|
66
|
+
} else if (y <= threshold) {
|
|
67
|
+
// Near the top: park the nav out of the way.
|
|
68
|
+
if (visible) setHidden(true, 1);
|
|
69
|
+
} else if (y > last) {
|
|
70
|
+
setHidden(true, 0.3);
|
|
71
|
+
} else if (y < last) {
|
|
72
|
+
setHidden(false, 0.3);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
last = y;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
window.addEventListener("scroll", onScroll, { passive: true });
|
|
79
|
+
onScroll();
|
|
80
|
+
|
|
81
|
+
return () => {
|
|
82
|
+
window.removeEventListener("scroll", onScroll);
|
|
83
|
+
// A destroy() must not leave new work behind: kill any in-flight slide
|
|
84
|
+
// and drop the transform we own instead of tweening back (the old code
|
|
85
|
+
// started a fresh 0.3s tween *from* destroy, which also survived it).
|
|
86
|
+
killTweens(nav);
|
|
87
|
+
gsap.set(nav, { clearProps: "transform" });
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parallax — the studio's `data-speed` utility.
|
|
3
|
+
*
|
|
4
|
+
* <img data-speed="-0.4" />
|
|
5
|
+
*
|
|
6
|
+
* Each matching element is tweened `yPercent: 50 * speed` across
|
|
7
|
+
* `start:"50% bottom"` → `end:"bottom top"` with `scrub:true`, so negative
|
|
8
|
+
* speeds drift up and positive speeds lag down. Pure `ease:"none"`.
|
|
9
|
+
*/
|
|
10
|
+
import { gsap, ScrollTrigger, initGSAP } 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 ParallaxOptions extends CommonOptions {
|
|
16
|
+
/** Attribute holding the speed multiplier. @default "data-speed" */
|
|
17
|
+
attribute?: string;
|
|
18
|
+
/** Multiplier applied to the attribute value. @default 50 */
|
|
19
|
+
scale?: number;
|
|
20
|
+
start?: string;
|
|
21
|
+
end?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function parallax(target: TargetLike, options: ParallaxOptions = {}): Destroy {
|
|
25
|
+
initGSAP();
|
|
26
|
+
|
|
27
|
+
const root = one<HTMLElement>(target);
|
|
28
|
+
if (!root) return () => {};
|
|
29
|
+
|
|
30
|
+
const {
|
|
31
|
+
attribute = "data-speed",
|
|
32
|
+
scale = 50,
|
|
33
|
+
start = "50% bottom",
|
|
34
|
+
end = "bottom top",
|
|
35
|
+
} = options;
|
|
36
|
+
|
|
37
|
+
return guard(options, () => {
|
|
38
|
+
const items: Array<{ el: HTMLElement; st?: ScrollTrigger }> = [];
|
|
39
|
+
|
|
40
|
+
root.querySelectorAll<HTMLElement>(`[${attribute}]`).forEach((el) => {
|
|
41
|
+
const raw = parseFloat(el.getAttribute(attribute) || "0");
|
|
42
|
+
if (Number.isNaN(raw)) return;
|
|
43
|
+
const speed = raw * scale;
|
|
44
|
+
|
|
45
|
+
const tw = gsap.to(el, {
|
|
46
|
+
yPercent: speed,
|
|
47
|
+
ease: "none",
|
|
48
|
+
scrollTrigger: { trigger: el, start, end, scrub: true },
|
|
49
|
+
});
|
|
50
|
+
items.push({ el, st: tw.scrollTrigger });
|
|
51
|
+
// Track tweens for cleanup via a WeakMap-free trick: kill through ST.
|
|
52
|
+
(el as unknown as Record<string, unknown>).__akParallax = tw;
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
ScrollTrigger.refresh();
|
|
56
|
+
|
|
57
|
+
return () => {
|
|
58
|
+
items.forEach(({ el, st }) => {
|
|
59
|
+
st?.kill();
|
|
60
|
+
const tw = (el as unknown as { __akParallax?: gsap.core.Tween }).__akParallax;
|
|
61
|
+
tw?.kill();
|
|
62
|
+
delete (el as unknown as Record<string, unknown>).__akParallax;
|
|
63
|
+
gsap.set(el, { clearProps: "transform" });
|
|
64
|
+
});
|
|
65
|
+
ScrollTrigger.refresh();
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
}
|