@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,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preloader — the full-screen intro from dzinrstudio.com.
|
|
3
|
+
*
|
|
4
|
+
* What it does on the real site:
|
|
5
|
+
* 1. A big number counts 0 → 100 in the bottom-left corner (steps of 5,
|
|
6
|
+
* every 200ms — i.e. a 4s counter), sliding in from x:-100px.
|
|
7
|
+
* 2. An SVG "!" glyph is revealed bottom-up via `clip-path: inset(N% 0 0 0)`
|
|
8
|
+
* driven by the same progress.
|
|
9
|
+
* 3. When the count hits 100 the glyph scales to 35x and fades out with the
|
|
10
|
+
* `ak-preload-out` ease (delay 5s), then the backdrop fades (delay 5.5s).
|
|
11
|
+
* 4. The whole overlay unmounts ~1s later. A `sessionStorage` flag skips the
|
|
12
|
+
* preloader on subsequent visits within the session.
|
|
13
|
+
*/
|
|
14
|
+
import { gsap, initGSAP, EASES, killTweens } from "../core/gsap.js";
|
|
15
|
+
import { guard } from "../core/guard.js";
|
|
16
|
+
import { one, prefersReducedMotion } from "../core/util.js";
|
|
17
|
+
import type { CommonOptions, Destroy, TargetLike } from "../core/types.js";
|
|
18
|
+
|
|
19
|
+
export interface PreloaderOptions extends CommonOptions {
|
|
20
|
+
/** Root overlay element. */
|
|
21
|
+
root: string | HTMLElement;
|
|
22
|
+
/** The SVG glyph that fills up. Defaults to the first svg inside `root`. */
|
|
23
|
+
glyph?: string | HTMLElement;
|
|
24
|
+
/** Element receiving the 0→100 number. Defaults to first element with `[data-counter]`. */
|
|
25
|
+
counter?: string | HTMLElement;
|
|
26
|
+
/** Solid backdrop behind the glyph. Defaults to `[data-backdrop]`. */
|
|
27
|
+
backdrop?: string | HTMLElement;
|
|
28
|
+
/** Total counter duration in seconds. @default 4 */
|
|
29
|
+
duration?: number;
|
|
30
|
+
/** Counter increment per tick. @default 5 */
|
|
31
|
+
step?: number;
|
|
32
|
+
/** Interval between ticks in ms. @default 200 */
|
|
33
|
+
interval?: number;
|
|
34
|
+
/** Hide when already shown this session. @default true */
|
|
35
|
+
sessionGuard?: boolean;
|
|
36
|
+
/** sessionStorage key. @default "ak-preloader-shown" */
|
|
37
|
+
storageKey?: string;
|
|
38
|
+
/** Called once the overlay has finished and should be removed. */
|
|
39
|
+
onComplete?: () => void;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Intro preloader — 0→100 counter, glyph `inset()` fill, scale-out exit.
|
|
44
|
+
*
|
|
45
|
+
* Accepts the library's standard positional form **or** an options object:
|
|
46
|
+
* preloader("#preloader", { duration: 4 })
|
|
47
|
+
* preloader({ root: "#preloader", duration: 4 })
|
|
48
|
+
*/
|
|
49
|
+
export function preloader(options: PreloaderOptions): Destroy;
|
|
50
|
+
export function preloader(target: TargetLike, options?: Partial<PreloaderOptions>): Destroy;
|
|
51
|
+
export function preloader(
|
|
52
|
+
targetOrOptions: PreloaderOptions | TargetLike,
|
|
53
|
+
maybeOptions: Partial<PreloaderOptions> = {},
|
|
54
|
+
): Destroy {
|
|
55
|
+
initGSAP();
|
|
56
|
+
|
|
57
|
+
const options: PreloaderOptions = isTargetArg(targetOrOptions)
|
|
58
|
+
? { ...maybeOptions, root: (targetOrOptions ?? maybeOptions.root) as PreloaderOptions["root"] }
|
|
59
|
+
: targetOrOptions;
|
|
60
|
+
|
|
61
|
+
const {
|
|
62
|
+
duration = 4,
|
|
63
|
+
step = 5,
|
|
64
|
+
interval = 200,
|
|
65
|
+
sessionGuard = true,
|
|
66
|
+
storageKey = "ak-preloader-shown",
|
|
67
|
+
onComplete,
|
|
68
|
+
} = options;
|
|
69
|
+
|
|
70
|
+
const root = one(options.root);
|
|
71
|
+
if (!root) return () => {};
|
|
72
|
+
const overlay: HTMLElement = root;
|
|
73
|
+
|
|
74
|
+
const glyph = options.glyph ? one(options.glyph) : root.querySelector("svg");
|
|
75
|
+
const counterEl = options.counter
|
|
76
|
+
? one(options.counter)
|
|
77
|
+
: root.querySelector<HTMLElement>("[data-counter]");
|
|
78
|
+
const backdrop = options.backdrop
|
|
79
|
+
? one(options.backdrop)
|
|
80
|
+
: root.querySelector<HTMLElement>("[data-backdrop]");
|
|
81
|
+
|
|
82
|
+
if (sessionGuard && sessionStorage.getItem(storageKey)) {
|
|
83
|
+
overlay.style.display = "none";
|
|
84
|
+
onComplete?.();
|
|
85
|
+
return () => {};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Reduced motion: never block the page behind a counting intro.
|
|
89
|
+
if (prefersReducedMotion() && !options.force) {
|
|
90
|
+
overlay.style.display = "none";
|
|
91
|
+
onComplete?.();
|
|
92
|
+
return () => {};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return guard(options, () => run());
|
|
96
|
+
|
|
97
|
+
function run(): Destroy {
|
|
98
|
+
const timers: number[] = [];
|
|
99
|
+
let value = 0;
|
|
100
|
+
let done = false;
|
|
101
|
+
|
|
102
|
+
// Entrance: number slides in from the left.
|
|
103
|
+
if (counterEl) {
|
|
104
|
+
gsap.fromTo(
|
|
105
|
+
counterEl,
|
|
106
|
+
{ opacity: 0, x: -100 },
|
|
107
|
+
{ opacity: 1, x: 0, duration: 0.5, ease: "power2.out" },
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Glyph fills bottom → top, tied to the counter.
|
|
112
|
+
if (glyph) {
|
|
113
|
+
gsap.set(glyph, { clipPath: "inset(100% 0px 0px 0px)" });
|
|
114
|
+
gsap.to(glyph, {
|
|
115
|
+
clipPath: "inset(0% 0px 0px 0px)",
|
|
116
|
+
duration,
|
|
117
|
+
ease: "power1.inOut",
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const tick = () => {
|
|
122
|
+
value = Math.min(100, value + step);
|
|
123
|
+
if (counterEl) counterEl.textContent = String(value);
|
|
124
|
+
if (value >= 100) {
|
|
125
|
+
finish();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
timers.push(window.setTimeout(tick, interval));
|
|
129
|
+
};
|
|
130
|
+
timers.push(window.setTimeout(tick, interval));
|
|
131
|
+
|
|
132
|
+
function finish() {
|
|
133
|
+
if (done) return;
|
|
134
|
+
done = true;
|
|
135
|
+
|
|
136
|
+
const tl = gsap.timeline({
|
|
137
|
+
onComplete: () => {
|
|
138
|
+
sessionStorage.setItem(storageKey, "true");
|
|
139
|
+
overlay.style.display = "none";
|
|
140
|
+
onComplete?.();
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// Glyph blows up and dissolves…
|
|
145
|
+
if (glyph) {
|
|
146
|
+
tl.to(
|
|
147
|
+
glyph,
|
|
148
|
+
{
|
|
149
|
+
scale: 35,
|
|
150
|
+
opacity: 0,
|
|
151
|
+
duration: 0.6,
|
|
152
|
+
ease: EASES.preloadOut,
|
|
153
|
+
transformOrigin: "center 50%",
|
|
154
|
+
},
|
|
155
|
+
1,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
// …the number exits, …
|
|
159
|
+
if (counterEl) {
|
|
160
|
+
tl.to(counterEl, { opacity: 0, x: -40, duration: 0.35, ease: "power2.in" }, 1.15);
|
|
161
|
+
}
|
|
162
|
+
// …then the backdrop fades to reveal the page.
|
|
163
|
+
if (backdrop) {
|
|
164
|
+
tl.to(backdrop, { opacity: 0, duration: 0.5, ease: "power2.out" }, 1.5);
|
|
165
|
+
} else {
|
|
166
|
+
tl.to(root, { opacity: 0, duration: 0.5 }, 1.5);
|
|
167
|
+
}
|
|
168
|
+
tl.set(root, { pointerEvents: "none" }, 1.6);
|
|
169
|
+
tl.call(() => onComplete?.(), undefined, ">-0.1");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return () => {
|
|
173
|
+
timers.forEach(clearTimeout);
|
|
174
|
+
killTweens([root, glyph, counterEl, backdrop]);
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Distinguish a positional DOM target from an options object. */
|
|
180
|
+
function isTargetArg(v: PreloaderOptions | TargetLike): v is TargetLike {
|
|
181
|
+
if (typeof v === "string" || v == null) return true;
|
|
182
|
+
if (typeof v !== "object") return false;
|
|
183
|
+
if (Array.isArray(v)) return true;
|
|
184
|
+
const rec = v as unknown as Record<string, unknown>;
|
|
185
|
+
if ("nodeType" in rec) return true; // Element / Node
|
|
186
|
+
return typeof rec.length === "number" && "item" in rec; // NodeList
|
|
187
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scatter text — the giant "So, are you ready to Stand out?" band.
|
|
3
|
+
*
|
|
4
|
+
* The line is split into per-character spans, then pinned and scrolled
|
|
5
|
+
* horizontally. Every character carries a random initial `yPercent` (±60) and
|
|
6
|
+
* `rotation` (±15) and settles to zero as it crosses the viewport, driven by
|
|
7
|
+
* `containerAnimation` so the settle is tied to the *horizontal* scroll.
|
|
8
|
+
*/
|
|
9
|
+
import { gsap, ScrollTrigger, initGSAP } from "../core/gsap.js";
|
|
10
|
+
import { guard } from "../core/guard.js";
|
|
11
|
+
import { one } from "../core/util.js";
|
|
12
|
+
import type { CommonOptions, Destroy, TargetLike } from "../core/types.js";
|
|
13
|
+
|
|
14
|
+
export interface ScatterTextOptions extends CommonOptions {
|
|
15
|
+
/** Element that holds the text and gets translated on X. */
|
|
16
|
+
line?: TargetLike;
|
|
17
|
+
/** Element pinned while the band scrolls. */
|
|
18
|
+
pinTarget?: TargetLike;
|
|
19
|
+
/** Split by "chars" (default) or "words". @default "chars" */
|
|
20
|
+
granularity?: "chars" | "words";
|
|
21
|
+
/** Max random vertical offset, in percent of the line height. @default 60 */
|
|
22
|
+
scatterY?: number;
|
|
23
|
+
/** Max random rotation, degrees. @default 15 */
|
|
24
|
+
scatterRotation?: number;
|
|
25
|
+
/** Scrub smoothing. @default 0.5 */
|
|
26
|
+
scrub?: number;
|
|
27
|
+
/** Per-character settle start/end as a fraction of the container. */
|
|
28
|
+
settleStart?: string;
|
|
29
|
+
settleEnd?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function scatterText(
|
|
33
|
+
target: TargetLike,
|
|
34
|
+
options: ScatterTextOptions = {},
|
|
35
|
+
): Destroy {
|
|
36
|
+
initGSAP();
|
|
37
|
+
|
|
38
|
+
const wrap = one<HTMLElement>(target);
|
|
39
|
+
const line = (options.line ? one<HTMLElement>(options.line) : null) ?? wrap;
|
|
40
|
+
if (!wrap || !line) return () => {};
|
|
41
|
+
|
|
42
|
+
const pinTarget =
|
|
43
|
+
(options.pinTarget ? one<HTMLElement>(options.pinTarget) : null) ??
|
|
44
|
+
(wrap.querySelector<HTMLElement>("[data-pin]") ?? wrap);
|
|
45
|
+
|
|
46
|
+
const {
|
|
47
|
+
granularity = "chars",
|
|
48
|
+
scatterY = 60,
|
|
49
|
+
scatterRotation = 15,
|
|
50
|
+
scrub = 0.5,
|
|
51
|
+
settleStart = "left 100%",
|
|
52
|
+
settleEnd = "left 15%",
|
|
53
|
+
} = options;
|
|
54
|
+
|
|
55
|
+
return guard(options, () => {
|
|
56
|
+
const tweens: gsap.core.Tween[] = [];
|
|
57
|
+
const triggers: ScrollTrigger[] = [];
|
|
58
|
+
const text = line.textContent || "";
|
|
59
|
+
|
|
60
|
+
const build = () => {
|
|
61
|
+
// Split manually: spaces need a fixed-size slot so the band keeps flowing.
|
|
62
|
+
line.innerHTML = text
|
|
63
|
+
.split("")
|
|
64
|
+
.map((ch) =>
|
|
65
|
+
ch === " "
|
|
66
|
+
? `<span class="ak-space"> </span>`
|
|
67
|
+
: granularity === "chars"
|
|
68
|
+
? `<span class="ak-char">${ch}</span>`
|
|
69
|
+
: `<span class="ak-char">${ch}</span>`,
|
|
70
|
+
)
|
|
71
|
+
.join("");
|
|
72
|
+
|
|
73
|
+
const travel = () => Math.max(0, line.scrollWidth - window.innerWidth);
|
|
74
|
+
|
|
75
|
+
const master = gsap.to(line, {
|
|
76
|
+
x: () => -travel(),
|
|
77
|
+
ease: "none",
|
|
78
|
+
scrollTrigger: {
|
|
79
|
+
trigger: wrap,
|
|
80
|
+
pin: pinTarget,
|
|
81
|
+
scrub,
|
|
82
|
+
end: () => "+=" + travel(),
|
|
83
|
+
invalidateOnRefresh: true,
|
|
84
|
+
anticipatePin: 1,
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
tweens.push(master);
|
|
88
|
+
if (master.scrollTrigger) triggers.push(master.scrollTrigger);
|
|
89
|
+
|
|
90
|
+
const pieces = Array.from(line.querySelectorAll<HTMLElement>(".ak-char"));
|
|
91
|
+
pieces.forEach((piece) => {
|
|
92
|
+
const y = (Math.random() - 0.5) * scatterY;
|
|
93
|
+
const r = (Math.random() - 0.5) * scatterRotation;
|
|
94
|
+
const t = gsap.fromTo(
|
|
95
|
+
piece,
|
|
96
|
+
{ yPercent: y, rotation: r },
|
|
97
|
+
{
|
|
98
|
+
yPercent: 0,
|
|
99
|
+
rotation: 0,
|
|
100
|
+
ease: "power2.out",
|
|
101
|
+
scrollTrigger: {
|
|
102
|
+
trigger: piece,
|
|
103
|
+
containerAnimation: master,
|
|
104
|
+
start: settleStart,
|
|
105
|
+
end: settleEnd,
|
|
106
|
+
scrub: 0.5,
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
);
|
|
110
|
+
tweens.push(t);
|
|
111
|
+
if (t.scrollTrigger) triggers.push(t.scrollTrigger);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
ScrollTrigger.refresh();
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
// Measure only after webfonts land, or the travel distance is wrong.
|
|
118
|
+
if (document.fonts?.ready) document.fonts.ready.then(build).catch(build);
|
|
119
|
+
else build();
|
|
120
|
+
|
|
121
|
+
return () => {
|
|
122
|
+
triggers.forEach((t) => t.kill());
|
|
123
|
+
tweens.forEach((t) => t.kill());
|
|
124
|
+
line.textContent = text;
|
|
125
|
+
gsap.set(line, { clearProps: "transform" });
|
|
126
|
+
ScrollTrigger.refresh();
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stacked cards — the pinned "services" deck.
|
|
3
|
+
*
|
|
4
|
+
* A tall wrapper scrolls normally while a sticky viewport holds still; cards
|
|
5
|
+
* inside are pre-offset below centre (`yPercent:50, y:innerHeight/2`) and a
|
|
6
|
+
* scrubbed timeline marches them up past centre with a stagger and the
|
|
7
|
+
* studio's `ak-card-stack` bezier.
|
|
8
|
+
*
|
|
9
|
+
* DOM contract:
|
|
10
|
+
* <div class="ak-stack-wrap"> ← trigger, height e.g. 500vh
|
|
11
|
+
* <div class="ak-stack-viewport">← pinned (pinSpacing:false)
|
|
12
|
+
* <div class="ak-stack-cards"> ← the row of cards
|
|
13
|
+
* …cards…
|
|
14
|
+
*/
|
|
15
|
+
import { gsap, ScrollTrigger, initGSAP, EASES } from "../core/gsap.js";
|
|
16
|
+
import { guard } from "../core/guard.js";
|
|
17
|
+
import { one, toArray } from "../core/util.js";
|
|
18
|
+
import type { CommonOptions, Destroy, TargetLike } from "../core/types.js";
|
|
19
|
+
|
|
20
|
+
export interface StackedCardsOptions extends CommonOptions {
|
|
21
|
+
/** Tall scroll wrapper (trigger). */
|
|
22
|
+
wrap?: TargetLike;
|
|
23
|
+
/** Element that stays on screen. Defaults to `wrap`'s first child. */
|
|
24
|
+
viewport?: TargetLike;
|
|
25
|
+
/** Card selector scoped to the viewport. @default ".ak-card" */
|
|
26
|
+
card?: string;
|
|
27
|
+
/** Scrub smoothing. @default 0.5 */
|
|
28
|
+
scrub?: number;
|
|
29
|
+
/** Delay between cards, seconds. @default 0.12 */
|
|
30
|
+
stagger?: number;
|
|
31
|
+
/** The cascading ease. @default EASES.cardStack */
|
|
32
|
+
ease?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function stackedCards(
|
|
36
|
+
target: TargetLike,
|
|
37
|
+
options: StackedCardsOptions = {},
|
|
38
|
+
): Destroy {
|
|
39
|
+
initGSAP();
|
|
40
|
+
|
|
41
|
+
const wrap = one<HTMLElement>(target);
|
|
42
|
+
if (!wrap) return () => {};
|
|
43
|
+
|
|
44
|
+
const viewport =
|
|
45
|
+
(options.viewport ? one<HTMLElement>(options.viewport) : null) ??
|
|
46
|
+
(wrap.firstElementChild as HTMLElement | null) ??
|
|
47
|
+
wrap;
|
|
48
|
+
|
|
49
|
+
const { scrub = 0.5, stagger = 0.12, card = ".ak-card", ease = EASES.cardStack } = options;
|
|
50
|
+
|
|
51
|
+
return guard(options, () => {
|
|
52
|
+
const cards = toArray<HTMLElement>(card, viewport);
|
|
53
|
+
if (!cards.length) return () => {};
|
|
54
|
+
|
|
55
|
+
const offset = () => window.innerHeight / 2;
|
|
56
|
+
|
|
57
|
+
// Cards start stacked below centre…
|
|
58
|
+
gsap.set(cards, { yPercent: 50, y: offset() });
|
|
59
|
+
|
|
60
|
+
const tl = gsap.timeline({
|
|
61
|
+
scrollTrigger: {
|
|
62
|
+
trigger: wrap,
|
|
63
|
+
start: "top top",
|
|
64
|
+
end: "bottom bottom",
|
|
65
|
+
scrub,
|
|
66
|
+
invalidateOnRefresh: true,
|
|
67
|
+
},
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// …and are marched up past centre, one after another.
|
|
71
|
+
tl.to(
|
|
72
|
+
cards,
|
|
73
|
+
{ yPercent: -50, y: () => -offset(), duration: 1, stagger, ease },
|
|
74
|
+
"step",
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const st = tl.scrollTrigger;
|
|
78
|
+
|
|
79
|
+
ScrollTrigger.refresh();
|
|
80
|
+
|
|
81
|
+
return () => {
|
|
82
|
+
st?.kill();
|
|
83
|
+
tl.kill();
|
|
84
|
+
gsap.set(cards, { clearProps: "all" });
|
|
85
|
+
ScrollTrigger.refresh();
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Variant used when the sticky viewport is a sibling rather than a child —
|
|
92
|
+
* pins `viewport` with `pinSpacing:false` over `wrap`'s scroll length.
|
|
93
|
+
*/
|
|
94
|
+
export function stackedCardsPinned(
|
|
95
|
+
wrapTarget: TargetLike,
|
|
96
|
+
options: StackedCardsOptions & { viewport: TargetLike } = { viewport: "" },
|
|
97
|
+
): Destroy {
|
|
98
|
+
initGSAP();
|
|
99
|
+
|
|
100
|
+
const wrap = one<HTMLElement>(wrapTarget);
|
|
101
|
+
const viewport = one<HTMLElement>(options.viewport);
|
|
102
|
+
if (!wrap || !viewport) return () => {};
|
|
103
|
+
|
|
104
|
+
const { scrub = true, stagger = 0.1, card = ".ak-card", ease = EASES.cardStack } = options;
|
|
105
|
+
|
|
106
|
+
return guard(options, () => {
|
|
107
|
+
const cards = toArray<HTMLElement>(card, viewport);
|
|
108
|
+
if (!cards.length) return () => {};
|
|
109
|
+
|
|
110
|
+
gsap.set(cards, { yPercent: 50, y: window.innerHeight / 2 });
|
|
111
|
+
|
|
112
|
+
const pin = ScrollTrigger.create({
|
|
113
|
+
trigger: wrap,
|
|
114
|
+
start: "top top",
|
|
115
|
+
end: "bottom bottom",
|
|
116
|
+
pin: viewport,
|
|
117
|
+
pinSpacing: false,
|
|
118
|
+
anticipatePin: 1,
|
|
119
|
+
invalidateOnRefresh: true,
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
const tl = gsap.timeline({
|
|
123
|
+
scrollTrigger: {
|
|
124
|
+
trigger: wrap,
|
|
125
|
+
start: "top top",
|
|
126
|
+
end: "bottom bottom",
|
|
127
|
+
scrub,
|
|
128
|
+
invalidateOnRefresh: true,
|
|
129
|
+
},
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
tl.to(
|
|
133
|
+
cards,
|
|
134
|
+
{
|
|
135
|
+
yPercent: -50,
|
|
136
|
+
y: () => -window.innerHeight / 2,
|
|
137
|
+
duration: 1,
|
|
138
|
+
stagger,
|
|
139
|
+
ease,
|
|
140
|
+
},
|
|
141
|
+
0,
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
ScrollTrigger.refresh();
|
|
145
|
+
|
|
146
|
+
return () => {
|
|
147
|
+
pin.kill();
|
|
148
|
+
tl.scrollTrigger?.kill();
|
|
149
|
+
tl.kill();
|
|
150
|
+
gsap.set(cards, { clearProps: "all" });
|
|
151
|
+
ScrollTrigger.refresh();
|
|
152
|
+
};
|
|
153
|
+
});
|
|
154
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Theme reveal — circular clip-path wipe between light and dark.
|
|
3
|
+
*
|
|
4
|
+
* Uses the View Transitions API when available: `document.startViewTransition()`
|
|
5
|
+
* swaps the theme inside the callback while injected keyframes animate
|
|
6
|
+
* `::view-transition-new(root)` from `circle(0%)` to `circle(100%)`, centred on
|
|
7
|
+
* the button that was clicked. Falls back to an instant swap.
|
|
8
|
+
*
|
|
9
|
+
* const theme = themeReveal({ toggle: "#theme-btn" });
|
|
10
|
+
* theme.set("dark"); // wipes to dark
|
|
11
|
+
* theme.toggle(); // flips and wipes
|
|
12
|
+
*/
|
|
13
|
+
import type { CommonOptions, Destroy, TargetLike } from "../core/types.js";
|
|
14
|
+
import { one } from "../core/util.js";
|
|
15
|
+
|
|
16
|
+
export type ThemeName = "light" | "dark";
|
|
17
|
+
|
|
18
|
+
export interface ThemeRevealOptions extends CommonOptions {
|
|
19
|
+
/** Click target that triggers the wipe (usually the toggle button). */
|
|
20
|
+
toggle?: TargetLike;
|
|
21
|
+
/** Storage key for persistence. @default "ak-theme" */
|
|
22
|
+
storageKey?: string;
|
|
23
|
+
/** Initial theme; defaults to whatever is already on <html>. */
|
|
24
|
+
initial?: ThemeName;
|
|
25
|
+
/** Circle origin, e.g. "50% 50%" or an element to centre on. */
|
|
26
|
+
origin?: string | TargetLike;
|
|
27
|
+
/** Animation duration, seconds. @default 1 */
|
|
28
|
+
duration?: number;
|
|
29
|
+
onChange?: (theme: ThemeName) => void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ThemeRevealHandle {
|
|
33
|
+
set: (theme: ThemeName) => void;
|
|
34
|
+
toggle: () => void;
|
|
35
|
+
current: () => ThemeName;
|
|
36
|
+
destroy: Destroy;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const STYLE_ID = "ak-theme-reveal";
|
|
40
|
+
|
|
41
|
+
export function themeReveal(options: ThemeRevealOptions = {}): ThemeRevealHandle {
|
|
42
|
+
const {
|
|
43
|
+
toggle,
|
|
44
|
+
storageKey = "ak-theme",
|
|
45
|
+
origin = "50% 50%",
|
|
46
|
+
duration = 1,
|
|
47
|
+
onChange,
|
|
48
|
+
} = options;
|
|
49
|
+
|
|
50
|
+
const root = document.documentElement;
|
|
51
|
+
const btn = toggle ? one<HTMLElement>(toggle) : null;
|
|
52
|
+
|
|
53
|
+
const stored = (() => {
|
|
54
|
+
try {
|
|
55
|
+
return sessionStorage.getItem(storageKey) as ThemeName | null;
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
})();
|
|
60
|
+
|
|
61
|
+
let current: ThemeName =
|
|
62
|
+
options.initial ??
|
|
63
|
+
stored ??
|
|
64
|
+
((root.classList.contains("dark") ? "dark" : "light") as ThemeName);
|
|
65
|
+
|
|
66
|
+
apply(current, false);
|
|
67
|
+
|
|
68
|
+
const onClick = () => set(current === "dark" ? "light" : "dark");
|
|
69
|
+
if (btn) btn.addEventListener("click", onClick);
|
|
70
|
+
|
|
71
|
+
function resolveOrigin(): string {
|
|
72
|
+
if (typeof origin === "string") return origin;
|
|
73
|
+
const el = origin ? one<HTMLElement>(origin) : btn;
|
|
74
|
+
if (!el) return "50% 50%";
|
|
75
|
+
const r = el.getBoundingClientRect();
|
|
76
|
+
return `${Math.round(r.left + r.width / 2)}px ${Math.round(r.top + r.height / 2)}px`;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function injectStyles(o: string) {
|
|
80
|
+
let style = document.getElementById(STYLE_ID);
|
|
81
|
+
if (!style) {
|
|
82
|
+
style = document.createElement("style");
|
|
83
|
+
style.id = STYLE_ID;
|
|
84
|
+
document.head.appendChild(style);
|
|
85
|
+
}
|
|
86
|
+
const t = `${duration}s`;
|
|
87
|
+
style.textContent = `
|
|
88
|
+
@supports (view-transition-name: root) {
|
|
89
|
+
::view-transition-group(root) { animation-timing-function: cubic-bezier(0.76, 0, 0.24, 1); }
|
|
90
|
+
::view-transition-old(root),
|
|
91
|
+
.dark::view-transition-old(root) { animation: none; z-index: -1; }
|
|
92
|
+
::view-transition-new(root) {
|
|
93
|
+
animation: ak-theme-reveal ${t} cubic-bezier(0.76, 0, 0.24, 1);
|
|
94
|
+
clip-path: circle(0% at ${o});
|
|
95
|
+
}
|
|
96
|
+
@keyframes ak-theme-reveal {
|
|
97
|
+
from { clip-path: circle(0% at ${o}); }
|
|
98
|
+
to { clip-path: circle(142% at ${o}); }
|
|
99
|
+
}
|
|
100
|
+
}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function apply(theme: ThemeName, animate: boolean) {
|
|
104
|
+
const commit = () => {
|
|
105
|
+
root.classList.toggle("dark", theme === "dark");
|
|
106
|
+
root.style.colorScheme = theme;
|
|
107
|
+
current = theme;
|
|
108
|
+
try {
|
|
109
|
+
sessionStorage.setItem(storageKey, theme);
|
|
110
|
+
} catch {
|
|
111
|
+
/* private mode */
|
|
112
|
+
}
|
|
113
|
+
onChange?.(theme);
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const supported =
|
|
117
|
+
animate && !options.force && typeof document.startViewTransition === "function";
|
|
118
|
+
|
|
119
|
+
if (!supported) {
|
|
120
|
+
commit();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
injectStyles(resolveOrigin());
|
|
125
|
+
// startViewTransition returns a promise; ignore rejection on rapid toggles.
|
|
126
|
+
void document.startViewTransition(commit);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
set: (theme) => {
|
|
131
|
+
if (theme !== current) set(theme);
|
|
132
|
+
},
|
|
133
|
+
toggle: () => set(current === "dark" ? "light" : "dark"),
|
|
134
|
+
current: () => current,
|
|
135
|
+
destroy: () => {
|
|
136
|
+
if (btn) btn.removeEventListener("click", onClick);
|
|
137
|
+
document.getElementById(STYLE_ID)?.remove();
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
function set(theme: ThemeName) {
|
|
142
|
+
apply(theme, true);
|
|
143
|
+
}
|
|
144
|
+
}
|