@mehshekari/gsap-motion 4.3.0 → 4.4.2
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 +1 -1
- package/bin/gsap-motion.mjs +7 -0
- package/package.json +1 -1
- package/skill/SKILL.md +8 -8
- package/skill/adapter/next.md +1 -2
- package/skill/adapter/r3f.md +4 -1
- package/skill/adapter/react.md +5 -1
- package/skill/adapter/three.md +2 -0
- package/skill/example/magnetic-button.md +101 -101
- package/skill/preset/cursor.md +184 -184
- package/skill/preset/magnetic.md +155 -155
- package/skill/reference/devices.md +10 -0
- package/skill/reference/flip.md +220 -220
- package/skill/reference/interaction.md +1 -0
- package/skill/reference/performance.md +87 -0
- package/skill/reference/refine.md +54 -0
- package/skill/reference/routing.md +11 -1
- package/skill/reference/scrolltrigger.md +79 -0
- package/skill/scripts/audit-gsap.mjs +4 -0
- package/skill/scripts/capture-motion.mjs +120 -10
- package/skill/scripts/lib/ast.mjs +58 -9
- package/skill/scripts/lib/rules.mjs +134 -5
- package/skill/scripts/review-motion.mjs +167 -8
- package/skill/template/useScrollScene.ts +126 -126
- package/skill/template/useTimeline.ts +110 -110
|
@@ -19,13 +19,15 @@
|
|
|
19
19
|
* for a reviewer looking at the filmstrip; this reports evidence, with the time
|
|
20
20
|
* and the element, and leaves the verdict to them.
|
|
21
21
|
*
|
|
22
|
-
* Node.js 22+, no dependencies, whatever Chrome the machine already has.
|
|
22
|
+
* Node.js 22+, no dependencies, whatever Chrome the machine already has. With
|
|
23
|
+
* `--probe install` and `--probe read` it drives no browser at all, and prints
|
|
24
|
+
* the two functions to run in one you already have.
|
|
23
25
|
*/
|
|
24
|
-
import {
|
|
26
|
+
import { writeFileSync } from "node:fs";
|
|
25
27
|
import { join, resolve } from "node:path";
|
|
26
28
|
import { fileURLToPath } from "node:url";
|
|
27
29
|
|
|
28
|
-
import { connect, findChrome } from "./capture-motion.mjs";
|
|
30
|
+
import { connect, findChrome, gpuNote, prepareOut } from "./capture-motion.mjs";
|
|
29
31
|
|
|
30
32
|
// --- What a recording means ---------------------------------------------------
|
|
31
33
|
|
|
@@ -229,6 +231,33 @@ export function seamEvents(torn, { window = 50 } = {}) {
|
|
|
229
231
|
}));
|
|
230
232
|
}
|
|
231
233
|
|
|
234
|
+
/**
|
|
235
|
+
* The long frames, with the sampler's own time taken out of each.
|
|
236
|
+
*
|
|
237
|
+
* Reading every moving element's box and style is work inside every frame
|
|
238
|
+
* this records, so a long frame is partly the recorder's. `cost` is that work,
|
|
239
|
+
* per frame, as `[at, ms]`; what falls inside a long frame is subtracted, and
|
|
240
|
+
* the rest is the page's own. Null when the browser has no Long Animation
|
|
241
|
+
* Frames API, which is not the same as no long frames.
|
|
242
|
+
*/
|
|
243
|
+
export function longFrameSummary(longFrames, cost = []) {
|
|
244
|
+
if (!longFrames) return null;
|
|
245
|
+
const own = (frame) =>
|
|
246
|
+
cost
|
|
247
|
+
.filter(([at]) => at >= frame.at && at <= frame.at + frame.duration)
|
|
248
|
+
.reduce((sum, [, ms]) => sum + ms, 0);
|
|
249
|
+
const frames = longFrames.map((frame) => ({
|
|
250
|
+
...frame,
|
|
251
|
+
page: Math.max(0, Math.round(frame.duration - own(frame))),
|
|
252
|
+
}));
|
|
253
|
+
const longest = frames.reduce((a, b) => (b.duration > (a?.duration ?? -1) ? b : a), null);
|
|
254
|
+
return {
|
|
255
|
+
count: frames.length,
|
|
256
|
+
longest,
|
|
257
|
+
blocking: frames.reduce((sum, frame) => sum + frame.blocking, 0),
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
232
261
|
/** Each element and the moment it first moved, in that order. */
|
|
233
262
|
export function starts(log) {
|
|
234
263
|
const first = new Map();
|
|
@@ -259,6 +288,8 @@ export function parseOptions(argv) {
|
|
|
259
288
|
out: null,
|
|
260
289
|
json: false,
|
|
261
290
|
chrome: null,
|
|
291
|
+
headed: false,
|
|
292
|
+
probe: null,
|
|
262
293
|
errors: [],
|
|
263
294
|
};
|
|
264
295
|
|
|
@@ -300,6 +331,9 @@ export function parseOptions(argv) {
|
|
|
300
331
|
case "--chrome":
|
|
301
332
|
options.chrome = next() ?? null;
|
|
302
333
|
break;
|
|
334
|
+
case "--headed":
|
|
335
|
+
options.headed = true;
|
|
336
|
+
break;
|
|
303
337
|
case "--mobile":
|
|
304
338
|
options.mobile = true;
|
|
305
339
|
break;
|
|
@@ -312,12 +346,24 @@ export function parseOptions(argv) {
|
|
|
312
346
|
case "--json":
|
|
313
347
|
options.json = true;
|
|
314
348
|
break;
|
|
349
|
+
case "--probe": {
|
|
350
|
+
const kind = next();
|
|
351
|
+
if (kind !== "install" && kind !== "read") {
|
|
352
|
+
options.errors.push("--probe takes install or read.");
|
|
353
|
+
} else {
|
|
354
|
+
options.probe = kind;
|
|
355
|
+
}
|
|
356
|
+
break;
|
|
357
|
+
}
|
|
315
358
|
default:
|
|
316
359
|
options.errors.push(`Unknown option: ${argument}`);
|
|
317
360
|
}
|
|
318
361
|
}
|
|
319
362
|
|
|
320
|
-
|
|
363
|
+
/** A probe is printed, not run, so it needs no page. */
|
|
364
|
+
if (options.url === null && options.probe === null) {
|
|
365
|
+
options.errors.push("No URL. Pass the page to review.");
|
|
366
|
+
}
|
|
321
367
|
return options;
|
|
322
368
|
}
|
|
323
369
|
|
|
@@ -366,9 +412,46 @@ const RECORDER = `(() => {
|
|
|
366
412
|
return id;
|
|
367
413
|
};
|
|
368
414
|
|
|
415
|
+
/**
|
|
416
|
+
* Long frames and layout shifts, where the browser reports them. The sampler
|
|
417
|
+
* below is itself work inside every frame, so its own time is kept too, and a
|
|
418
|
+
* long frame is reported with that time taken out. A read never shifts
|
|
419
|
+
* layout, so the shifts are the page's alone.
|
|
420
|
+
*/
|
|
421
|
+
const supported = (type) =>
|
|
422
|
+
typeof PerformanceObserver !== "undefined" &&
|
|
423
|
+
(PerformanceObserver.supportedEntryTypes || []).includes(type);
|
|
424
|
+
const longFrames = supported("long-animation-frame") ? [] : null;
|
|
425
|
+
const shift = supported("layout-shift") ? { total: 0, count: 0, largest: null } : null;
|
|
426
|
+
const cost = [];
|
|
427
|
+
if (longFrames) {
|
|
428
|
+
new PerformanceObserver((list) => {
|
|
429
|
+
for (const entry of list.getEntries()) {
|
|
430
|
+
longFrames.push({
|
|
431
|
+
at: Math.round(entry.startTime - started),
|
|
432
|
+
duration: Math.round(entry.duration),
|
|
433
|
+
blocking: Math.round(entry.blockingDuration || 0),
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
}).observe({ type: "long-animation-frame" });
|
|
437
|
+
}
|
|
438
|
+
if (shift) {
|
|
439
|
+
new PerformanceObserver((list) => {
|
|
440
|
+
for (const entry of list.getEntries()) {
|
|
441
|
+
if (entry.hadRecentInput) continue;
|
|
442
|
+
shift.total += entry.value;
|
|
443
|
+
shift.count += 1;
|
|
444
|
+
if (!shift.largest || entry.value > shift.largest.value) {
|
|
445
|
+
shift.largest = { at: Math.round(entry.startTime - started), value: entry.value };
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}).observe({ type: "layout-shift" });
|
|
449
|
+
}
|
|
450
|
+
|
|
369
451
|
const tick =() => {
|
|
452
|
+
const began = performance.now();
|
|
370
453
|
frames += 1;
|
|
371
|
-
const at = Math.round(
|
|
454
|
+
const at = Math.round(began - started);
|
|
372
455
|
const now = new Map();
|
|
373
456
|
const moved = [];
|
|
374
457
|
/**
|
|
@@ -428,6 +511,7 @@ const RECORDER = `(() => {
|
|
|
428
511
|
|
|
429
512
|
previous = now;
|
|
430
513
|
if (moved.length) log.push({ at, moved, scrolled });
|
|
514
|
+
cost.push([at, Math.round((performance.now() - began) * 10) / 10]);
|
|
431
515
|
requestAnimationFrame(tick);
|
|
432
516
|
};
|
|
433
517
|
requestAnimationFrame(tick);
|
|
@@ -471,6 +555,10 @@ const RECORDER = `(() => {
|
|
|
471
555
|
viewport: { width: innerWidth, height: innerHeight },
|
|
472
556
|
text: textLines(),
|
|
473
557
|
log,
|
|
558
|
+
longFrames,
|
|
559
|
+
shift,
|
|
560
|
+
cost,
|
|
561
|
+
canvases: document.querySelectorAll("canvas").length,
|
|
474
562
|
});
|
|
475
563
|
return true;
|
|
476
564
|
})()`;
|
|
@@ -485,7 +573,7 @@ async function watch(options) {
|
|
|
485
573
|
throw new Error("No Chrome found. Install Chrome, or pass --chrome <path>, or set CHROME_PATH.");
|
|
486
574
|
}
|
|
487
575
|
|
|
488
|
-
const browser = connect(executable);
|
|
576
|
+
const browser = connect(executable, { headless: !options.headed });
|
|
489
577
|
const notes = [];
|
|
490
578
|
|
|
491
579
|
try {
|
|
@@ -557,9 +645,9 @@ async function watch(options) {
|
|
|
557
645
|
|
|
558
646
|
let shot = null;
|
|
559
647
|
if (options.out) {
|
|
560
|
-
|
|
648
|
+
const out = prepareOut(options.out);
|
|
561
649
|
const png = await call("Page.captureScreenshot", { format: "png" });
|
|
562
|
-
shot = join(
|
|
650
|
+
shot = join(out, "review.png");
|
|
563
651
|
writeFileSync(shot, Buffer.from(png.data, "base64"));
|
|
564
652
|
}
|
|
565
653
|
|
|
@@ -629,7 +717,48 @@ export function conclude(result) {
|
|
|
629
717
|
overText,
|
|
630
718
|
began: began.slice(0, 8),
|
|
631
719
|
spread: began.length > 1 ? began.at(-1).at - began[0].at : 0,
|
|
720
|
+
/** Both absent from a recording made before they were measured: unknown, not zero. */
|
|
721
|
+
longFrames: longFrameSummary(recorded.longFrames, recorded.cost),
|
|
722
|
+
canvases: recorded.canvases ?? null,
|
|
723
|
+
shift: recorded.shift
|
|
724
|
+
? { ...recorded.shift, total: Math.round(recorded.shift.total * 1000) / 1000 }
|
|
725
|
+
: null,
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// --- In a browser this does not drive -----------------------------------------
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* The recorder and its conclusions, as functions for a browser something else
|
|
733
|
+
* already holds — a DevTools MCP server's `evaluate_script`, where a project
|
|
734
|
+
* allows no second Chrome.
|
|
735
|
+
*
|
|
736
|
+
* `install` records from the moment it runs, not from navigation, so first
|
|
737
|
+
* motion is measured from there. `read` concludes in the page with the same
|
|
738
|
+
* functions this file uses, and returns only the findings: the frame log never
|
|
739
|
+
* crosses the wire. Emulation is the driving tool's own to set.
|
|
740
|
+
*/
|
|
741
|
+
export function probe(kind) {
|
|
742
|
+
if (kind === "install") return `() => ${RECORDER}`;
|
|
743
|
+
const helpers = {
|
|
744
|
+
overlap,
|
|
745
|
+
coverage,
|
|
746
|
+
seams,
|
|
747
|
+
motionOverText,
|
|
748
|
+
afterScroll,
|
|
749
|
+
seamEvents,
|
|
750
|
+
starts,
|
|
751
|
+
firstMotion,
|
|
752
|
+
longFrameSummary,
|
|
753
|
+
conclude,
|
|
632
754
|
};
|
|
755
|
+
return `() => {
|
|
756
|
+
if (!window.__gsapMotionReview) return null;
|
|
757
|
+
${Object.entries(helpers)
|
|
758
|
+
.map(([name, fn]) => ` const ${name} = ${fn};`)
|
|
759
|
+
.join("\n")}
|
|
760
|
+
return conclude({ recorded: window.__gsapMotionReview() });
|
|
761
|
+
}`;
|
|
633
762
|
}
|
|
634
763
|
|
|
635
764
|
// --- Report --------------------------------------------------------------------
|
|
@@ -643,9 +772,12 @@ export function report(result, options) {
|
|
|
643
772
|
options.dark && "dark",
|
|
644
773
|
options.mobile && "mobile 390x844",
|
|
645
774
|
options.cpu > 1 && `CPU ÷${options.cpu}`,
|
|
775
|
+
options.headed && "headed, on the GPU",
|
|
646
776
|
].filter(Boolean);
|
|
647
777
|
if (emulated.length) lines.push(` as: ${emulated.join(", ")}`);
|
|
648
778
|
for (const note of result.notes) lines.push(` ${note}`);
|
|
779
|
+
const gpu = gpuNote(found.canvases, options);
|
|
780
|
+
if (gpu) lines.push(` ${gpu}`);
|
|
649
781
|
|
|
650
782
|
lines.push("", "What moved");
|
|
651
783
|
if (found.firstMotion === null) {
|
|
@@ -680,6 +812,28 @@ export function report(result, options) {
|
|
|
680
812
|
*/
|
|
681
813
|
lines.push(" layout work is not measured here — sampling forces it; use inspect for that");
|
|
682
814
|
|
|
815
|
+
const long = found.longFrames;
|
|
816
|
+
if (long === null) {
|
|
817
|
+
lines.push(" long frames: not measured — this browser has no Long Animation Frames API");
|
|
818
|
+
} else if (long.count === 0) {
|
|
819
|
+
lines.push(" no frame took longer than 50ms");
|
|
820
|
+
} else {
|
|
821
|
+
lines.push(
|
|
822
|
+
` ${long.count} frame${long.count === 1 ? "" : "s"} over 50ms; the longest` +
|
|
823
|
+
` ${long.longest.duration}ms at ${long.longest.at}ms, ${long.longest.page}ms of it` +
|
|
824
|
+
" the page's own rather than the sampler's",
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
const shift = found.shift;
|
|
828
|
+
if (shift === null) {
|
|
829
|
+
lines.push(" layout shift: not measured — this browser does not report it");
|
|
830
|
+
} else {
|
|
831
|
+
lines.push(
|
|
832
|
+
` layout shift: ${shift.total} over ${shift.count} shift${shift.count === 1 ? "" : "s"}` +
|
|
833
|
+
`${shift.largest ? `, the largest ${Math.round(shift.largest.value * 1000) / 1000} at ${shift.largest.at}ms` : ""}`,
|
|
834
|
+
);
|
|
835
|
+
}
|
|
836
|
+
|
|
683
837
|
if (found.seams.length) {
|
|
684
838
|
lines.push("", "Seams — a step far larger than the motion's own rhythm");
|
|
685
839
|
for (const event of found.seams.slice(0, 6)) {
|
|
@@ -714,6 +868,11 @@ async function main(argv) {
|
|
|
714
868
|
process.exit(2);
|
|
715
869
|
}
|
|
716
870
|
|
|
871
|
+
if (options.probe) {
|
|
872
|
+
console.log(probe(options.probe));
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
|
|
717
876
|
try {
|
|
718
877
|
const result = await watch(options);
|
|
719
878
|
console.log(
|
|
@@ -1,126 +1,126 @@
|
|
|
1
|
-
"use client";
|
|
2
|
-
|
|
3
|
-
import { useGSAP } from "@gsap/react";
|
|
4
|
-
import gsap from "gsap";
|
|
5
|
-
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
|
6
|
-
import { useRef } from "react";
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Starting point for a scroll-driven scene.
|
|
10
|
-
*
|
|
11
|
-
* The shape here is the lesson: one trigger owning one timeline, a genuinely
|
|
12
|
-
* different strategy on mobile rather than a smaller one, and `ease: "none"`
|
|
13
|
-
* throughout because the scrollbar — not an ease curve — is the playhead.
|
|
14
|
-
*
|
|
15
|
-
* Copy it, rename it, replace the beats. Typechecked for the same reason as
|
|
16
|
-
* the sibling template.
|
|
17
|
-
*/
|
|
18
|
-
gsap.registerPlugin(useGSAP, ScrollTrigger);
|
|
19
|
-
|
|
20
|
-
/** Scroll distance the pinned sequence is spread across, in pixels. */
|
|
21
|
-
const SCROLL_LENGTH = 2400;
|
|
22
|
-
|
|
23
|
-
export function useScrollScene() {
|
|
24
|
-
const rootRef = useRef<HTMLDivElement>(null);
|
|
25
|
-
|
|
26
|
-
useGSAP(
|
|
27
|
-
() => {
|
|
28
|
-
const root = rootRef.current;
|
|
29
|
-
if (!root) return;
|
|
30
|
-
|
|
31
|
-
const mm = gsap.matchMedia();
|
|
32
|
-
|
|
33
|
-
mm.add(
|
|
34
|
-
{
|
|
35
|
-
desktop: "(min-width: 768px) and (prefers-reduced-motion: no-preference)",
|
|
36
|
-
mobile: "(max-width: 767px) and (prefers-reduced-motion: no-preference)",
|
|
37
|
-
reduced: "(prefers-reduced-motion: reduce)",
|
|
38
|
-
},
|
|
39
|
-
(context) => {
|
|
40
|
-
const { desktop, reduced } = context.conditions as Record<
|
|
41
|
-
string,
|
|
42
|
-
boolean
|
|
43
|
-
>;
|
|
44
|
-
|
|
45
|
-
/**
|
|
46
|
-
* Scroll-scrubbed pinning is a textbook vestibular trigger: the
|
|
47
|
-
* content moves while the page does not. Under `reduce` the beats
|
|
48
|
-
* simply exist, composed, with no pin and no scrub.
|
|
49
|
-
*/
|
|
50
|
-
if (reduced) {
|
|
51
|
-
gsap.set("[data-beat]", { autoAlpha: 1, y: 0 });
|
|
52
|
-
return;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* Not the desktop sequence scaled down. A 2400px pin on a phone is a
|
|
57
|
-
* long time to be stuck, and pinning fights the address bar
|
|
58
|
-
* collapsing — so each beat becomes an ordinary in-view reveal
|
|
59
|
-
* instead. Same content, different strategy.
|
|
60
|
-
*/
|
|
61
|
-
if (!desktop) {
|
|
62
|
-
gsap.utils.toArray<HTMLElement>("[data-beat]").forEach((beat) => {
|
|
63
|
-
gsap.from(beat, {
|
|
64
|
-
autoAlpha: 0,
|
|
65
|
-
y: 24,
|
|
66
|
-
duration: 0.6,
|
|
67
|
-
ease: "power3.out",
|
|
68
|
-
scrollTrigger: { trigger: beat, start: "top 78%" },
|
|
69
|
-
});
|
|
70
|
-
});
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
gsap
|
|
75
|
-
.timeline({
|
|
76
|
-
/**
|
|
77
|
-
* `ease: "none"` on every child. Easing inside a scrubbed
|
|
78
|
-
* timeline fights the visitor's own scrolling and reads as lag —
|
|
79
|
-
* `scrub` is where the smoothing belongs.
|
|
80
|
-
*/
|
|
81
|
-
defaults: { ease: "none" },
|
|
82
|
-
scrollTrigger: {
|
|
83
|
-
trigger: root,
|
|
84
|
-
start: "top top",
|
|
85
|
-
end: `+=${SCROLL_LENGTH}`,
|
|
86
|
-
pin: true,
|
|
87
|
-
scrub: 1,
|
|
88
|
-
/** Reduces the flicker when the pin engages at speed. */
|
|
89
|
-
anticipatePin: 1,
|
|
90
|
-
/** Recomputes measured start values after a resize. */
|
|
91
|
-
invalidateOnRefresh: true,
|
|
92
|
-
markers: process.env.NODE_ENV === "development",
|
|
93
|
-
},
|
|
94
|
-
})
|
|
95
|
-
/**
|
|
96
|
-
* Labels rather than offsets. A scrubbed sequence gets retimed
|
|
97
|
-
* many times, and two children sharing a label stay together when
|
|
98
|
-
* the beat before them changes length.
|
|
99
|
-
*/
|
|
100
|
-
.addLabel("open")
|
|
101
|
-
.from("[data-beat='1']", { autoAlpha: 0, y: 24, duration: 0.4 }, "open")
|
|
102
|
-
|
|
103
|
-
.addLabel("detail")
|
|
104
|
-
.to("[data-beat='1']", { autoAlpha: 0, duration: 0.3 }, "detail")
|
|
105
|
-
.from("[data-beat='2']", { autoAlpha: 0, y: 24, duration: 0.4 }, "detail+=0.3")
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Placed at position 0 with the timeline's full span, so it tracks
|
|
109
|
-
* overall progress regardless of what else is happening.
|
|
110
|
-
*/
|
|
111
|
-
.to("[data-progress]", { scaleX: 1, transformOrigin: "0% 50%" }, 0);
|
|
112
|
-
},
|
|
113
|
-
);
|
|
114
|
-
|
|
115
|
-
/**
|
|
116
|
-
* ScrollTriggers and the `matchMedia` above are both reverted with the
|
|
117
|
-
* context — each registers itself when constructed inside one. Only
|
|
118
|
-
* things GSAP never sees (timers, raw listeners, observers) need a
|
|
119
|
-
* cleanup returned from here.
|
|
120
|
-
*/
|
|
121
|
-
},
|
|
122
|
-
{ scope: rootRef },
|
|
123
|
-
);
|
|
124
|
-
|
|
125
|
-
return rootRef;
|
|
126
|
-
}
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useGSAP } from "@gsap/react";
|
|
4
|
+
import gsap from "gsap";
|
|
5
|
+
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
|
6
|
+
import { useRef } from "react";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Starting point for a scroll-driven scene.
|
|
10
|
+
*
|
|
11
|
+
* The shape here is the lesson: one trigger owning one timeline, a genuinely
|
|
12
|
+
* different strategy on mobile rather than a smaller one, and `ease: "none"`
|
|
13
|
+
* throughout because the scrollbar — not an ease curve — is the playhead.
|
|
14
|
+
*
|
|
15
|
+
* Copy it, rename it, replace the beats. Typechecked for the same reason as
|
|
16
|
+
* the sibling template.
|
|
17
|
+
*/
|
|
18
|
+
gsap.registerPlugin(useGSAP, ScrollTrigger);
|
|
19
|
+
|
|
20
|
+
/** Scroll distance the pinned sequence is spread across, in pixels. */
|
|
21
|
+
const SCROLL_LENGTH = 2400;
|
|
22
|
+
|
|
23
|
+
export function useScrollScene() {
|
|
24
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
25
|
+
|
|
26
|
+
useGSAP(
|
|
27
|
+
() => {
|
|
28
|
+
const root = rootRef.current;
|
|
29
|
+
if (!root) return;
|
|
30
|
+
|
|
31
|
+
const mm = gsap.matchMedia();
|
|
32
|
+
|
|
33
|
+
mm.add(
|
|
34
|
+
{
|
|
35
|
+
desktop: "(min-width: 768px) and (prefers-reduced-motion: no-preference)",
|
|
36
|
+
mobile: "(max-width: 767px) and (prefers-reduced-motion: no-preference)",
|
|
37
|
+
reduced: "(prefers-reduced-motion: reduce)",
|
|
38
|
+
},
|
|
39
|
+
(context) => {
|
|
40
|
+
const { desktop, reduced } = context.conditions as Record<
|
|
41
|
+
string,
|
|
42
|
+
boolean
|
|
43
|
+
>;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Scroll-scrubbed pinning is a textbook vestibular trigger: the
|
|
47
|
+
* content moves while the page does not. Under `reduce` the beats
|
|
48
|
+
* simply exist, composed, with no pin and no scrub.
|
|
49
|
+
*/
|
|
50
|
+
if (reduced) {
|
|
51
|
+
gsap.set("[data-beat]", { autoAlpha: 1, y: 0 });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Not the desktop sequence scaled down. A 2400px pin on a phone is a
|
|
57
|
+
* long time to be stuck, and pinning fights the address bar
|
|
58
|
+
* collapsing — so each beat becomes an ordinary in-view reveal
|
|
59
|
+
* instead. Same content, different strategy.
|
|
60
|
+
*/
|
|
61
|
+
if (!desktop) {
|
|
62
|
+
gsap.utils.toArray<HTMLElement>("[data-beat]").forEach((beat) => {
|
|
63
|
+
gsap.from(beat, {
|
|
64
|
+
autoAlpha: 0,
|
|
65
|
+
y: 24,
|
|
66
|
+
duration: 0.6,
|
|
67
|
+
ease: "power3.out",
|
|
68
|
+
scrollTrigger: { trigger: beat, start: "top 78%" },
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
gsap
|
|
75
|
+
.timeline({
|
|
76
|
+
/**
|
|
77
|
+
* `ease: "none"` on every child. Easing inside a scrubbed
|
|
78
|
+
* timeline fights the visitor's own scrolling and reads as lag —
|
|
79
|
+
* `scrub` is where the smoothing belongs.
|
|
80
|
+
*/
|
|
81
|
+
defaults: { ease: "none" },
|
|
82
|
+
scrollTrigger: {
|
|
83
|
+
trigger: root,
|
|
84
|
+
start: "top top",
|
|
85
|
+
end: `+=${SCROLL_LENGTH}`,
|
|
86
|
+
pin: true,
|
|
87
|
+
scrub: 1,
|
|
88
|
+
/** Reduces the flicker when the pin engages at speed. */
|
|
89
|
+
anticipatePin: 1,
|
|
90
|
+
/** Recomputes measured start values after a resize. */
|
|
91
|
+
invalidateOnRefresh: true,
|
|
92
|
+
markers: process.env.NODE_ENV === "development",
|
|
93
|
+
},
|
|
94
|
+
})
|
|
95
|
+
/**
|
|
96
|
+
* Labels rather than offsets. A scrubbed sequence gets retimed
|
|
97
|
+
* many times, and two children sharing a label stay together when
|
|
98
|
+
* the beat before them changes length.
|
|
99
|
+
*/
|
|
100
|
+
.addLabel("open")
|
|
101
|
+
.from("[data-beat='1']", { autoAlpha: 0, y: 24, duration: 0.4 }, "open")
|
|
102
|
+
|
|
103
|
+
.addLabel("detail")
|
|
104
|
+
.to("[data-beat='1']", { autoAlpha: 0, duration: 0.3 }, "detail")
|
|
105
|
+
.from("[data-beat='2']", { autoAlpha: 0, y: 24, duration: 0.4 }, "detail+=0.3")
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Placed at position 0 with the timeline's full span, so it tracks
|
|
109
|
+
* overall progress regardless of what else is happening.
|
|
110
|
+
*/
|
|
111
|
+
.to("[data-progress]", { scaleX: 1, transformOrigin: "0% 50%" }, 0);
|
|
112
|
+
},
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* ScrollTriggers and the `matchMedia` above are both reverted with the
|
|
117
|
+
* context — each registers itself when constructed inside one. Only
|
|
118
|
+
* things GSAP never sees (timers, raw listeners, observers) need a
|
|
119
|
+
* cleanup returned from here.
|
|
120
|
+
*/
|
|
121
|
+
},
|
|
122
|
+
{ scope: rootRef },
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
return rootRef;
|
|
126
|
+
}
|