@cosmictraveler002/anim-kit 1.2.0 → 1.4.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 -31
- package/dist/anim-kit.standalone.js +5 -5
- package/dist/anim-kit.standalone.js.map +1 -1
- package/dist/core/gsap.d.ts +1 -1
- package/dist/core/gsap.d.ts.map +1 -1
- package/dist/core/gsap.js +1 -1
- 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/drag-rail.d.ts +31 -0
- package/dist/effects/drag-rail.d.ts.map +1 -0
- package/dist/effects/drag-rail.js +235 -0
- package/dist/effects/drag-rail.js.map +1 -0
- 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 +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -1
- package/dist/index.js.map +1 -1
- package/dist/three/gl-rail.d.ts +29 -0
- package/dist/three/gl-rail.d.ts.map +1 -0
- package/dist/three/gl-rail.js +350 -0
- package/dist/three/gl-rail.js.map +1 -0
- package/dist/three/index.d.ts +15 -0
- package/dist/three/index.d.ts.map +1 -0
- package/dist/three/index.js +13 -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 +1 -1
- package/src/core/smooth-scroll.ts +1 -1
- package/src/effects/drag-rail.ts +285 -0
- package/src/effects/preloader.ts +1 -1
- package/src/effects/reel-text.ts +212 -0
- package/src/index.ts +9 -1
- package/src/three/gl-rail.ts +437 -0
- package/src/three/index.ts +14 -0
- package/src/three/webgl-media.ts +325 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reel text — the per-character odometer roll.
|
|
3
|
+
*
|
|
4
|
+
* Every character gets its own overflow-hidden mask and a vertical strip of
|
|
5
|
+
* ghost glyphs stacked under the final one; the strips roll upward (staggered
|
|
6
|
+
* left→right) and land on the real text. This is the DOM twin of a shader
|
|
7
|
+
* slot-reel: character cells spun per glyph (`u_roll`/`u_spin` style), ported
|
|
8
|
+
* to plain GSAP so it needs no canvas. Why it is shaped this way:
|
|
9
|
+
*
|
|
10
|
+
* - **Built eagerly, rolled on trigger.** The masks exist as soon as the
|
|
11
|
+
* effect mounts (so layout is stable and tests can see the structure); the
|
|
12
|
+
* roll plays immediately or on scroll-enter. On completion the original
|
|
13
|
+
* `innerHTML` is restored — screen readers and copy/paste see clean text —
|
|
14
|
+
* and a `replay` re-entry re-splits from scratch. The ScrollTrigger is
|
|
15
|
+
* registered **once per element**, outside the rebuild path, so replay
|
|
16
|
+
* never stacks triggers.
|
|
17
|
+
* - **Width probed, never guessed.** Each wrapper's width is measured from
|
|
18
|
+
* the *final* glyph (hidden probe in the body) so ghost frames of a
|
|
19
|
+
* different width can't jitter the line while it spins. jsdom reports 0 —
|
|
20
|
+
* the wrapper then shrink-wraps, which is why the unit smoke still passes.
|
|
21
|
+
* - **Same-case ghosts.** Uppercase rolls through the uppercase alphabet,
|
|
22
|
+
* lowercase through lowercase, digits through digits — the line never
|
|
23
|
+
* changes colour/shape mid-roll the way mixed-case ghosts would. Spaces and
|
|
24
|
+
* punctuation stay as plain text (they don't spin).
|
|
25
|
+
* - **Fidelity.** `destroy()` kills the timeline + trigger, clears the stamp
|
|
26
|
+
* and puts the original `innerHTML` back byte for byte.
|
|
27
|
+
*
|
|
28
|
+
* reelText("[data-reel]", { mode: "scroll", frames: 4 })
|
|
29
|
+
*
|
|
30
|
+
* CSS: nothing required — masks, strip and frame heights are inline. Give the
|
|
31
|
+
* target the `line-height` you want the cells to inherit.
|
|
32
|
+
*/
|
|
33
|
+
import { gsap, ScrollTrigger, initGSAP } from "../core/gsap.js";
|
|
34
|
+
import { guard } from "../core/guard.js";
|
|
35
|
+
import { toArray } from "../core/util.js";
|
|
36
|
+
import type { CommonOptions, Destroy, TargetLike } from "../core/types.js";
|
|
37
|
+
|
|
38
|
+
export interface ReelTextOptions extends CommonOptions {
|
|
39
|
+
/** `'immediate'` rolls on mount; `'scroll'` rolls on viewport enter. @default `"immediate"` */
|
|
40
|
+
mode?: "immediate" | "scroll";
|
|
41
|
+
/** ScrollTrigger start (only with `mode: "scroll"`). @default `"top 85%"` */
|
|
42
|
+
start?: string;
|
|
43
|
+
/** Ghost frames per character before the final glyph. @default 4 */
|
|
44
|
+
frames?: number;
|
|
45
|
+
/** Roll duration per character, seconds. @default 0.8 */
|
|
46
|
+
duration?: number;
|
|
47
|
+
/** Stagger between characters, seconds. @default 0.05 */
|
|
48
|
+
stagger?: number;
|
|
49
|
+
/** GSAP ease for the roll. @default `"power4.out"` */
|
|
50
|
+
ease?: string;
|
|
51
|
+
/** Re-arm on every scroll re-entry instead of playing once. @default false */
|
|
52
|
+
replay?: boolean;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface Cell {
|
|
56
|
+
el: HTMLElement;
|
|
57
|
+
original: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const UPPERCASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
|
61
|
+
const LOWERCASE = "abcdefghijklmnopqrstuvwxyz";
|
|
62
|
+
const DIGITS = "0123456789";
|
|
63
|
+
|
|
64
|
+
/** Only alphanumerics get a reel; spaces/punctuation pass through untouched. */
|
|
65
|
+
const spins = (ch: string) => /[A-Za-z0-9]/.test(ch);
|
|
66
|
+
|
|
67
|
+
const ghostsFor = (ch: string, count: number): string => {
|
|
68
|
+
const pool = /[A-Z]/.test(ch) ? UPPERCASE : /[a-z]/.test(ch) ? LOWERCASE : DIGITS;
|
|
69
|
+
let out = "";
|
|
70
|
+
for (let i = 0; i < count; i++) out += pool[Math.floor(Math.random() * pool.length)];
|
|
71
|
+
return out;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/** Width of a glyph — 0 in jsdom, where wrappers may shrink-wrap instead. */
|
|
75
|
+
const measureGlyph = (ch: string, sample: HTMLElement): number => {
|
|
76
|
+
const doc = sample.ownerDocument;
|
|
77
|
+
if (!doc.body) return 0;
|
|
78
|
+
const probe = doc.createElement("span");
|
|
79
|
+
probe.style.cssText = "position:absolute;visibility:hidden;white-space:pre;";
|
|
80
|
+
probe.textContent = ch;
|
|
81
|
+
doc.body.appendChild(probe);
|
|
82
|
+
const w = probe.getBoundingClientRect().width;
|
|
83
|
+
probe.remove();
|
|
84
|
+
return Math.round(w);
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export function reelText(target: TargetLike, options: ReelTextOptions = {}): Destroy {
|
|
88
|
+
initGSAP();
|
|
89
|
+
|
|
90
|
+
const els = toArray<HTMLElement>(target);
|
|
91
|
+
if (!els.length) return () => {};
|
|
92
|
+
|
|
93
|
+
const {
|
|
94
|
+
mode = "immediate",
|
|
95
|
+
start = "top 85%",
|
|
96
|
+
frames = 4,
|
|
97
|
+
duration = 0.8,
|
|
98
|
+
stagger = 0.05,
|
|
99
|
+
ease = "power4.out",
|
|
100
|
+
replay = false,
|
|
101
|
+
} = options;
|
|
102
|
+
|
|
103
|
+
return guard(options, () => {
|
|
104
|
+
const cells: Cell[] = [];
|
|
105
|
+
const timelines = new Map<HTMLElement, gsap.core.Timeline>();
|
|
106
|
+
const triggers: ScrollTrigger[] = [];
|
|
107
|
+
|
|
108
|
+
/** Unwind to the original markup (on completion and on destroy). */
|
|
109
|
+
const restore = (el: HTMLElement) => {
|
|
110
|
+
const i = cells.findIndex((c) => c.el === el);
|
|
111
|
+
if (i < 0) return;
|
|
112
|
+
el.innerHTML = cells[i].original;
|
|
113
|
+
delete el.dataset.akReel;
|
|
114
|
+
cells.splice(i, 1);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Split one element into masks + a paused roll timeline.
|
|
119
|
+
* Returns the timeline; the caller decides when (and whether) it plays.
|
|
120
|
+
*/
|
|
121
|
+
const build = (el: HTMLElement): gsap.core.Timeline | null => {
|
|
122
|
+
const text = el.textContent ?? "";
|
|
123
|
+
if (!text.trim()) return null;
|
|
124
|
+
|
|
125
|
+
timelines.get(el)?.kill(); // a finished roll from a previous entry
|
|
126
|
+
// Keep the FIRST original: a second build must not snapshot masked HTML.
|
|
127
|
+
if (!cells.some((c) => c.el === el)) cells.push({ el, original: el.innerHTML });
|
|
128
|
+
|
|
129
|
+
const lineHeight =
|
|
130
|
+
parseFloat(getComputedStyle(el).lineHeight) || el.getBoundingClientRect().height || 16;
|
|
131
|
+
|
|
132
|
+
// Original markup → per-char segments (the element must be plain text).
|
|
133
|
+
el.textContent = "";
|
|
134
|
+
const strips: HTMLElement[] = [];
|
|
135
|
+
const ends: number[] = [];
|
|
136
|
+
|
|
137
|
+
for (const ch of [...text]) {
|
|
138
|
+
if (!spins(ch)) {
|
|
139
|
+
el.appendChild(el.ownerDocument.createTextNode(ch));
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const wrapper = el.ownerDocument.createElement("span");
|
|
143
|
+
wrapper.className = "ak-reel__ch";
|
|
144
|
+
wrapper.style.cssText = `display:inline-block;overflow:hidden;vertical-align:top;height:${lineHeight}px;`;
|
|
145
|
+
|
|
146
|
+
const width = measureGlyph(ch, el);
|
|
147
|
+
if (width > 0) wrapper.style.width = `${width}px`;
|
|
148
|
+
|
|
149
|
+
const strip = el.ownerDocument.createElement("span");
|
|
150
|
+
strip.className = "ak-reel__strip";
|
|
151
|
+
strip.style.cssText = "display:flex;flex-direction:column;will-change:transform;";
|
|
152
|
+
|
|
153
|
+
for (const frame of ghostsFor(ch, frames) + ch) {
|
|
154
|
+
const frameEl = el.ownerDocument.createElement("span");
|
|
155
|
+
frameEl.style.cssText = `display:flex;align-items:center;justify-content:center;height:${lineHeight}px;flex:none;`;
|
|
156
|
+
frameEl.textContent = frame;
|
|
157
|
+
strip.appendChild(frameEl);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
wrapper.appendChild(strip);
|
|
161
|
+
el.appendChild(wrapper);
|
|
162
|
+
strips.push(strip);
|
|
163
|
+
ends.push(-frames * lineHeight);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
el.dataset.akReel = "true";
|
|
167
|
+
|
|
168
|
+
const tl = gsap.timeline({ paused: true, onComplete: () => restore(el) });
|
|
169
|
+
strips.forEach((strip, i) => {
|
|
170
|
+
tl.to(strip, { y: ends[i], duration, ease }, i * stagger);
|
|
171
|
+
});
|
|
172
|
+
timelines.set(el, tl);
|
|
173
|
+
return tl;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
els.forEach((el) => {
|
|
177
|
+
const tl = build(el);
|
|
178
|
+
if (!tl) return;
|
|
179
|
+
|
|
180
|
+
if (mode === "immediate") {
|
|
181
|
+
tl.play(0);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Registered once per element — rebuilds on replay reuse this trigger.
|
|
186
|
+
triggers.push(
|
|
187
|
+
ScrollTrigger.create({
|
|
188
|
+
trigger: el,
|
|
189
|
+
start,
|
|
190
|
+
once: !replay,
|
|
191
|
+
onEnter: () => {
|
|
192
|
+
// Restored after the last roll (or never started) → rebuild first.
|
|
193
|
+
const active = el.dataset.akReel === "true" ? timelines.get(el) : build(el);
|
|
194
|
+
active?.play(0);
|
|
195
|
+
},
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
return () => {
|
|
201
|
+
triggers.forEach((st) => st.kill());
|
|
202
|
+
triggers.length = 0;
|
|
203
|
+
timelines.forEach((tl) => tl.kill());
|
|
204
|
+
timelines.clear();
|
|
205
|
+
cells.slice().forEach((cell) => {
|
|
206
|
+
cell.el.innerHTML = cell.original;
|
|
207
|
+
delete cell.el.dataset.akReel;
|
|
208
|
+
});
|
|
209
|
+
cells.length = 0;
|
|
210
|
+
};
|
|
211
|
+
});
|
|
212
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* anim-kit — modular animation library
|
|
2
|
+
* anim-kit — modular animation library by kalakritico.in.
|
|
3
3
|
*
|
|
4
4
|
* Core:
|
|
5
5
|
* smoothScroll() Lenis + ScrollTrigger bridge (lerp 0.08)
|
|
@@ -24,10 +24,12 @@
|
|
|
24
24
|
* Infinite / loops:
|
|
25
25
|
* marquee() dual-row constant-speed marquee
|
|
26
26
|
* dragStrip() infinite draggable carousel with rotation
|
|
27
|
+
* dragRail() bounded drag rail — inertia, tanh rubber-band, wheel
|
|
27
28
|
* rollText() rolling word rotator (seamless row loop)
|
|
28
29
|
*
|
|
29
30
|
* Text extras:
|
|
30
31
|
* scrambleText() decode / cipher reveal per character
|
|
32
|
+
* reelText() per-character odometer roll (masked reel strips)
|
|
31
33
|
* flipWords() words that glide between two layouts (FLIP)
|
|
32
34
|
*
|
|
33
35
|
* Micro-interactions:
|
|
@@ -96,9 +98,15 @@ export type { MarqueeOptions } from "./effects/marquee.js";
|
|
|
96
98
|
export { rollText } from "./effects/roll-text.js";
|
|
97
99
|
export type { RollTextOptions } from "./effects/roll-text.js";
|
|
98
100
|
|
|
101
|
+
export { reelText } from "./effects/reel-text.js";
|
|
102
|
+
export type { ReelTextOptions } from "./effects/reel-text.js";
|
|
103
|
+
|
|
99
104
|
export { dragStrip } from "./effects/drag-strip.js";
|
|
100
105
|
export type { DragStripOptions } from "./effects/drag-strip.js";
|
|
101
106
|
|
|
107
|
+
export { dragRail } from "./effects/drag-rail.js";
|
|
108
|
+
export type { DragRailOptions } from "./effects/drag-rail.js";
|
|
109
|
+
|
|
102
110
|
/* ---- text extras ---- */
|
|
103
111
|
export { scrambleText } from "./effects/scramble-text.js";
|
|
104
112
|
export type { ScrambleTextOptions } from "./effects/scramble-text.js";
|
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GL rail — the WebGL overlay rail: physics below, bent cards above.
|
|
3
|
+
*
|
|
4
|
+
* `dragRail` owns the motion (intent/pos ticker, tanh rubber-band, throw
|
|
5
|
+
* momentum, Lenis-safe wheel, auto axis); this effect adds the GPU layer the
|
|
6
|
+
* flat DOM can't draw: every card re-rendered onto **one fixed canvas** whose
|
|
7
|
+
* surfaces bend around an invisible cylinder, over a perspective grid floor.
|
|
8
|
+
* Why it is shaped this way (audit notes):
|
|
9
|
+
*
|
|
10
|
+
* - **The DOM stays the source of truth.** Cards keep owning layout, labels,
|
|
11
|
+
* hit areas and alt text; each frame reads their viewport rects and the
|
|
12
|
+
* shader re-creates the pixels *beneath* the labels. A label never drifts
|
|
13
|
+
* off its card, because the card **is** the rect the label sits in — only
|
|
14
|
+
* the picture curves.
|
|
15
|
+
* - **The bend lives in the vertex shader.** Each vertex takes its offset
|
|
16
|
+
* along the rail axis (`uOffset + local`), maps it onto a cylinder of
|
|
17
|
+
* `uRadius` (`a = o / R`, `x = R·sin a`, `z = −R·(1−cos a)`) and re-centres
|
|
18
|
+
* so the card's middle stays glued to its DOM position while its edges
|
|
19
|
+
* foreshorten onto the curve. One uniform per card, no per-vertex CPU work
|
|
20
|
+
* — and the rotation the cards need arrives free from the bend itself.
|
|
21
|
+
* - **One renderer for the whole rail.** A single alpha canvas inserted
|
|
22
|
+
* *under* the track (the track's transform creates a stacking context that
|
|
23
|
+
* paints above it): labels stay DOM, card backgrounds stay transparent, and
|
|
24
|
+
* the only pixels the canvas needs are the `<img>`s it hides — each one
|
|
25
|
+
* only after its texture has actually loaded.
|
|
26
|
+
* - **The axis follows the layout.** `axis: "auto"` hands the same
|
|
27
|
+
* `resolveRailAxis()` rule to the physics *and* the bend: row overflows →
|
|
28
|
+
* horizontal rail + bend around Y, only the column overflows (mobile
|
|
29
|
+
* stack) → vertical rail + bend around X. One init serves both.
|
|
30
|
+
* - **Silent no-op ladder.** Missing target → no-op. No
|
|
31
|
+
* `WebGLRenderingContext` (jsdom, SSR, WebGL-disabled browsers) → the flat
|
|
32
|
+
* `dragRail` rail alone, *before* any context is requested (a probe call
|
|
33
|
+
* would itself log). Renderer construction throws → flat rail. Texture 404
|
|
34
|
+
* → that one card's `<img>` stays visible. Under `prefers-reduced-motion`
|
|
35
|
+
* nothing mounts at all (the outer `guard` owns that decision). Nothing
|
|
36
|
+
* ever logs.
|
|
37
|
+
* - **Fidelity.** `destroy()` unwinds the physics, then the GPU: ticker,
|
|
38
|
+
* observers, geometry, materials, textures and the renderer are disposed,
|
|
39
|
+
* the canvas is removed and image opacities + the stage's inline position
|
|
40
|
+
* are restored.
|
|
41
|
+
*
|
|
42
|
+
* glRail("[data-rail]", { radius: 1200, grid: true })
|
|
43
|
+
*
|
|
44
|
+
* CSS: the stage (viewport) `position: relative; overflow: hidden` — the
|
|
45
|
+
* canvas covers it; the track `display: flex; width: max-content; gap: 1rem;
|
|
46
|
+
* cursor: grab; user-select: none; touch-action: pan-y` (flip to a column +
|
|
47
|
+
* `width: 100%; height: max-content` for the vertical layout). Cards
|
|
48
|
+
* `position: relative` with a **transparent background** — labels paint above
|
|
49
|
+
* the canvas for free, the effect hides only the media.
|
|
50
|
+
*/
|
|
51
|
+
import * as THREE from "three";
|
|
52
|
+
import { gsap, initGSAP } from "../core/gsap.js";
|
|
53
|
+
import { guard } from "../core/guard.js";
|
|
54
|
+
import { one, toArray } from "../core/util.js";
|
|
55
|
+
import { dragRail, resolveRailAxis } from "../effects/drag-rail.js";
|
|
56
|
+
import type { CommonOptions, Destroy, TargetLike } from "../core/types.js";
|
|
57
|
+
|
|
58
|
+
export interface GlRailOptions extends CommonOptions {
|
|
59
|
+
/** Stage around the track — the canvas covers it. Defaults to `track.parentElement`. */
|
|
60
|
+
viewport?: TargetLike;
|
|
61
|
+
/** Cards re-rendered on the canvas — each needs an `<img>`. @default `":scope > *"` */
|
|
62
|
+
card?: string;
|
|
63
|
+
/** Motion + bend axis: `"x"`, `"y"`, or the layout's choice. @default `"auto"` */
|
|
64
|
+
axis?: "x" | "y" | "auto";
|
|
65
|
+
/** Cylinder radius the cards bend around, px — smaller = stronger bend. @default 1200 */
|
|
66
|
+
radius?: number;
|
|
67
|
+
/** Corner radius, px (rounded-box SDF in the fragment shader). @default 16 */
|
|
68
|
+
corner?: number;
|
|
69
|
+
/** Perspective grid floor under the cards. @default true */
|
|
70
|
+
grid?: boolean;
|
|
71
|
+
/** Device-pixel-ratio cap. @default 2 */
|
|
72
|
+
dpr?: number;
|
|
73
|
+
/** Follow speed toward the intent, per 60fps frame (0..1). @default 0.1 */
|
|
74
|
+
lerp?: number;
|
|
75
|
+
/** Rubber-band resistance distance past the ends, px. @default 140 */
|
|
76
|
+
edge?: number;
|
|
77
|
+
/** Momentum multiplier on release. @default 14 */
|
|
78
|
+
throwScale?: number;
|
|
79
|
+
/** Wheel / trackpad drives the rail (consumed while it can still move). @default true */
|
|
80
|
+
wheel?: boolean;
|
|
81
|
+
/** `(pos, velocity) => {}` called every rendered frame while mounted. */
|
|
82
|
+
onTick?: (pos: number, velocity: number) => void;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/* Card: the cylinder bend in the vertex shader, cover-crop + SDF corners in
|
|
86
|
+
the fragment — the pixel contract is webglMedia's, bent to the rail. */
|
|
87
|
+
const CARD_VERT = /* glsl */ `
|
|
88
|
+
varying vec2 vUv;
|
|
89
|
+
uniform vec2 uSize; // card size, px
|
|
90
|
+
uniform float uOffset; // centre offset from the stage centre along the rail axis, px
|
|
91
|
+
uniform float uRadius; // cylinder radius, px
|
|
92
|
+
uniform float uAxis; // 0 = rail runs x (bend around Y), 1 = rail runs y (bend around X)
|
|
93
|
+
|
|
94
|
+
void main() {
|
|
95
|
+
vUv = uv;
|
|
96
|
+
vec3 p = vec3(position.xy * uSize, 0.0);
|
|
97
|
+
float along = uAxis < 0.5 ? p.x : p.y;
|
|
98
|
+
float a = (uOffset + along) / uRadius;
|
|
99
|
+
// Re-centre on the card's own middle: the centre stays glued to the DOM
|
|
100
|
+
// rect while the edges foreshorten onto the cylinder.
|
|
101
|
+
float bent = uRadius * sin(a) - uRadius * sin(uOffset / uRadius) + uOffset;
|
|
102
|
+
if (uAxis < 0.5) p.x = bent; else p.y = bent;
|
|
103
|
+
p.z = -uRadius * (1.0 - cos(a));
|
|
104
|
+
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
|
|
105
|
+
}
|
|
106
|
+
`;
|
|
107
|
+
|
|
108
|
+
const CARD_FRAG = /* glsl */ `
|
|
109
|
+
precision highp float;
|
|
110
|
+
varying vec2 vUv;
|
|
111
|
+
uniform sampler2D uTex;
|
|
112
|
+
uniform vec2 uRes;
|
|
113
|
+
uniform vec2 uTexSize;
|
|
114
|
+
uniform float uCorner;
|
|
115
|
+
|
|
116
|
+
float sdRoundBox(vec2 p, vec2 b, float r) {
|
|
117
|
+
vec2 q = abs(p) - b + r;
|
|
118
|
+
return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
void main() {
|
|
122
|
+
vec2 px = (vUv - 0.5) * uRes;
|
|
123
|
+
float d = sdRoundBox(px, uRes * 0.5, uCorner);
|
|
124
|
+
float alpha = 1.0 - smoothstep(-1.0, 1.0, d); // 1px-ish AA on the corner
|
|
125
|
+
if (alpha < 0.003) discard;
|
|
126
|
+
|
|
127
|
+
// object-fit: cover — window the texture around its centre.
|
|
128
|
+
vec2 scale = uRes / max(uTexSize, vec2(1.0));
|
|
129
|
+
vec2 cover = (vUv - 0.5) * (scale / max(scale.x, scale.y)) + 0.5;
|
|
130
|
+
gl_FragColor = vec4(texture2D(uTex, cover).rgb, alpha);
|
|
131
|
+
}
|
|
132
|
+
`;
|
|
133
|
+
|
|
134
|
+
/* Floor: one plane lying at the bottom of the stage, lines in world space,
|
|
135
|
+
fading toward the horizon and the sides, drifting slightly with the rail. */
|
|
136
|
+
const GRID_VERT = /* glsl */ `
|
|
137
|
+
varying vec2 vWorld; // world xz under this fragment
|
|
138
|
+
void main() {
|
|
139
|
+
vec4 wp = modelMatrix * vec4(position, 1.0);
|
|
140
|
+
vWorld = wp.xz;
|
|
141
|
+
gl_Position = projectionMatrix * viewMatrix * wp;
|
|
142
|
+
}
|
|
143
|
+
`;
|
|
144
|
+
|
|
145
|
+
const GRID_FRAG = /* glsl */ `
|
|
146
|
+
precision highp float;
|
|
147
|
+
varying vec2 vWorld;
|
|
148
|
+
uniform vec2 uShift; // parallax drift with the rail position, px
|
|
149
|
+
uniform vec3 uColor;
|
|
150
|
+
uniform float uOpacity;
|
|
151
|
+
uniform float uSpacing;
|
|
152
|
+
|
|
153
|
+
void main() {
|
|
154
|
+
vec2 cell = (vWorld + uShift) / uSpacing;
|
|
155
|
+
vec2 grad = abs(fract(cell - 0.5) - 0.5) / max(fwidth(cell), 1e-5);
|
|
156
|
+
float line = 1.0 - min(min(grad.x, grad.y), 1.0);
|
|
157
|
+
float depth = smoothstep(-5200.0, -400.0, vWorld.y); // melt into the horizon
|
|
158
|
+
float side = 1.0 - smoothstep(1300.0, 3600.0, abs(vWorld.x));
|
|
159
|
+
gl_FragColor = vec4(uColor, line * uOpacity * depth * side);
|
|
160
|
+
}
|
|
161
|
+
`;
|
|
162
|
+
|
|
163
|
+
/** Floor geometry constants — near edge slightly in front of the cards, far
|
|
164
|
+
edge past the horizon; the stage bottom sits where the z=0 plane ends. */
|
|
165
|
+
const GRID_SPAN = 7200;
|
|
166
|
+
const GRID_DEPTH = 6000;
|
|
167
|
+
const GRID_NEAR_Z = 520;
|
|
168
|
+
|
|
169
|
+
export function glRail(target: TargetLike, options: GlRailOptions = {}): Destroy {
|
|
170
|
+
initGSAP();
|
|
171
|
+
|
|
172
|
+
const track = one<HTMLElement>(target);
|
|
173
|
+
if (!track) return () => {};
|
|
174
|
+
|
|
175
|
+
const {
|
|
176
|
+
viewport,
|
|
177
|
+
card = ":scope > *",
|
|
178
|
+
axis = "auto",
|
|
179
|
+
radius = 1200,
|
|
180
|
+
corner = 16,
|
|
181
|
+
grid = true,
|
|
182
|
+
dpr = 2,
|
|
183
|
+
lerp,
|
|
184
|
+
edge,
|
|
185
|
+
throwScale,
|
|
186
|
+
wheel,
|
|
187
|
+
onTick,
|
|
188
|
+
} = options;
|
|
189
|
+
|
|
190
|
+
return guard(options, () => {
|
|
191
|
+
const vp = one<HTMLElement>(viewport) ?? track.parentElement ?? track;
|
|
192
|
+
|
|
193
|
+
/* ---------------- physics first: the rail works with or without a GPU ---------------- */
|
|
194
|
+
|
|
195
|
+
let lastPos = 0;
|
|
196
|
+
const stopPhysics = dragRail(track, {
|
|
197
|
+
viewport: vp,
|
|
198
|
+
axis,
|
|
199
|
+
lerp,
|
|
200
|
+
edge,
|
|
201
|
+
throwScale,
|
|
202
|
+
wheel,
|
|
203
|
+
// The outer guard already decided about reduced motion for both of us.
|
|
204
|
+
force: true,
|
|
205
|
+
onTick: (pos, velocity) => {
|
|
206
|
+
lastPos = pos;
|
|
207
|
+
onTick?.(pos, velocity);
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
/* ---------------- availability ladder (silent, no probes) ---------------- */
|
|
212
|
+
|
|
213
|
+
if (typeof window === "undefined" || !("WebGLRenderingContext" in window)) return stopPhysics;
|
|
214
|
+
|
|
215
|
+
let renderer: THREE.WebGLRenderer;
|
|
216
|
+
try {
|
|
217
|
+
renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
|
|
218
|
+
} catch {
|
|
219
|
+
return stopPhysics; // context refused — the flat DOM rail is the fallback
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/* ---------------- stage + camera ---------------- */
|
|
223
|
+
|
|
224
|
+
const prevPosition = vp.style.position;
|
|
225
|
+
if (!prevPosition && getComputedStyle(vp).position === "static") {
|
|
226
|
+
vp.style.position = "relative";
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, dpr));
|
|
230
|
+
const scene = new THREE.Scene();
|
|
231
|
+
const camera = new THREE.PerspectiveCamera(45, 1, 10, 14000);
|
|
232
|
+
|
|
233
|
+
// Pixel-space at z = 0: the z=0 plane exactly fills the stage.
|
|
234
|
+
const fitCamera = (w: number, h: number) => {
|
|
235
|
+
camera.aspect = w / h;
|
|
236
|
+
camera.position.set(0, 0, h / 2 / Math.tan((camera.fov * Math.PI) / 360));
|
|
237
|
+
camera.updateProjectionMatrix();
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
/* ---------------- grid floor ---------------- */
|
|
241
|
+
|
|
242
|
+
let gridMesh: THREE.Mesh | null = null;
|
|
243
|
+
let gridShift: THREE.Vector2 | null = null;
|
|
244
|
+
if (grid) {
|
|
245
|
+
gridShift = new THREE.Vector2();
|
|
246
|
+
const material = new THREE.ShaderMaterial({
|
|
247
|
+
vertexShader: GRID_VERT,
|
|
248
|
+
fragmentShader: GRID_FRAG,
|
|
249
|
+
uniforms: {
|
|
250
|
+
uShift: { value: gridShift },
|
|
251
|
+
uColor: { value: new THREE.Color(0.66, 0.7, 0.78) },
|
|
252
|
+
uOpacity: { value: 0.24 },
|
|
253
|
+
uSpacing: { value: 150 },
|
|
254
|
+
},
|
|
255
|
+
transparent: true,
|
|
256
|
+
depthWrite: false,
|
|
257
|
+
depthTest: false,
|
|
258
|
+
});
|
|
259
|
+
gridMesh = new THREE.Mesh(new THREE.PlaneGeometry(1, 1), material);
|
|
260
|
+
gridMesh.rotation.x = -Math.PI / 2;
|
|
261
|
+
gridMesh.renderOrder = -1; // floor first, cards over it
|
|
262
|
+
scene.add(gridMesh);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/* ---------------- cards ---------------- */
|
|
266
|
+
|
|
267
|
+
type Entry = {
|
|
268
|
+
el: HTMLElement;
|
|
269
|
+
img: HTMLImageElement;
|
|
270
|
+
mesh: THREE.Mesh;
|
|
271
|
+
material: THREE.ShaderMaterial;
|
|
272
|
+
prevOpacity: string;
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
const geometry = new THREE.PlaneGeometry(1, 1, 24, 12);
|
|
276
|
+
const entries: Entry[] = [];
|
|
277
|
+
const loader = new THREE.TextureLoader();
|
|
278
|
+
let disposed = false;
|
|
279
|
+
|
|
280
|
+
for (const el of toArray<HTMLElement>(card, track)) {
|
|
281
|
+
const img = el.querySelector("img") as HTMLImageElement | null;
|
|
282
|
+
const src = img?.currentSrc || img?.src;
|
|
283
|
+
if (!img || !src) continue; // label-only card — nothing to paint
|
|
284
|
+
|
|
285
|
+
const uniforms = {
|
|
286
|
+
uTex: { value: null as THREE.Texture | null },
|
|
287
|
+
uRes: { value: new THREE.Vector2(1, 1) },
|
|
288
|
+
uTexSize: { value: new THREE.Vector2(1, 1) },
|
|
289
|
+
uCorner: { value: corner },
|
|
290
|
+
uSize: { value: new THREE.Vector2(1, 1) },
|
|
291
|
+
uOffset: { value: 0 },
|
|
292
|
+
uRadius: { value: radius },
|
|
293
|
+
uAxis: { value: 0 },
|
|
294
|
+
};
|
|
295
|
+
const material = new THREE.ShaderMaterial({
|
|
296
|
+
vertexShader: CARD_VERT,
|
|
297
|
+
fragmentShader: CARD_FRAG,
|
|
298
|
+
uniforms,
|
|
299
|
+
transparent: true,
|
|
300
|
+
depthWrite: false,
|
|
301
|
+
depthTest: false,
|
|
302
|
+
});
|
|
303
|
+
// Positions come from the shader (uOffset/uSize), not the 1×1 geometry —
|
|
304
|
+
// the default bounding sphere would cull every card off-screen.
|
|
305
|
+
const mesh = new THREE.Mesh(geometry, material);
|
|
306
|
+
mesh.frustumCulled = false;
|
|
307
|
+
mesh.visible = false;
|
|
308
|
+
scene.add(mesh);
|
|
309
|
+
|
|
310
|
+
const prevOpacity = img.style.opacity;
|
|
311
|
+
entries.push({ el, img, mesh, material, prevOpacity });
|
|
312
|
+
|
|
313
|
+
loader.load(
|
|
314
|
+
src,
|
|
315
|
+
(texture) => {
|
|
316
|
+
if (disposed) {
|
|
317
|
+
texture.dispose();
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
texture.minFilter = THREE.LinearFilter;
|
|
321
|
+
texture.magFilter = THREE.LinearFilter;
|
|
322
|
+
// No sRGB decode: the raw shader passes sampled values through, so
|
|
323
|
+
// the texture stays an ordinary RGBA8 upload (same contract as
|
|
324
|
+
// webglMedia — built-in colourSpace conversion only happens inside
|
|
325
|
+
// three's own material chunks).
|
|
326
|
+
uniforms.uTex.value = texture;
|
|
327
|
+
uniforms.uTexSize.value.set(texture.image?.width || 1, texture.image?.height || 1);
|
|
328
|
+
mesh.visible = true;
|
|
329
|
+
img.style.opacity = "0"; // the canvas owns these pixels from here
|
|
330
|
+
},
|
|
331
|
+
undefined,
|
|
332
|
+
() => {
|
|
333
|
+
/* 404 / decode failure — this card's <img> was never hidden. */
|
|
334
|
+
},
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
if (!entries.length) {
|
|
339
|
+
// Nothing renderable — plain dragRail is the whole effect.
|
|
340
|
+
geometry.dispose();
|
|
341
|
+
gridMesh?.geometry.dispose();
|
|
342
|
+
(gridMesh?.material as THREE.Material | undefined)?.dispose();
|
|
343
|
+
renderer.dispose();
|
|
344
|
+
return stopPhysics;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/* ---------------- canvas: under the track, over the stage ---------------- */
|
|
348
|
+
|
|
349
|
+
const canvas = renderer.domElement;
|
|
350
|
+
canvas.style.cssText =
|
|
351
|
+
"position:absolute;inset:0;width:100%;height:100%;display:block;pointer-events:none;";
|
|
352
|
+
vp.insertBefore(canvas, track);
|
|
353
|
+
vp.dataset.akGlRail = "true";
|
|
354
|
+
|
|
355
|
+
/* ---------------- sizing ---------------- */
|
|
356
|
+
|
|
357
|
+
const resize = () => {
|
|
358
|
+
const w = Math.max(1, vp.clientWidth);
|
|
359
|
+
const h = Math.max(1, vp.clientHeight);
|
|
360
|
+
renderer.setSize(w, h, false);
|
|
361
|
+
fitCamera(w, h);
|
|
362
|
+
if (gridMesh) {
|
|
363
|
+
gridMesh.scale.set(GRID_SPAN, GRID_DEPTH, 1);
|
|
364
|
+
// Local +y maps to world −z, so centre the plane to span
|
|
365
|
+
// [GRID_NEAR_Z − GRID_DEPTH, GRID_NEAR_Z] in z.
|
|
366
|
+
gridMesh.position.set(0, -h / 2 + 2, GRID_NEAR_Z - GRID_DEPTH / 2);
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
resize();
|
|
370
|
+
|
|
371
|
+
let ro: ResizeObserver | null = null;
|
|
372
|
+
if (typeof ResizeObserver === "function") {
|
|
373
|
+
ro = new ResizeObserver(resize);
|
|
374
|
+
ro.observe(vp);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/* ---------------- loop ---------------- */
|
|
378
|
+
|
|
379
|
+
let visible = true;
|
|
380
|
+
let io: IntersectionObserver | null = null;
|
|
381
|
+
if (typeof IntersectionObserver === "function") {
|
|
382
|
+
io = new IntersectionObserver(([entry]) => (visible = entry.isIntersecting), {
|
|
383
|
+
rootMargin: "120px",
|
|
384
|
+
});
|
|
385
|
+
io.observe(vp);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const sync = () => {
|
|
389
|
+
const stage = vp.getBoundingClientRect();
|
|
390
|
+
const cx = stage.left + stage.width / 2;
|
|
391
|
+
const cy = stage.top + stage.height / 2;
|
|
392
|
+
const vertical = resolveRailAxis(track, vp, axis) === "y";
|
|
393
|
+
for (const entry of entries) {
|
|
394
|
+
const rect = entry.el.getBoundingClientRect();
|
|
395
|
+
const u = entry.material.uniforms;
|
|
396
|
+
u.uOffset.value = vertical
|
|
397
|
+
? cy - (rect.top + rect.height / 2)
|
|
398
|
+
: rect.left + rect.width / 2 - cx;
|
|
399
|
+
u.uSize.value.set(rect.width, rect.height);
|
|
400
|
+
u.uRes.value.set(rect.width, rect.height);
|
|
401
|
+
u.uAxis.value = vertical ? 1 : 0;
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
const render = () => {
|
|
406
|
+
if (!visible) return;
|
|
407
|
+
sync();
|
|
408
|
+
gridShift?.set(lastPos * 0.1, 0); // subtle parallax: the floor trails the rail
|
|
409
|
+
renderer.render(scene, camera);
|
|
410
|
+
};
|
|
411
|
+
gsap.ticker.add(render);
|
|
412
|
+
|
|
413
|
+
/* ---------------- teardown ---------------- */
|
|
414
|
+
|
|
415
|
+
return () => {
|
|
416
|
+
disposed = true;
|
|
417
|
+
stopPhysics();
|
|
418
|
+
gsap.ticker.remove(render);
|
|
419
|
+
io?.disconnect();
|
|
420
|
+
ro?.disconnect();
|
|
421
|
+
for (const entry of entries) {
|
|
422
|
+
(entry.material.uniforms.uTex.value as THREE.Texture | null)?.dispose();
|
|
423
|
+
entry.material.dispose();
|
|
424
|
+
entry.img.style.opacity = entry.prevOpacity;
|
|
425
|
+
}
|
|
426
|
+
geometry.dispose();
|
|
427
|
+
if (gridMesh) {
|
|
428
|
+
gridMesh.geometry.dispose();
|
|
429
|
+
(gridMesh.material as THREE.Material).dispose();
|
|
430
|
+
}
|
|
431
|
+
renderer.dispose();
|
|
432
|
+
canvas.remove();
|
|
433
|
+
delete vp.dataset.akGlRail;
|
|
434
|
+
vp.style.position = prevPosition;
|
|
435
|
+
};
|
|
436
|
+
});
|
|
437
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@cosmictraveler002/anim-kit/three` — the WebGL entry.
|
|
3
|
+
*
|
|
4
|
+
* Everything here renders media through **three.js**, which is an *optional
|
|
5
|
+
* peer dependency*: install `three` alongside anim-kit to use this subpath.
|
|
6
|
+
* The core barrel (`@cosmictraveler002/anim-kit`) never imports three, so
|
|
7
|
+
* plain DOM/scroll users keep a gsap+lenis-only dependency tree.
|
|
8
|
+
*
|
|
9
|
+
* import { webglMedia, glRail } from "@cosmictraveler002/anim-kit/three";
|
|
10
|
+
*/
|
|
11
|
+
export { webglMedia } from "./webgl-media.js";
|
|
12
|
+
export type { WebglMediaOptions } from "./webgl-media.js";
|
|
13
|
+
export { glRail } from "./gl-rail.js";
|
|
14
|
+
export type { GlRailOptions } from "./gl-rail.js";
|