@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.
Files changed (44) hide show
  1. package/README.md +275 -31
  2. package/dist/anim-kit.standalone.js +5 -5
  3. package/dist/anim-kit.standalone.js.map +1 -1
  4. package/dist/core/gsap.d.ts +1 -1
  5. package/dist/core/gsap.d.ts.map +1 -1
  6. package/dist/core/gsap.js +1 -1
  7. package/dist/core/gsap.js.map +1 -1
  8. package/dist/core/smooth-scroll.d.ts +1 -1
  9. package/dist/core/smooth-scroll.js +1 -1
  10. package/dist/effects/drag-rail.d.ts +31 -0
  11. package/dist/effects/drag-rail.d.ts.map +1 -0
  12. package/dist/effects/drag-rail.js +235 -0
  13. package/dist/effects/drag-rail.js.map +1 -0
  14. package/dist/effects/preloader.js +1 -1
  15. package/dist/effects/reel-text.d.ts +19 -0
  16. package/dist/effects/reel-text.d.ts.map +1 -0
  17. package/dist/effects/reel-text.js +163 -0
  18. package/dist/effects/reel-text.js.map +1 -0
  19. package/dist/index.d.ts +7 -1
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +5 -1
  22. package/dist/index.js.map +1 -1
  23. package/dist/three/gl-rail.d.ts +29 -0
  24. package/dist/three/gl-rail.d.ts.map +1 -0
  25. package/dist/three/gl-rail.js +350 -0
  26. package/dist/three/gl-rail.js.map +1 -0
  27. package/dist/three/index.d.ts +15 -0
  28. package/dist/three/index.d.ts.map +1 -0
  29. package/dist/three/index.js +13 -0
  30. package/dist/three/index.js.map +1 -0
  31. package/dist/three/webgl-media.d.ts +21 -0
  32. package/dist/three/webgl-media.d.ts.map +1 -0
  33. package/dist/three/webgl-media.js +268 -0
  34. package/dist/three/webgl-media.js.map +1 -0
  35. package/package.json +17 -1
  36. package/src/core/gsap.ts +1 -1
  37. package/src/core/smooth-scroll.ts +1 -1
  38. package/src/effects/drag-rail.ts +285 -0
  39. package/src/effects/preloader.ts +1 -1
  40. package/src/effects/reel-text.ts +212 -0
  41. package/src/index.ts +9 -1
  42. package/src/three/gl-rail.ts +437 -0
  43. package/src/three/index.ts +14 -0
  44. package/src/three/webgl-media.ts +325 -0
@@ -0,0 +1,268 @@
1
+ /**
2
+ * WebGL media — the shader card: a DOM `<img>` re-rendered as a rounded,
3
+ * hover-reactive GL plane.
4
+ *
5
+ * The image keeps owning layout and alt text; the effect paints it onto a
6
+ * canvas that covers the wrapper, so the card can do what CSS can't: press a
7
+ * *dent* into the picture on hover (sample squeeze + fake dome lighting),
8
+ * split its channels (chroma), and wipe in on reveal — all inside one
9
+ * fragment shader. This is the media-card pattern from three.js portfolios,
10
+ * kept dependency-honest:
11
+ *
12
+ * - **Optional peer.** Lives behind the `@cosmictraveler002/anim-kit/three`
13
+ * subpath; the core barrel stays gsap+lenis only. `three` is an optional
14
+ * peer — install it to use this entry, the core never imports it.
15
+ * - **Silent no-op ladder.** Missing target → no-op. No
16
+ * `WebGLRenderingContext` (jsdom, SSR, WebGL-disabled browsers) → no-op
17
+ * *before* a context is ever requested (a probe call would itself log).
18
+ * Renderer construction throws → no-op. Texture 404s → the plain `<img>`
19
+ * stays visible. Under `prefers-reduced-motion` the canvas never mounts.
20
+ * - **Cover crop in-shader.** The texture is sampled with `object-fit: cover`
21
+ * maths (`scale = uRes / uTexSize`, windowed around the centre), so any
22
+ * source aspect fills the card without letterboxing.
23
+ * - **Rounded corners are shader-true.** A rounded-box SDF with a 1px smooth
24
+ * edge — not `border-radius` on the canvas — so the dent/chroma never bleed
25
+ * into the corner.
26
+ * - **Fidelity.** `destroy()` kills ticker + tweens + observers/listeners,
27
+ * disposes geometry/material/texture/renderer, removes the canvas and
28
+ * restores the `<img>` opacity and wrapper position it changed.
29
+ *
30
+ * webglMedia("[data-gl]", { corner: 18, dent: 70, chroma: 2 })
31
+ *
32
+ * Markup: a wrapper element containing `<img>` (or pass `src` explicitly):
33
+ *
34
+ * <figure class="gl-card" data-gl><img src="photo.jpg" alt="" /></figure>
35
+ */
36
+ import * as THREE from "three";
37
+ import { gsap, initGSAP } from "../core/gsap.js";
38
+ import { guard } from "../core/guard.js";
39
+ import { one } from "../core/util.js";
40
+ const VERT = /* glsl */ `
41
+ varying vec2 vUv;
42
+ uniform float uHover;
43
+ uniform float uDent;
44
+ uniform float uTime;
45
+
46
+ void main() {
47
+ vUv = uv;
48
+ vec3 p = position;
49
+ float dome = max(cos(uv.x * 3.14159265) * cos(uv.y * 3.14159265), 0.0);
50
+ dome *= 0.94 + 0.06 * sin(uTime * 1.6);
51
+ // Pressed-in curvature while hovered — the fragment does the visible work.
52
+ p.z -= uHover * uDent * dome;
53
+ gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
54
+ }
55
+ `;
56
+ const FRAG = /* glsl */ `
57
+ precision highp float;
58
+ varying vec2 vUv;
59
+ uniform sampler2D uTex;
60
+ uniform vec2 uRes;
61
+ uniform vec2 uTexSize;
62
+ uniform float uCorner;
63
+ uniform float uHover;
64
+ uniform float uDent;
65
+ uniform float uChroma;
66
+ uniform float uProgress;
67
+ uniform float uTime;
68
+
69
+ float sdRoundBox(vec2 p, vec2 b, float r) {
70
+ vec2 q = abs(p) - b + r;
71
+ return min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - r;
72
+ }
73
+
74
+ void main() {
75
+ vec2 px = (vUv - 0.5) * uRes;
76
+ float d = sdRoundBox(px, uRes * 0.5, uCorner);
77
+ float alpha = 1.0 - smoothstep(-1.0, 1.0, d); // 1px-ish AA on the corner
78
+
79
+ // Entrance wipe: grows from the bottom with a soft leading edge.
80
+ if (uProgress < 1.0) {
81
+ alpha *= 1.0 - smoothstep(uProgress - 0.05, uProgress, vUv.y);
82
+ }
83
+ if (alpha < 0.003) discard;
84
+
85
+ // object-fit: cover — window the texture around its centre.
86
+ vec2 scale = uRes / max(uTexSize, vec2(1.0));
87
+ vec2 cover = (vUv - 0.5) * (scale / max(scale.x, scale.y)) + 0.5;
88
+
89
+ // Hover dent: squeeze the sample toward the centre (breathing slightly).
90
+ vec2 c = cover - 0.5;
91
+ float dome = max(cos(vUv.x * 3.14159265) * cos(vUv.y * 3.14159265), 0.0);
92
+ dome *= 0.94 + 0.06 * sin(uTime * 1.6);
93
+ vec2 suv = 0.5 + c * (1.0 - uHover * uDent * 0.0016 * dome);
94
+
95
+ float chroma = uChroma * uHover * dome / max(uRes.x, 1.0);
96
+ vec3 col;
97
+ col.r = texture2D(uTex, suv + vec2(chroma, 0.0)).r;
98
+ col.g = texture2D(uTex, suv).g;
99
+ col.b = texture2D(uTex, suv - vec2(chroma, 0.0)).b;
100
+
101
+ // Fake lighting off the dome slope: sheen at the crest, shade at the rim.
102
+ float light = dome * uHover;
103
+ col += light * 0.12;
104
+ col *= 1.0 - 0.12 * uHover * (1.0 - dome);
105
+
106
+ gl_FragColor = vec4(col, alpha);
107
+ }
108
+ `;
109
+ export function webglMedia(target, options = {}) {
110
+ initGSAP();
111
+ const host = one(target);
112
+ if (!host)
113
+ return () => { };
114
+ const { src, corner = 16, dent = 70, chroma = 2, reveal = true, revealDuration = 1.1, hoverDuration = 0.6, dpr = 2, } = options;
115
+ return guard(options, () => {
116
+ /* ---------------- availability ladder (silent, no probes) ---------------- */
117
+ if (typeof window === "undefined" || !("WebGLRenderingContext" in window))
118
+ return () => { };
119
+ let renderer;
120
+ try {
121
+ renderer = new THREE.WebGLRenderer({ alpha: true, antialias: true });
122
+ }
123
+ catch {
124
+ return () => { }; // context refused (disabled/blacklisted) — plain img stays
125
+ }
126
+ const img = host.tagName === "IMG" ? null : host.querySelector("img");
127
+ const textureSrc = src || img?.currentSrc || img?.src;
128
+ if (!textureSrc) {
129
+ renderer.dispose();
130
+ return () => { };
131
+ }
132
+ /* ---------------- layout + styles we must restore ---------------- */
133
+ const prevPosition = host.style.position;
134
+ if (!prevPosition && getComputedStyle(host).position === "static") {
135
+ host.style.position = "relative";
136
+ }
137
+ const prevImgOpacity = img?.style.opacity ?? "";
138
+ host.dataset.akGl = "true";
139
+ const w = Math.max(1, host.clientWidth);
140
+ const h = Math.max(1, host.clientHeight);
141
+ renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, dpr));
142
+ renderer.setSize(w, h, false);
143
+ const canvas = renderer.domElement;
144
+ canvas.style.cssText =
145
+ "position:absolute;inset:0;width:100%;height:100%;display:block;pointer-events:none;";
146
+ const scene = new THREE.Scene();
147
+ const camera = new THREE.OrthographicCamera(-w / 2, w / 2, h / 2, -h / 2, -100, 100);
148
+ camera.position.z = 10;
149
+ const geometry = new THREE.PlaneGeometry(1, 1, 1, 1);
150
+ const uniforms = {
151
+ uTex: { value: null },
152
+ uRes: { value: new THREE.Vector2(w, h) },
153
+ uTexSize: { value: new THREE.Vector2(1, 1) },
154
+ uCorner: { value: corner },
155
+ uHover: { value: 0 },
156
+ uDent: { value: dent },
157
+ uChroma: { value: chroma },
158
+ uProgress: { value: reveal ? 0 : 1 },
159
+ uTime: { value: 0 },
160
+ };
161
+ const material = new THREE.ShaderMaterial({
162
+ vertexShader: VERT,
163
+ fragmentShader: FRAG,
164
+ uniforms,
165
+ transparent: true,
166
+ });
167
+ const mesh = new THREE.Mesh(geometry, material);
168
+ mesh.scale.set(w, h, 1);
169
+ mesh.visible = false;
170
+ scene.add(mesh);
171
+ host.appendChild(canvas);
172
+ /* ---------------- texture ---------------- */
173
+ let disposed = false;
174
+ const loader = new THREE.TextureLoader();
175
+ let revealTween = null;
176
+ loader.load(textureSrc, (texture) => {
177
+ if (disposed) {
178
+ texture.dispose();
179
+ return;
180
+ }
181
+ texture.minFilter = THREE.LinearFilter;
182
+ texture.magFilter = THREE.LinearFilter;
183
+ // No sRGB decode: the raw shader passes sampled values through, so the
184
+ // texture must stay an ordinary RGBA8 upload (built-in colorSpace
185
+ // conversion only happens inside three's own material chunks).
186
+ uniforms.uTex.value = texture;
187
+ uniforms.uTexSize.value.set(texture.image.width || 1, texture.image.height || 1);
188
+ mesh.visible = true;
189
+ if (img)
190
+ img.style.opacity = "0"; // canvas owns the pixels from here
191
+ if (reveal) {
192
+ revealTween = gsap.fromTo(uniforms.uProgress, { value: 0 }, { value: 1, duration: revealDuration, ease: "power2.inOut" });
193
+ }
194
+ }, undefined, () => {
195
+ /* 404 / decode failure — the plain <img> was never hidden. */
196
+ });
197
+ /* ---------------- hover ---------------- */
198
+ let hoverTween = null;
199
+ const hoverTo = (value) => {
200
+ hoverTween = gsap.to(uniforms.uHover, {
201
+ value,
202
+ duration: hoverDuration,
203
+ ease: "power3.out",
204
+ overwrite: "auto",
205
+ });
206
+ };
207
+ const onEnter = () => hoverTo(1);
208
+ const onLeave = () => hoverTo(0);
209
+ host.addEventListener("pointerenter", onEnter);
210
+ host.addEventListener("pointerleave", onLeave);
211
+ /* ---------------- loop / observers ---------------- */
212
+ let visible = true;
213
+ let io = null;
214
+ if (typeof IntersectionObserver === "function") {
215
+ io = new IntersectionObserver(([entry]) => (visible = entry.isIntersecting), {
216
+ rootMargin: "120px",
217
+ });
218
+ io.observe(host);
219
+ }
220
+ let ro = null;
221
+ const resize = () => {
222
+ const rw = Math.max(1, host.clientWidth);
223
+ const rh = Math.max(1, host.clientHeight);
224
+ renderer.setSize(rw, rh, false);
225
+ camera.left = -rw / 2;
226
+ camera.right = rw / 2;
227
+ camera.top = rh / 2;
228
+ camera.bottom = -rh / 2;
229
+ camera.updateProjectionMatrix();
230
+ mesh.scale.set(rw, rh, 1);
231
+ uniforms.uRes.value.set(rw, rh);
232
+ };
233
+ if (typeof ResizeObserver === "function") {
234
+ ro = new ResizeObserver(resize);
235
+ ro.observe(host);
236
+ }
237
+ const render = () => {
238
+ if (!visible || !mesh.visible)
239
+ return;
240
+ uniforms.uTime.value = gsap.ticker.time;
241
+ renderer.render(scene, camera);
242
+ };
243
+ gsap.ticker.add(render);
244
+ /* ---------------- teardown ---------------- */
245
+ return () => {
246
+ disposed = true;
247
+ gsap.ticker.remove(render);
248
+ hoverTween?.kill();
249
+ revealTween?.kill();
250
+ gsap.killTweensOf(uniforms.uHover);
251
+ gsap.killTweensOf(uniforms.uProgress);
252
+ host.removeEventListener("pointerenter", onEnter);
253
+ host.removeEventListener("pointerleave", onLeave);
254
+ io?.disconnect();
255
+ ro?.disconnect();
256
+ uniforms.uTex.value?.dispose();
257
+ geometry.dispose();
258
+ material.dispose();
259
+ renderer.dispose();
260
+ canvas.remove();
261
+ delete host.dataset.akGl;
262
+ host.style.position = prevPosition;
263
+ if (img)
264
+ img.style.opacity = prevImgOpacity;
265
+ };
266
+ });
267
+ }
268
+ //# sourceMappingURL=webgl-media.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"webgl-media.js","sourceRoot":"","sources":["../../src/three/webgl-media.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmictraveler002/anim-kit",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Modular, framework-agnostic animation library — GSAP + ScrollTrigger + Lenis effects packaged as composable ES modules.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -17,6 +17,11 @@
17
17
  "import": "./dist/anim-kit.standalone.js",
18
18
  "default": "./dist/anim-kit.standalone.js"
19
19
  },
20
+ "./three": {
21
+ "types": "./dist/three/index.d.ts",
22
+ "import": "./dist/three/index.js",
23
+ "default": "./dist/three/index.js"
24
+ },
20
25
  "./effects/*": {
21
26
  "types": "./dist/effects/*.d.ts",
22
27
  "import": "./dist/effects/*.js",
@@ -42,6 +47,7 @@
42
47
  "typecheck": "tsc --noEmit -p tsconfig.json",
43
48
  "dev": "tsc -p tsconfig.json --watch",
44
49
  "demo": "node scripts/serve.mjs",
50
+ "sync:live": "node scripts/sync-demo-live.mjs",
45
51
  "test": "npm run build && node scripts/smoke.mjs && node scripts/demo-smoke.mjs",
46
52
  "smoke": "node scripts/smoke.mjs && node scripts/demo-smoke.mjs",
47
53
  "prepublishOnly": "npm run build && npm run smoke"
@@ -75,8 +81,18 @@
75
81
  "gsap": "^3.13.0",
76
82
  "lenis": "^1.3.11"
77
83
  },
84
+ "peerDependencies": {
85
+ "three": ">=0.160.0"
86
+ },
87
+ "peerDependenciesMeta": {
88
+ "three": {
89
+ "optional": true
90
+ }
91
+ },
78
92
  "devDependencies": {
93
+ "@types/three": "^0.186.0",
79
94
  "jsdom": "^25.0.1",
95
+ "three": "^0.186.1",
80
96
  "tsup": "^8.5.1",
81
97
  "typescript": "^5.7.3"
82
98
  }
package/src/core/gsap.ts CHANGED
@@ -16,7 +16,7 @@ import type { TargetLike } from "./types.js";
16
16
 
17
17
  export { gsap, ScrollTrigger, SplitText, Draggable, CustomEase, ScrollSmoother, Flip };
18
18
 
19
- /** Named eases recovered from dzinrstudio.com's bundles. */
19
+ /** Named eases from kalakritico.in's bundles. */
20
20
  export const EASES = {
21
21
  /** `.76,0,.2,1` — the studio's signature menu / nav curtain ease. */
22
22
  curtain: "ak-curtain",
@@ -10,7 +10,7 @@
10
10
  * clocks on the same frame, which is what stops ScrollTrigger from stuttering.
11
11
  *
12
12
  * `useScrollerProxy: true` additionally proxies `document.documentElement`,
13
- * matching dzinrstudio.com's setup — only needed if you scroll a nested
13
+ * matching kalakritico.in's setup — only needed if you scroll a nested
14
14
  * element instead of the window.
15
15
  */
16
16
  import Lenis from "lenis";
@@ -0,0 +1,285 @@
1
+ /**
2
+ * Drag rail — the bounded, inertia-driven rail (horizontal or vertical).
3
+ *
4
+ * A grab-and-throw rail with real physics, extracted from the drag-driven
5
+ * portfolio rail pattern: pointer drag, wheel and trackpad all feed one
6
+ * *intent* value, and the track follows it with a per-frame lerp. Why it is
7
+ * shaped this way (audit notes):
8
+ *
9
+ * - **Two values, not one.** `intent` is where the rail wants to be (drag
10
+ * delta, wheel delta, throw momentum); `pos` is what is rendered. The tick
11
+ * lerps intent → pos (`lerp * deltaRatio`), which is what gives the
12
+ * buttery catch-up after a hard throw. Writing the transform directly from
13
+ * pointermove (v1) felt like dragging a brick.
14
+ * - **tanh rubber-band.** Past either end, intent is squashed through
15
+ * `edge * tanh(overshoot / edge)` — the further you pull, the less each
16
+ * pixel buys you, but it never hard-stops. On release a restore force pulls
17
+ * the overscrolled intent back inside the bounds while the lerp chases it,
18
+ * producing the settle. This is the same soft-edge maths as the original
19
+ * rail's `a.t = a.a + tanh(l / t) * t`.
20
+ * - **Release throw.** Velocity is sampled as a smoothed per-frame pointer
21
+ * delta (0.7 smoothing) and multiplied by `throwScale` into intent — a flick
22
+ * coasts, a slow release barely moves. No InertiaPlugin dependency; the
23
+ * ticker *is* the inertia.
24
+ * - **Wheel coexistence.** While the rail can still move in the wheel's
25
+ * direction the event is consumed (`preventDefault` + `stopPropagation` —
26
+ * the stop matters: Lenis listens above us and would scroll the page in
27
+ * parallel). Once overscrolled a full `edge`, the wheel passes through to
28
+ * the page again, so the section never traps the reader.
29
+ * - **Velocity-reactive items.** `tilt` rotates children proportionally to the
30
+ * rendered per-frame velocity (the cards lean into motion and spring flat at
31
+ * rest); `onTick` exposes pos/velocity for WebGL or custom consumers.
32
+ * - **Two axes.** `axis: "x"` (default), `"y"`, or `"auto"` — under `auto`
33
+ * the *layout* decides via `resolveRailAxis()` (only the column overflows →
34
+ * vertical, otherwise horizontal; the same rule glRail uses to pick its
35
+ * bend axis). touch-action claims the rail's own gesture axis (`pan-y` for
36
+ * a horizontal rail, `pan-x` for a vertical one) so the page never fights
37
+ * the rail for a swipe, and a mid-life flip re-keys the transform instead
38
+ * of leaving the stale axis behind.
39
+ * - **Fidelity.** `destroy()` removes every listener + the ticker, kills the
40
+ * tilt state and restores the inline cursor/user-select/touch-action the
41
+ * effect had overwritten.
42
+ *
43
+ * dragRail(".ak-rail-track", { tilt: 0.05, axis: "auto" })
44
+ *
45
+ * CSS: viewport `overflow: hidden`; track `display: flex; width: max-content;
46
+ * gap: 1rem; cursor: grab; user-select: none; touch-action: pan-y`.
47
+ */
48
+ import { gsap, initGSAP } from "../core/gsap.js";
49
+ import { guard } from "../core/guard.js";
50
+ import { one, toArray } from "../core/util.js";
51
+ import type { CommonOptions, Destroy, TargetLike } from "../core/types.js";
52
+
53
+ export interface DragRailOptions extends CommonOptions {
54
+ /** Scroll viewport around the track — defaults to `track.parentElement`. */
55
+ viewport?: TargetLike;
56
+ /** Motion axis — `"auto"` flips vertical when only the column overflows. @default `"x"` */
57
+ axis?: "x" | "y" | "auto";
58
+ /** Children that tilt with velocity. @default `":scope > *"` */
59
+ item?: string;
60
+ /** Follow speed toward the intent, per 60fps frame (0..1). @default 0.1 */
61
+ lerp?: number;
62
+ /** Rubber-band resistance distance past the ends, px. @default 140 */
63
+ edge?: number;
64
+ /** Momentum multiplier on release (px of coast per px/frame of flick). @default 14 */
65
+ throwScale?: number;
66
+ /** Wheel / trackpad drives the rail (consumed while it can still move). @default true */
67
+ wheel?: boolean;
68
+ /** Degrees of tilt per px/frame of velocity — `0` disables. @default 0 */
69
+ tilt?: number;
70
+ /** Tilt clamp, degrees. @default 8 */
71
+ tiltMax?: number;
72
+ /** `(pos, velocity) => {}` called every rendered frame while mounted (pos along the active axis). */
73
+ onTick?: (pos: number, velocity: number) => void;
74
+ }
75
+
76
+ /**
77
+ * The axis rule shared by `dragRail` and `glRail`: fixed when asked, otherwise
78
+ * the layout decides — a rail only runs vertical when the *column* overflows
79
+ * and the row doesn't (a mobile stack), everything else is horizontal.
80
+ */
81
+ export function resolveRailAxis(
82
+ track: HTMLElement,
83
+ viewport: HTMLElement,
84
+ pref: "x" | "y" | "auto",
85
+ ): "x" | "y" {
86
+ if (pref !== "auto") return pref;
87
+ const overflowY = track.scrollHeight - viewport.clientHeight;
88
+ const overflowX = track.scrollWidth - viewport.clientWidth;
89
+ return overflowY > 0 && overflowX <= 0 ? "y" : "x";
90
+ }
91
+
92
+ export function dragRail(target: TargetLike, options: DragRailOptions = {}): Destroy {
93
+ initGSAP();
94
+
95
+ const track = one<HTMLElement>(target);
96
+ if (!track) return () => {};
97
+
98
+ const {
99
+ viewport,
100
+ axis: axisOpt = "x",
101
+ item = ":scope > *",
102
+ lerp = 0.1,
103
+ edge = 140,
104
+ throwScale = 14,
105
+ wheel = true,
106
+ tilt = 0,
107
+ tiltMax = 8,
108
+ onTick,
109
+ } = options;
110
+
111
+ return guard(options, () => {
112
+ const vp = one<HTMLElement>(viewport) ?? track.parentElement ?? track;
113
+ const prevStyle = {
114
+ cursor: track.style.cursor,
115
+ userSelect: track.style.userSelect,
116
+ touchAction: track.style.touchAction,
117
+ };
118
+
119
+ /* ---------------- bounds ---------------- */
120
+
121
+ let axis: "x" | "y" = resolveRailAxis(track, vp, axisOpt);
122
+ let flipped = false; // axis changed mid-life → force a transform re-key
123
+ let min = 0; // most-negative resting position (content overflows)
124
+ let max = 0; // start of the rail
125
+ let pos = 0; // rendered position
126
+ let intent = 0; // wanted position (drag/wheel/throw feed this)
127
+
128
+ const measure = () => {
129
+ const next = resolveRailAxis(track, vp, axisOpt);
130
+ if (next !== axis) {
131
+ axis = next;
132
+ flipped = true;
133
+ // Claim the rail's own gesture axis; the other one belongs to the page.
134
+ track.style.touchAction = axis === "x" ? "pan-y" : "pan-x";
135
+ }
136
+ const overflow =
137
+ axis === "x"
138
+ ? track.scrollWidth - vp.clientWidth
139
+ : track.scrollHeight - vp.clientHeight;
140
+ min = -Math.max(0, overflow);
141
+ max = 0;
142
+ // Fold an out-of-range position back through the soft edge (no jump).
143
+ intent = soft(intent);
144
+ pos = soft(pos);
145
+ };
146
+
147
+ /** tanh soft-edge: unbounded input, saturating output around [min, max]. */
148
+ function soft(v: number): number {
149
+ if (v > max) return max + edge * Math.tanh((v - max) / edge);
150
+ if (v < min) return min + edge * Math.tanh((v - min) / edge);
151
+ return v;
152
+ }
153
+
154
+ /* ---------------- pointer drag ---------------- */
155
+
156
+ let dragging = false;
157
+ let last = 0; // last pointer coordinate along the active axis
158
+ let velocity = 0; // smoothed pointer delta (px/frame-ish)
159
+ let renderedVel = 0; // rendered per-frame velocity (drives tilt/onTick)
160
+
161
+ const along = (e: { clientX: number; clientY: number }) =>
162
+ axis === "x" ? e.clientX : e.clientY;
163
+
164
+ const onPointerDown = (e: PointerEvent) => {
165
+ if (e.pointerType === "mouse" && e.button !== 0) return;
166
+ dragging = true;
167
+ last = along(e);
168
+ velocity = 0;
169
+ try {
170
+ vp.setPointerCapture(e.pointerId);
171
+ } catch {
172
+ /* capture is best-effort — moves still land while the pointer is down */
173
+ }
174
+ track.style.cursor = "grabbing";
175
+ };
176
+
177
+ const onPointerMove = (e: PointerEvent) => {
178
+ if (!dragging) return;
179
+ const delta = along(e) - last;
180
+ last = along(e);
181
+ velocity = velocity * 0.7 + delta * 0.3; // smooth out event-rate jitter
182
+ intent = soft(intent + delta);
183
+ };
184
+
185
+ const endDrag = (e?: PointerEvent) => {
186
+ if (!dragging) return;
187
+ dragging = false;
188
+ track.style.cursor = prevStyle.cursor || "grab";
189
+ if (e) {
190
+ try {
191
+ vp.releasePointerCapture(e.pointerId);
192
+ } catch {
193
+ /* already released */
194
+ }
195
+ }
196
+ // A flick coasts on its sampled momentum, then the soft edge reclaims it.
197
+ intent = soft(intent + velocity * throwScale);
198
+ };
199
+
200
+ const onPointerUp = (e: PointerEvent) => endDrag(e);
201
+ const onPointerCancel = (e: PointerEvent) => endDrag(e);
202
+ const onDragStart = (e: Event) => e.preventDefault(); // no native img drag
203
+
204
+ /* ---------------- wheel / trackpad ---------------- */
205
+
206
+ const onWheel = (e: WheelEvent) => {
207
+ if (!wheel) return;
208
+ const delta =
209
+ axis === "x"
210
+ ? Math.abs(e.deltaX) > Math.abs(e.deltaY)
211
+ ? e.deltaX
212
+ : e.deltaY
213
+ : e.deltaY;
214
+ if (!delta) return;
215
+ const bound = delta > 0 ? max : min;
216
+ const outward = (intent - bound) * (delta > 0 ? 1 : -1);
217
+ if (outward > edge) return; // over-extended — hand the scroll back to the page
218
+ e.preventDefault();
219
+ e.stopPropagation(); // Lenis listens above us; it must not scroll in parallel
220
+ intent = soft(intent + delta);
221
+ };
222
+
223
+ /* ---------------- tick ---------------- */
224
+
225
+ const items = tilt ? toArray<HTMLElement>(item, track) : [];
226
+ if (items.length) gsap.set(items, { transformOrigin: "50% 50%" });
227
+
228
+ const tick = () => {
229
+ const ratio = gsap.ticker.deltaRatio(60); // frames since last tick, clamped
230
+ // Release restore: once input stops, overscrolled intent springs home.
231
+ if (!dragging) {
232
+ const bound = intent < min ? min : intent > max ? max : null;
233
+ if (bound !== null) intent += (bound - intent) * Math.min(1, 0.18 * ratio);
234
+ }
235
+ const prev = pos;
236
+ pos += (intent - pos) * Math.min(1, lerp * ratio);
237
+ renderedVel = pos - prev;
238
+ if (renderedVel === 0 && pos === intent && !flipped) return; // idle: skip the write
239
+ flipped = false;
240
+ // Re-key both axes: a flip must clear the stale one, never leave it behind.
241
+ gsap.set(track, axis === "x" ? { x: pos, y: 0 } : { x: 0, y: pos });
242
+ if (items.length) {
243
+ gsap.set(items, {
244
+ rotation: gsap.utils.clamp(-tiltMax, tiltMax, renderedVel * tilt),
245
+ });
246
+ }
247
+ onTick?.(pos, renderedVel);
248
+ };
249
+
250
+ /* ---------------- wiring ---------------- */
251
+
252
+ measure();
253
+ track.dataset.akRail = "true";
254
+ track.style.cursor = "grab";
255
+ track.style.userSelect = "none";
256
+ track.style.touchAction = axis === "x" ? "pan-y" : "pan-x";
257
+
258
+ vp.addEventListener("pointerdown", onPointerDown);
259
+ vp.addEventListener("pointermove", onPointerMove);
260
+ vp.addEventListener("pointerup", onPointerUp);
261
+ vp.addEventListener("pointercancel", onPointerCancel);
262
+ vp.addEventListener("wheel", onWheel, { passive: false });
263
+ track.addEventListener("dragstart", onDragStart);
264
+ const onResize = () => measure();
265
+ window.addEventListener("resize", onResize);
266
+ gsap.ticker.add(tick);
267
+
268
+ return () => {
269
+ window.removeEventListener("resize", onResize);
270
+ vp.removeEventListener("pointerdown", onPointerDown);
271
+ vp.removeEventListener("pointermove", onPointerMove);
272
+ vp.removeEventListener("pointerup", onPointerUp);
273
+ vp.removeEventListener("pointercancel", onPointerCancel);
274
+ vp.removeEventListener("wheel", onWheel);
275
+ track.removeEventListener("dragstart", onDragStart);
276
+ gsap.ticker.remove(tick);
277
+ delete track.dataset.akRail;
278
+ track.style.cursor = prevStyle.cursor;
279
+ track.style.userSelect = prevStyle.userSelect;
280
+ track.style.touchAction = prevStyle.touchAction;
281
+ gsap.set(track, { clearProps: "transform" });
282
+ if (items.length) gsap.set(items, { clearProps: "rotation,transformOrigin" });
283
+ };
284
+ });
285
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Preloader — the full-screen intro from dzinrstudio.com.
2
+ * Preloader — the full-screen intro from kalakritico.in.
3
3
  *
4
4
  * What it does on the real site:
5
5
  * 1. A big number counts 0 → 100 in the bottom-left corner (steps of 5,