@mehshekari/gsap-motion 2.1.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/NOTICE.md +24 -0
- package/README.md +59 -0
- package/bin/gsap-motion.mjs +304 -0
- package/package.json +48 -0
- package/skill/LICENSE +21 -0
- package/skill/NOTICE.md +24 -0
- package/skill/SKILL.md +181 -0
- package/skill/example/cinematic-intro.md +202 -0
- package/skill/example/magnetic-button.md +101 -0
- package/skill/example/svg-loader.md +180 -0
- package/skill/preset/card-stack.md +175 -0
- package/skill/preset/cinematic.md +140 -0
- package/skill/preset/cursor.md +184 -0
- package/skill/preset/loader.md +172 -0
- package/skill/preset/magnetic.md +155 -0
- package/skill/preset/marquee.md +177 -0
- package/skill/preset/page-transition.md +151 -0
- package/skill/preset/reveal.md +146 -0
- package/skill/preset/scroll-storytelling.md +176 -0
- package/skill/reference/accessibility.md +143 -0
- package/skill/reference/core-gsap.md +233 -0
- package/skill/reference/flip.md +220 -0
- package/skill/reference/frameworks.md +153 -0
- package/skill/reference/interaction.md +183 -0
- package/skill/reference/motion-design.md +144 -0
- package/skill/reference/performance.md +216 -0
- package/skill/reference/project-rules.md +178 -0
- package/skill/reference/react-nextjs.md +234 -0
- package/skill/reference/routing.md +119 -0
- package/skill/reference/scrolltrigger.md +191 -0
- package/skill/reference/svg.md +201 -0
- package/skill/reference/text.md +150 -0
- package/skill/reference/three-r3f.md +161 -0
- package/skill/reference/timeline.md +147 -0
- package/skill/scripts/audit-gsap.mjs +157 -0
- package/skill/scripts/audit-svg.mjs +358 -0
- package/skill/scripts/lib/rules.mjs +555 -0
- package/skill/scripts/lib/source.mjs +238 -0
- package/skill/template/useScrollScene.ts +126 -0
- package/skill/template/useTimeline.ts +110 -0
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source reading for the GSAP audit.
|
|
3
|
+
*
|
|
4
|
+
* Every helper here exists because naive matching produces false findings, and
|
|
5
|
+
* a checker that cries wolf is one people switch off. Comments are stripped
|
|
6
|
+
* before any rule runs — well-commented animation code explains itself at length, and
|
|
7
|
+
* a docblock saying "do not use `gsap.to` in a pointermove handler" would
|
|
8
|
+
* otherwise be reported as doing exactly that.
|
|
9
|
+
*/
|
|
10
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
11
|
+
import { extname, join, relative } from "node:path";
|
|
12
|
+
|
|
13
|
+
const SOURCE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs"]);
|
|
14
|
+
const SKIP_DIRECTORIES = new Set([
|
|
15
|
+
"node_modules",
|
|
16
|
+
".next",
|
|
17
|
+
".git",
|
|
18
|
+
"dist",
|
|
19
|
+
"build",
|
|
20
|
+
"coverage",
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
/** Every source file under `root`, or `root` itself when it is a file. */
|
|
24
|
+
export function collect(root) {
|
|
25
|
+
if (statSync(root).isFile()) return [root];
|
|
26
|
+
|
|
27
|
+
const files = [];
|
|
28
|
+
const walk = (dir) => {
|
|
29
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
30
|
+
if (entry.name.startsWith(".") || SKIP_DIRECTORIES.has(entry.name)) {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
const full = join(dir, entry.name);
|
|
34
|
+
if (entry.isDirectory()) walk(full);
|
|
35
|
+
else if (SOURCE_EXTENSIONS.has(extname(entry.name))) files.push(full);
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
walk(root);
|
|
40
|
+
return files.sort();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Replaces comment bodies with spaces, keeping every newline and every offset.
|
|
45
|
+
*
|
|
46
|
+
* Offsets are preserved rather than the text being cut out, so an index into
|
|
47
|
+
* the returned string is still an index into the original and `lineAt` stays
|
|
48
|
+
* correct. Blanking also means a rule cannot accidentally join two tokens that
|
|
49
|
+
* a comment sat between.
|
|
50
|
+
*/
|
|
51
|
+
export function stripComments(source) {
|
|
52
|
+
const out = source.split("");
|
|
53
|
+
let mode = "code";
|
|
54
|
+
let quote = "";
|
|
55
|
+
|
|
56
|
+
for (let i = 0; i < source.length; i += 1) {
|
|
57
|
+
const c = source[i];
|
|
58
|
+
const next = source[i + 1];
|
|
59
|
+
|
|
60
|
+
if (mode === "code") {
|
|
61
|
+
if (c === "/" && next === "/") {
|
|
62
|
+
mode = "line";
|
|
63
|
+
out[i] = " ";
|
|
64
|
+
out[i + 1] = " ";
|
|
65
|
+
i += 1;
|
|
66
|
+
} else if (c === "/" && next === "*") {
|
|
67
|
+
mode = "block";
|
|
68
|
+
out[i] = " ";
|
|
69
|
+
out[i + 1] = " ";
|
|
70
|
+
i += 1;
|
|
71
|
+
} else if (c === '"' || c === "'" || c === "`") {
|
|
72
|
+
mode = "string";
|
|
73
|
+
quote = c;
|
|
74
|
+
}
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (mode === "line") {
|
|
79
|
+
if (c === "\n") mode = "code";
|
|
80
|
+
else out[i] = " ";
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (mode === "block") {
|
|
85
|
+
if (c === "*" && next === "/") {
|
|
86
|
+
out[i] = " ";
|
|
87
|
+
out[i + 1] = " ";
|
|
88
|
+
i += 1;
|
|
89
|
+
mode = "code";
|
|
90
|
+
} else if (c !== "\n") {
|
|
91
|
+
out[i] = " ";
|
|
92
|
+
}
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Inside a string literal: left intact, but escapes must not end it early.
|
|
97
|
+
if (c === "\\") {
|
|
98
|
+
i += 1;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (c === quote) mode = "code";
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return out.join("");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** 1-indexed line number for a character offset. */
|
|
108
|
+
export function lineAt(source, index) {
|
|
109
|
+
let line = 1;
|
|
110
|
+
for (let i = 0; i < index && i < source.length; i += 1) {
|
|
111
|
+
if (source[i] === "\n") line += 1;
|
|
112
|
+
}
|
|
113
|
+
return line;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The `{ … }` block that opens at or after `from`, as a `[start, end]` pair.
|
|
118
|
+
*
|
|
119
|
+
* Used to ask "is this call inside that handler?" without a parser. Quotes are
|
|
120
|
+
* tracked so a brace inside a string cannot close the block early; comments are
|
|
121
|
+
* already gone by the time this runs.
|
|
122
|
+
*/
|
|
123
|
+
export function blockAfter(source, from) {
|
|
124
|
+
const start = source.indexOf("{", from);
|
|
125
|
+
if (start === -1) return null;
|
|
126
|
+
|
|
127
|
+
let depth = 0;
|
|
128
|
+
let quote = "";
|
|
129
|
+
|
|
130
|
+
for (let i = start; i < source.length; i += 1) {
|
|
131
|
+
const c = source[i];
|
|
132
|
+
|
|
133
|
+
if (quote) {
|
|
134
|
+
if (c === "\\") i += 1;
|
|
135
|
+
else if (c === quote) quote = "";
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
139
|
+
quote = c;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (c === "{") depth += 1;
|
|
143
|
+
else if (c === "}") {
|
|
144
|
+
depth -= 1;
|
|
145
|
+
if (depth === 0) return [start, i];
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Whether `index` falls inside any of the given `[start, end]` spans. */
|
|
153
|
+
export const within = (spans, index) =>
|
|
154
|
+
spans.some(([start, end]) => index > start && index < end);
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* The `( … )` of a call, as a `[start, end]` pair, given the index of its
|
|
158
|
+
* opening paren. Quotes are tracked the same way `blockAfter` tracks them.
|
|
159
|
+
*/
|
|
160
|
+
export function parenSpan(source, open) {
|
|
161
|
+
let depth = 0;
|
|
162
|
+
let quote = "";
|
|
163
|
+
|
|
164
|
+
for (let i = open; i < source.length; i += 1) {
|
|
165
|
+
const c = source[i];
|
|
166
|
+
|
|
167
|
+
if (quote) {
|
|
168
|
+
if (c === "\\") i += 1;
|
|
169
|
+
else if (c === quote) quote = "";
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if (c === '"' || c === "'" || c === "`") {
|
|
173
|
+
quote = c;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
if (c === "(") depth += 1;
|
|
177
|
+
else if (c === ")") {
|
|
178
|
+
depth -= 1;
|
|
179
|
+
if (depth === 0) return [open, i];
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Spans of every `useGSAP(…)`, `gsap.context(…)` and `contextSafe(…)` call.
|
|
188
|
+
*
|
|
189
|
+
* Anything created outside these is outside the context that reverts it, which
|
|
190
|
+
* is the single most common way an animation outlives its component.
|
|
191
|
+
*
|
|
192
|
+
* The span is the call's parentheses, not the first `{` after its name. The
|
|
193
|
+
* first brace failed silently twice over: a bodiless `useGSAP()` — the usual
|
|
194
|
+
* way to get `contextSafe` — claimed whatever block came next in the file, so an
|
|
195
|
+
* orphaned tween in the handler below it read as owned; and an expression-bodied
|
|
196
|
+
* `contextSafe(() => gsap.to(el, { … }))` measured as the tween's own vars
|
|
197
|
+
* object, which the call itself sits in front of.
|
|
198
|
+
*
|
|
199
|
+
* `contextSafe` counts because it is a context: a wrapped function's tweens are
|
|
200
|
+
* added to the one `useGSAP` created.
|
|
201
|
+
*/
|
|
202
|
+
export function contextSpans(code) {
|
|
203
|
+
const spans = [];
|
|
204
|
+
for (const match of code.matchAll(
|
|
205
|
+
/\b(?:useGSAP|gsap\.context|contextSafe)\s*\(/g,
|
|
206
|
+
)) {
|
|
207
|
+
const span = parenSpan(code, match.index + match[0].length - 1);
|
|
208
|
+
if (span) spans.push(span);
|
|
209
|
+
}
|
|
210
|
+
return spans;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** A file plus everything the rules need, read once. */
|
|
214
|
+
export function load(path, root) {
|
|
215
|
+
const raw = readFileSync(path, "utf8");
|
|
216
|
+
const code = stripComments(raw);
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
path,
|
|
220
|
+
display: relative(root, path).replace(/\\/g, "/"),
|
|
221
|
+
raw,
|
|
222
|
+
code,
|
|
223
|
+
/**
|
|
224
|
+
* "Can this mount more than once?" rather than literally "is this JSX".
|
|
225
|
+
*
|
|
226
|
+
* A hook lives in a `.ts` file and often imports only `@gsap/react`, never
|
|
227
|
+
* `react` itself — so matching on the extension or a React import alone
|
|
228
|
+
* misses exactly the files where the component-instance rules matter most.
|
|
229
|
+
*/
|
|
230
|
+
isReact:
|
|
231
|
+
/\.(tsx|jsx)$/.test(path) ||
|
|
232
|
+
/\bfrom ["']react["']/.test(code) ||
|
|
233
|
+
/\bfrom ["']@gsap\/react["']/.test(code),
|
|
234
|
+
isClient: /^\s*["']use client["']/m.test(code),
|
|
235
|
+
usesGsap: /\bfrom ["']gsap(?:\/|["'])/.test(code),
|
|
236
|
+
contexts: contextSpans(code),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
@@ -0,0 +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
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useGSAP } from "@gsap/react";
|
|
4
|
+
import gsap from "gsap";
|
|
5
|
+
import { useCallback, useRef } from "react";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Starting point for a component-scoped GSAP timeline.
|
|
9
|
+
*
|
|
10
|
+
* Copy it into the project's hooks folder or next to the component that owns
|
|
11
|
+
* it, rename it, and replace the timeline body. The structure is the part worth
|
|
12
|
+
* keeping: plugin registration at module scope, a scoped `useGSAP`, a
|
|
13
|
+
* `matchMedia` branch that treats reduced motion as a design rather than an off
|
|
14
|
+
* switch, and an explicit teardown for the things the GSAP context does not
|
|
15
|
+
* own.
|
|
16
|
+
*
|
|
17
|
+
* Worth typechecking where the skill is installed: add the skill's `template/`
|
|
18
|
+
* folder to `tsconfig.json`'s `include`. TypeScript's globs skip dot-directories
|
|
19
|
+
* such as `.claude/`, and an example that has quietly stopped compiling is worse
|
|
20
|
+
* than none.
|
|
21
|
+
*/
|
|
22
|
+
gsap.registerPlugin(useGSAP);
|
|
23
|
+
|
|
24
|
+
/** One place for the numbers, so retiming is not a hunt through the body. */
|
|
25
|
+
const TIMING = {
|
|
26
|
+
enter: 0.8,
|
|
27
|
+
stagger: 0.08,
|
|
28
|
+
hold: 0.4,
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
export function useTimeline() {
|
|
32
|
+
const rootRef = useRef<HTMLDivElement>(null);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Kept on a ref rather than returned directly: the timeline is built inside
|
|
36
|
+
* the effect, and a caller that wants to replay it needs a stable handle that
|
|
37
|
+
* does not exist until then.
|
|
38
|
+
*/
|
|
39
|
+
const timelineRef = useRef<gsap.core.Timeline | null>(null);
|
|
40
|
+
|
|
41
|
+
useGSAP(
|
|
42
|
+
() => {
|
|
43
|
+
/**
|
|
44
|
+
* `matchMedia` rather than a one-off query, because a visitor can change
|
|
45
|
+
* the preference or the viewport while the component is mounted. GSAP
|
|
46
|
+
* reverts the losing branch and builds the other one; a plain `if` leaves
|
|
47
|
+
* the first animation running forever.
|
|
48
|
+
*/
|
|
49
|
+
const mm = gsap.matchMedia();
|
|
50
|
+
|
|
51
|
+
mm.add(
|
|
52
|
+
{
|
|
53
|
+
motion: "(prefers-reduced-motion: no-preference)",
|
|
54
|
+
reduced: "(prefers-reduced-motion: reduce)",
|
|
55
|
+
},
|
|
56
|
+
(context) => {
|
|
57
|
+
const { reduced } = context.conditions as Record<string, boolean>;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The reduced branch must set the END state, not merely skip the
|
|
61
|
+
* tween. A `from` that never runs leaves its target at the authored
|
|
62
|
+
* start — which hides the content rather than calming it. This is the
|
|
63
|
+
* most common reduced-motion bug there is.
|
|
64
|
+
*/
|
|
65
|
+
if (reduced) {
|
|
66
|
+
gsap.set("[data-item]", { autoAlpha: 1, y: 0 });
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
timelineRef.current = gsap
|
|
71
|
+
.timeline({ defaults: { ease: "power3.out" } })
|
|
72
|
+
.from("[data-item]", {
|
|
73
|
+
y: 32,
|
|
74
|
+
autoAlpha: 0,
|
|
75
|
+
duration: TIMING.enter,
|
|
76
|
+
stagger: TIMING.stagger,
|
|
77
|
+
})
|
|
78
|
+
/**
|
|
79
|
+
* Positions, not delays. `<` starts a child with the previous one
|
|
80
|
+
* and survives a duration change upstream, where `-=0.3` silently
|
|
81
|
+
* drifts.
|
|
82
|
+
*/
|
|
83
|
+
.to("[data-accent]", { scaleX: 1, duration: 0.5 }, "<0.2");
|
|
84
|
+
},
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Nothing is returned, and that is deliberate. `matchMedia` registers
|
|
89
|
+
* itself with whatever GSAP context is active when it is constructed, so
|
|
90
|
+
* this one is reverted along with every tween above — as are Observer,
|
|
91
|
+
* Draggable, SplitText and ScrollTrigger when built in here.
|
|
92
|
+
*
|
|
93
|
+
* What a context never sees is the rest: timers, raw `addEventListener`,
|
|
94
|
+
* ResizeObserver, Three.js disposal. Return a cleanup for those.
|
|
95
|
+
*/
|
|
96
|
+
},
|
|
97
|
+
{ scope: rootRef },
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Playing a timeline that already exists creates nothing, so this needs no
|
|
102
|
+
* `contextSafe` — the context already owns it. Wrapping it would also be a
|
|
103
|
+
* lint error, because `contextSafe` runs during render and reading a ref
|
|
104
|
+
* there is what React forbids. Reserve `contextSafe` for a handler that
|
|
105
|
+
* *creates* a tween.
|
|
106
|
+
*/
|
|
107
|
+
const replay = useCallback(() => timelineRef.current?.restart(), []);
|
|
108
|
+
|
|
109
|
+
return { rootRef, replay };
|
|
110
|
+
}
|