@mehshekari/gsap-motion 3.1.1 → 3.2.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 +1 -1
- package/bin/gsap-motion.mjs +17 -0
- package/package.json +1 -1
- package/skill/SKILL.md +14 -10
- package/skill/adapter/astro.md +111 -0
- package/skill/adapter/next.md +99 -0
- package/skill/adapter/r3f.md +128 -0
- package/skill/adapter/react.md +133 -0
- package/skill/adapter/svelte.md +105 -0
- package/skill/adapter/three.md +141 -0
- package/skill/adapter/vanilla.md +115 -0
- package/skill/adapter/vue.md +120 -0
- package/skill/example/svg-loader.md +1 -1
- package/skill/reference/core-gsap.md +1 -1
- package/skill/reference/interaction.md +183 -183
- package/skill/reference/routing.md +119 -119
- package/skill/reference/scrolltrigger.md +1 -1
- package/skill/reference/svg.md +1 -1
- package/skill/reference/timeline.md +1 -1
- package/skill/scripts/lib/ast.mjs +7 -1
- package/skill/scripts/lib/rules.mjs +97 -3
- package/skill/scripts/lib/source.mjs +51 -3
- package/skill/reference/frameworks.md +0 -153
- package/skill/reference/react-nextjs.md +0 -234
- package/skill/reference/three-r3f.md +0 -161
|
@@ -21,10 +21,16 @@ const OPTIONS = {
|
|
|
21
21
|
* `.ts` is read without JSX, because there `<T>(x) => x` is a generic and
|
|
22
22
|
* `<Type>value` a cast; every other extension is read with it. A `.js` file
|
|
23
23
|
* with JSX is common enough, and JSX never changes how plain JavaScript parses.
|
|
24
|
+
*
|
|
25
|
+
* A single-file component's `<script>` is read without JSX too: it is
|
|
26
|
+
* TypeScript as often as not, and the template where the angle brackets live is
|
|
27
|
+
* blanked out before it reaches here.
|
|
24
28
|
*/
|
|
25
29
|
const TYPESCRIPT = Parser.extend(tsPlugin());
|
|
26
30
|
const TYPESCRIPT_JSX = Parser.extend(tsPlugin({ jsx: true }));
|
|
27
31
|
|
|
32
|
+
const WITHOUT_JSX = /\.(?:[mc]?ts|vue|svelte|astro)$/;
|
|
33
|
+
|
|
28
34
|
/**
|
|
29
35
|
* Parses `source` as the grammar its `path` implies.
|
|
30
36
|
*
|
|
@@ -34,7 +40,7 @@ const TYPESCRIPT_JSX = Parser.extend(tsPlugin({ jsx: true }));
|
|
|
34
40
|
*/
|
|
35
41
|
export function parse(source, path) {
|
|
36
42
|
const comments = [];
|
|
37
|
-
const parser =
|
|
43
|
+
const parser = WITHOUT_JSX.test(path) ? TYPESCRIPT : TYPESCRIPT_JSX;
|
|
38
44
|
const ast = parser.parse(source, { ...OPTIONS, onComment: comments });
|
|
39
45
|
return { ast, comments };
|
|
40
46
|
}
|
|
@@ -51,6 +51,8 @@ const HINT = {
|
|
|
51
51
|
"Nothing reverts it on unmount, and React StrictMode runs it twice in development. Move it into the useGSAP body, or wrap the handler in contextSafe. A helper counts as inside only when every use of it is a call from inside a context.",
|
|
52
52
|
unmanagedInstance:
|
|
53
53
|
"Inside a context these register themselves and are reverted with it. Out here nothing is watching: move it into the useGSAP body, or keep the instance and revert/kill it from the cleanup.",
|
|
54
|
+
unrevertedContext:
|
|
55
|
+
"Keep it under a name and revert it when the component goes: `onUnmounted` in Vue, the function returned from `onMount` or `$effect` in Svelte, the `useEffect` cleanup in React, `astro:before-swap` in Astro. Everything created inside the context goes with it.",
|
|
54
56
|
danglingListener:
|
|
55
57
|
"A listener that survives unmount keeps the component's closure — and its DOM nodes — alive. Name the handler and remove it in the cleanup, or pass `{ signal }` from an AbortController and abort it there.",
|
|
56
58
|
tweenPerEvent:
|
|
@@ -212,7 +214,7 @@ function listenerMethod(node) {
|
|
|
212
214
|
* `followNames`, a handler passed by name is followed to its declaration when
|
|
213
215
|
* the file declares exactly one function of that name.
|
|
214
216
|
*/
|
|
215
|
-
function handlersFor(ast, events, { followNames = true } = {}) {
|
|
217
|
+
function handlersFor(ast, events, { followNames = true, perFrame = false } = {}) {
|
|
216
218
|
const handler = (expression) => {
|
|
217
219
|
const node = unwrap(expression);
|
|
218
220
|
if (node?.type === "CallExpression") return node;
|
|
@@ -242,6 +244,17 @@ function handlersFor(ast, events, { followNames = true } = {}) {
|
|
|
242
244
|
if (node) handlers.push({ name: `on${prop[1]}`, node });
|
|
243
245
|
}
|
|
244
246
|
|
|
247
|
+
/**
|
|
248
|
+
* R3F's `useFrame(callback)` is a per-frame scope of its own: what is inside
|
|
249
|
+
* runs on every rendered frame, whatever the pointer is doing.
|
|
250
|
+
*/
|
|
251
|
+
if (perFrame) {
|
|
252
|
+
for (const call of findAll(ast, (node) => calleeName(node) === "useFrame")) {
|
|
253
|
+
const node = handler(call.arguments[0]);
|
|
254
|
+
if (node) handlers.push({ name: "useFrame", node });
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
245
258
|
return handlers;
|
|
246
259
|
}
|
|
247
260
|
|
|
@@ -493,6 +506,47 @@ export function pluginRegistrations(file) {
|
|
|
493
506
|
return names;
|
|
494
507
|
}
|
|
495
508
|
|
|
509
|
+
/**
|
|
510
|
+
* The mount hooks each adapter uses. A context created in one of them lives as
|
|
511
|
+
* long as the component does, so something has to revert it when the component
|
|
512
|
+
* goes.
|
|
513
|
+
*/
|
|
514
|
+
const MOUNT_HOOKS = new Set([
|
|
515
|
+
"onMounted",
|
|
516
|
+
"onMount",
|
|
517
|
+
"onActivated",
|
|
518
|
+
"useEffect",
|
|
519
|
+
"useLayoutEffect",
|
|
520
|
+
"$effect",
|
|
521
|
+
]);
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Which mount hook `node` was created in, or null. Astro has no hook: its
|
|
525
|
+
* equivalent is a listener for `astro:page-load`, which fires on the first load
|
|
526
|
+
* and after every view transition.
|
|
527
|
+
*/
|
|
528
|
+
function mountHook(ast, node) {
|
|
529
|
+
/** Nearest first, so a function's own parent is the entry after it. */
|
|
530
|
+
const ancestors = ancestorsOf(ast, node);
|
|
531
|
+
|
|
532
|
+
for (let i = 0; i < ancestors.length; i += 1) {
|
|
533
|
+
if (!isFunction(ancestors[i])) continue;
|
|
534
|
+
const parent = ancestors[i + 1];
|
|
535
|
+
if (parent?.type !== "CallExpression") continue;
|
|
536
|
+
|
|
537
|
+
const name = calleeName(parent);
|
|
538
|
+
if (MOUNT_HOOKS.has(name)) return `\`${name}\``;
|
|
539
|
+
if (
|
|
540
|
+
name?.endsWith("addEventListener") &&
|
|
541
|
+
staticString(parent.arguments[0]) === "astro:page-load"
|
|
542
|
+
) {
|
|
543
|
+
return "the `astro:page-load` handler";
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return null;
|
|
548
|
+
}
|
|
549
|
+
|
|
496
550
|
export const RULES = [
|
|
497
551
|
// --- Lifecycle ------------------------------------------------------------
|
|
498
552
|
{
|
|
@@ -575,6 +629,40 @@ export const RULES = [
|
|
|
575
629
|
}));
|
|
576
630
|
},
|
|
577
631
|
},
|
|
632
|
+
{
|
|
633
|
+
id: "unreverted-context",
|
|
634
|
+
level: "warn",
|
|
635
|
+
description:
|
|
636
|
+
"A `gsap.context` created on mount — `onMounted`, `onMount`, `useEffect`, `astro:page-load` — that nothing reverts",
|
|
637
|
+
/**
|
|
638
|
+
* Every adapter's teardown rule, in one shape: a context built when the
|
|
639
|
+
* component mounts holds every tween, ScrollTrigger and matchMedia made
|
|
640
|
+
* inside it, and reverting it is the whole cleanup. Nothing reverts it here.
|
|
641
|
+
*
|
|
642
|
+
* Only contexts created in a mount hook are read. A context built in a
|
|
643
|
+
* function the module exports — the vanilla `mountReveal(root)` shape — hands
|
|
644
|
+
* its teardown to the caller, and this file cannot see whether the caller
|
|
645
|
+
* calls it.
|
|
646
|
+
*/
|
|
647
|
+
test(file) {
|
|
648
|
+
const contexts = findAll(
|
|
649
|
+
file.ast,
|
|
650
|
+
(node) => calleeName(node) === "gsap.context",
|
|
651
|
+
);
|
|
652
|
+
|
|
653
|
+
return contexts.flatMap((call) => {
|
|
654
|
+
const hook = mountHook(file.ast, call);
|
|
655
|
+
if (!hook || tornDown(file.ast, call)) return [];
|
|
656
|
+
return [
|
|
657
|
+
{
|
|
658
|
+
index: call.start,
|
|
659
|
+
message: `The \`gsap.context\` created in ${hook} is never reverted.`,
|
|
660
|
+
hint: HINT.unrevertedContext,
|
|
661
|
+
},
|
|
662
|
+
];
|
|
663
|
+
});
|
|
664
|
+
},
|
|
665
|
+
},
|
|
578
666
|
{
|
|
579
667
|
id: "dangling-listener",
|
|
580
668
|
level: "warn",
|
|
@@ -699,14 +787,20 @@ export const RULES = [
|
|
|
699
787
|
);
|
|
700
788
|
});
|
|
701
789
|
|
|
702
|
-
return handlersFor(file.ast, STATE_HOT_EVENTS, {
|
|
790
|
+
return handlersFor(file.ast, STATE_HOT_EVENTS, {
|
|
791
|
+
followNames: false,
|
|
792
|
+
perFrame: true,
|
|
793
|
+
}).flatMap(
|
|
703
794
|
(handler) =>
|
|
704
795
|
setters
|
|
705
796
|
.filter((setter) => contains(handler.node, setter))
|
|
706
797
|
.filter((setter) => !debounced(handler, setter))
|
|
707
798
|
.map((setter) => ({
|
|
708
799
|
index: setter.start,
|
|
709
|
-
message:
|
|
800
|
+
message:
|
|
801
|
+
handler.name === "useFrame"
|
|
802
|
+
? "React state setter inside a `useFrame` callback, which runs every frame."
|
|
803
|
+
: `React state setter inside a ${handler.name} handler.`,
|
|
710
804
|
hint: HINT.statePerEvent,
|
|
711
805
|
})),
|
|
712
806
|
);
|
|
@@ -12,7 +12,19 @@ import { extname, join, relative } from "node:path";
|
|
|
12
12
|
|
|
13
13
|
import { parse } from "./ast.mjs";
|
|
14
14
|
|
|
15
|
-
const SOURCE_EXTENSIONS = new Set([
|
|
15
|
+
const SOURCE_EXTENSIONS = new Set([
|
|
16
|
+
".ts",
|
|
17
|
+
".tsx",
|
|
18
|
+
".js",
|
|
19
|
+
".jsx",
|
|
20
|
+
".mjs",
|
|
21
|
+
".vue",
|
|
22
|
+
".svelte",
|
|
23
|
+
".astro",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/** A single-file component: only its `<script>` blocks are code. */
|
|
27
|
+
const SINGLE_FILE = /\.(?:vue|svelte|astro)$/;
|
|
16
28
|
const SKIP_DIRECTORIES = new Set([
|
|
17
29
|
"node_modules",
|
|
18
30
|
".next",
|
|
@@ -131,6 +143,41 @@ export function lineAt(source, index) {
|
|
|
131
143
|
return line;
|
|
132
144
|
}
|
|
133
145
|
|
|
146
|
+
/**
|
|
147
|
+
* A single-file component with everything outside its `<script>` blocks blanked
|
|
148
|
+
* out, keeping every newline and every offset — so what reaches the parser is
|
|
149
|
+
* JavaScript, and a finding still points at the right line of the `.vue`,
|
|
150
|
+
* `.svelte` or `.astro` file.
|
|
151
|
+
*
|
|
152
|
+
* A `<script>` whose `type` is not JavaScript — JSON-LD, an import map, a
|
|
153
|
+
* template — is blanked with the markup. Astro's `---` frontmatter runs on the
|
|
154
|
+
* server and animates nothing, so it is left out too; the client `<script>` is
|
|
155
|
+
* what this reads.
|
|
156
|
+
*/
|
|
157
|
+
export function scriptsOnly(raw) {
|
|
158
|
+
const blank = (text) => text.replace(/[^\n]/g, " ");
|
|
159
|
+
const JAVASCRIPT = /^(?:module|text\/javascript|application\/javascript|ts|tsx)$/i;
|
|
160
|
+
const open = /<script\b([^>]*)>/gi;
|
|
161
|
+
|
|
162
|
+
let out = "";
|
|
163
|
+
let index = 0;
|
|
164
|
+
|
|
165
|
+
for (let tag = open.exec(raw); tag; tag = open.exec(raw)) {
|
|
166
|
+
const start = tag.index + tag[0].length;
|
|
167
|
+
const close = raw.indexOf("</script", start);
|
|
168
|
+
const end = close === -1 ? raw.length : close;
|
|
169
|
+
const type = tag[1].match(/\btype\s*=\s*["']([^"']*)["']/i)?.[1];
|
|
170
|
+
const code = !type || JAVASCRIPT.test(type.trim());
|
|
171
|
+
|
|
172
|
+
out += blank(raw.slice(index, start));
|
|
173
|
+
out += code ? raw.slice(start, end) : blank(raw.slice(start, end));
|
|
174
|
+
index = end;
|
|
175
|
+
open.lastIndex = end;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return out + blank(raw.slice(index));
|
|
179
|
+
}
|
|
180
|
+
|
|
134
181
|
/**
|
|
135
182
|
* A file plus everything the rules need, from its text.
|
|
136
183
|
*
|
|
@@ -139,7 +186,8 @@ export function lineAt(source, index) {
|
|
|
139
186
|
* points run the rules on the same parse of the same characters.
|
|
140
187
|
*/
|
|
141
188
|
export function fromText(raw, path, display = path) {
|
|
142
|
-
const
|
|
189
|
+
const source = SINGLE_FILE.test(path) ? scriptsOnly(raw) : raw;
|
|
190
|
+
const code = stripComments(source);
|
|
143
191
|
|
|
144
192
|
/**
|
|
145
193
|
* Parsed on first use, and once. Most files in a project never mention GSAP,
|
|
@@ -151,7 +199,7 @@ export function fromText(raw, path, display = path) {
|
|
|
151
199
|
const tree = () => {
|
|
152
200
|
if (parsed === undefined) {
|
|
153
201
|
try {
|
|
154
|
-
parsed = { ...parse(
|
|
202
|
+
parsed = { ...parse(source, path), error: null };
|
|
155
203
|
} catch (error) {
|
|
156
204
|
parsed = {
|
|
157
205
|
ast: null,
|
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
# Frameworks other than React
|
|
2
|
-
|
|
3
|
-
GSAP has no framework. What changes between frameworks is only **where an
|
|
4
|
-
animation is created and where it is torn down** — the lifecycle. Everything
|
|
5
|
-
else in this skill applies unchanged: motion design, timelines, ScrollTrigger,
|
|
6
|
-
SVG, text, Flip, performance and accessibility.
|
|
7
|
-
|
|
8
|
-
One pattern sits under every framework below:
|
|
9
|
-
|
|
10
|
-
1. Create animations inside `gsap.context(fn, root)`, so selector strings
|
|
11
|
-
resolve inside the component's own root element.
|
|
12
|
-
2. Build the reduced-motion branch with `gsap.matchMedia()` inside that context,
|
|
13
|
-
so it goes with it.
|
|
14
|
-
3. Call `ctx.revert()` when the component goes away. That undoes every tween,
|
|
15
|
-
ScrollTrigger, SplitText and matchMedia created inside, and restores inline
|
|
16
|
-
styles.
|
|
17
|
-
|
|
18
|
-
Timers, raw `addEventListener` calls and observers are not GSAP's — clear them
|
|
19
|
-
yourself in the same cleanup.
|
|
20
|
-
|
|
21
|
-
## Vanilla JavaScript
|
|
22
|
-
|
|
23
|
-
```js
|
|
24
|
-
import gsap from "gsap";
|
|
25
|
-
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
|
26
|
-
|
|
27
|
-
gsap.registerPlugin(ScrollTrigger);
|
|
28
|
-
|
|
29
|
-
export function mountReveal(root) {
|
|
30
|
-
const ctx = gsap.context(() => {
|
|
31
|
-
const mm = gsap.matchMedia();
|
|
32
|
-
mm.add(
|
|
33
|
-
{
|
|
34
|
-
motion: "(prefers-reduced-motion: no-preference)",
|
|
35
|
-
reduced: "(prefers-reduced-motion: reduce)",
|
|
36
|
-
},
|
|
37
|
-
({ conditions }) => {
|
|
38
|
-
if (conditions.reduced) {
|
|
39
|
-
gsap.set("[data-item]", { autoAlpha: 1, y: 0 });
|
|
40
|
-
return;
|
|
41
|
-
}
|
|
42
|
-
gsap.from("[data-item]", {
|
|
43
|
-
autoAlpha: 0,
|
|
44
|
-
y: 24,
|
|
45
|
-
stagger: { amount: 0.4 },
|
|
46
|
-
scrollTrigger: { trigger: root, start: "top 75%" },
|
|
47
|
-
});
|
|
48
|
-
},
|
|
49
|
-
);
|
|
50
|
-
}, root);
|
|
51
|
-
|
|
52
|
-
return () => ctx.revert(); // call when root is removed
|
|
53
|
-
}
|
|
54
|
-
```
|
|
55
|
-
|
|
56
|
-
A static page that never removes the element can skip the cleanup. Anything that
|
|
57
|
-
swaps content — a client-side router, HTMX, Turbo, a modal — cannot.
|
|
58
|
-
|
|
59
|
-
## Vue 3 and Nuxt
|
|
60
|
-
|
|
61
|
-
```vue
|
|
62
|
-
<script setup>
|
|
63
|
-
import gsap from "gsap";
|
|
64
|
-
import { onMounted, onUnmounted, ref } from "vue";
|
|
65
|
-
|
|
66
|
-
const root = ref(null);
|
|
67
|
-
let ctx;
|
|
68
|
-
|
|
69
|
-
onMounted(() => {
|
|
70
|
-
ctx = gsap.context(() => {
|
|
71
|
-
gsap.from("[data-item]", { autoAlpha: 0, y: 24, stagger: 0.06 });
|
|
72
|
-
}, root.value);
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
onUnmounted(() => ctx?.revert());
|
|
76
|
-
</script>
|
|
77
|
-
|
|
78
|
-
<template>
|
|
79
|
-
<section ref="root">…</section>
|
|
80
|
-
</template>
|
|
81
|
-
```
|
|
82
|
-
|
|
83
|
-
- `onMounted` never runs during server rendering, so this is safe under Nuxt as
|
|
84
|
-
written.
|
|
85
|
-
- Animation that reacts to state belongs in a `watch` that adds its tweens to
|
|
86
|
-
the same context with `ctx.add(() => { … })`, so they are reverted with it.
|
|
87
|
-
|
|
88
|
-
## Svelte
|
|
89
|
-
|
|
90
|
-
```svelte
|
|
91
|
-
<script>
|
|
92
|
-
import gsap from "gsap";
|
|
93
|
-
import { onMount } from "svelte";
|
|
94
|
-
|
|
95
|
-
let root;
|
|
96
|
-
|
|
97
|
-
onMount(() => {
|
|
98
|
-
const ctx = gsap.context(() => {
|
|
99
|
-
gsap.from("[data-item]", { autoAlpha: 0, y: 24, stagger: 0.06 });
|
|
100
|
-
}, root);
|
|
101
|
-
return () => ctx.revert();
|
|
102
|
-
});
|
|
103
|
-
</script>
|
|
104
|
-
|
|
105
|
-
<section bind:this={root}>…</section>
|
|
106
|
-
```
|
|
107
|
-
|
|
108
|
-
A function returned from `onMount` runs when the component is destroyed.
|
|
109
|
-
`onMount` does not run during server rendering. The markup uses Svelte's
|
|
110
|
-
classic syntax; check how your Svelte version declares a `bind:this` variable
|
|
111
|
-
before copying it.
|
|
112
|
-
|
|
113
|
-
## Astro
|
|
114
|
-
|
|
115
|
-
An Astro `<script>` runs once. With view transitions enabled, the page is
|
|
116
|
-
swapped without a reload, so build on each page load and revert before each
|
|
117
|
-
swap:
|
|
118
|
-
|
|
119
|
-
```astro
|
|
120
|
-
<script>
|
|
121
|
-
import gsap from "gsap";
|
|
122
|
-
|
|
123
|
-
let ctx;
|
|
124
|
-
|
|
125
|
-
document.addEventListener("astro:page-load", () => {
|
|
126
|
-
ctx = gsap.context(() => {
|
|
127
|
-
gsap.from("[data-item]", { autoAlpha: 0, y: 24, stagger: 0.06 });
|
|
128
|
-
});
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
document.addEventListener("astro:before-swap", () => ctx?.revert());
|
|
132
|
-
</script>
|
|
133
|
-
```
|
|
134
|
-
|
|
135
|
-
Inside a React, Vue or Svelte island, use that framework's section instead.
|
|
136
|
-
|
|
137
|
-
## Mounting twice
|
|
138
|
-
|
|
139
|
-
Selector strings are scoped to the context's root, but `motionPath.path` and
|
|
140
|
-
`morphSVG.shape` are resolved by the plugins against the whole document. A
|
|
141
|
-
component rendered twice with a hardcoded id drives the first instance's
|
|
142
|
-
geometry. Generate an id per instance in every framework, not only React.
|
|
143
|
-
|
|
144
|
-
## What the audit covers here
|
|
145
|
-
|
|
146
|
-
- `audit-gsap.mjs` reads `.js`, `.ts`, `.jsx`, `.tsx` and `.mjs` files only.
|
|
147
|
-
Logic inside `.vue`, `.svelte` or `.astro` files is not scanned — keep the
|
|
148
|
-
animation in a sibling module, like `mountReveal` above, if you want it
|
|
149
|
-
checked.
|
|
150
|
-
- Its React-specific rules — `orphan-tween`, `state-per-event` and
|
|
151
|
-
`shared-plugin-id` — apply only to React files. The rest apply to every file
|
|
152
|
-
it reads: cost, loops, scrub, listeners, unmanaged instances, plugin
|
|
153
|
-
registration, reduced motion and shipped dev tooling.
|
|
@@ -1,234 +0,0 @@
|
|
|
1
|
-
# React and Next.js
|
|
2
|
-
|
|
3
|
-
Written for the Next.js App Router with React 19 and Server Components, and
|
|
4
|
-
verified against `@gsap/react` 2.1. Most of it applies to any React app; the
|
|
5
|
-
RSC sections apply only where Server Components exist. Animation is always a
|
|
6
|
-
client concern, and the interesting problems are all lifecycle. Not using
|
|
7
|
-
React? Read [frameworks.md](frameworks.md) instead.
|
|
8
|
-
|
|
9
|
-
## `useGSAP`
|
|
10
|
-
|
|
11
|
-
`useGSAP`, from `@gsap/react`, is `useLayoutEffect` with an SSR fallback
|
|
12
|
-
plus automatic `gsap.context()` cleanup — which is what makes it survive React
|
|
13
|
-
19 StrictMode's double invoke.
|
|
14
|
-
|
|
15
|
-
```ts
|
|
16
|
-
"use client";
|
|
17
|
-
|
|
18
|
-
import { useGSAP } from "@gsap/react";
|
|
19
|
-
import gsap from "gsap";
|
|
20
|
-
import { useRef } from "react";
|
|
21
|
-
|
|
22
|
-
gsap.registerPlugin(useGSAP); // module scope, once
|
|
23
|
-
|
|
24
|
-
export function useThing() {
|
|
25
|
-
const root = useRef<HTMLDivElement>(null);
|
|
26
|
-
|
|
27
|
-
useGSAP(
|
|
28
|
-
() => {
|
|
29
|
-
gsap.from("[data-item]", { y: 20, stagger: 0.05 }); // scoped to root
|
|
30
|
-
return () => { /* anything the context does not own */ };
|
|
31
|
-
},
|
|
32
|
-
{ scope: root },
|
|
33
|
-
);
|
|
34
|
-
|
|
35
|
-
return root;
|
|
36
|
-
}
|
|
37
|
-
```
|
|
38
|
-
|
|
39
|
-
`{ scope: root }` is not optional in practice: it makes selector strings resolve
|
|
40
|
-
inside the component, so two instances on a page do not animate each other's
|
|
41
|
-
elements.
|
|
42
|
-
|
|
43
|
-
Options: `scope`, `dependencies`, `revertOnUpdate`. With `dependencies`, the
|
|
44
|
-
body re-runs when they change; `revertOnUpdate: true` also reverts the previous
|
|
45
|
-
run first.
|
|
46
|
-
|
|
47
|
-
Being a layout effect matters: it runs **before paint**, so a `from` or a `set`
|
|
48
|
-
lands without a flash of the end state.
|
|
49
|
-
|
|
50
|
-
## `contextSafe`
|
|
51
|
-
|
|
52
|
-
Anything that creates a tween *outside* the hook body — an event handler, a
|
|
53
|
-
promise, a callback — is created outside the context and never reverted.
|
|
54
|
-
|
|
55
|
-
```ts
|
|
56
|
-
const { contextSafe } = useGSAP(() => { ... }, { scope: root });
|
|
57
|
-
|
|
58
|
-
const onEnter = contextSafe(() => {
|
|
59
|
-
gsap.to(el.current, { scale: 1.05 }); // now owned by the context
|
|
60
|
-
});
|
|
61
|
-
```
|
|
62
|
-
|
|
63
|
-
Two things to get right:
|
|
64
|
-
|
|
65
|
-
- **Only wrap functions that create animations.** Playing a timeline that
|
|
66
|
-
already exists creates nothing and needs no wrapper.
|
|
67
|
-
- **`contextSafe` runs during render.** Reading a ref inside the callback you
|
|
68
|
-
pass it trips React's "cannot access refs during render" lint rule. Build the
|
|
69
|
-
timeline inside the hook body, keep it on a ref, and expose a plain
|
|
70
|
-
`useCallback` that plays it.
|
|
71
|
-
|
|
72
|
-
## Refs, never state
|
|
73
|
-
|
|
74
|
-
React state for a value that changes every frame is a re-render per frame.
|
|
75
|
-
|
|
76
|
-
```ts
|
|
77
|
-
// Wrong
|
|
78
|
-
const [x, setX] = useState(0);
|
|
79
|
-
onMouseMove = (e) => setX(e.clientX);
|
|
80
|
-
|
|
81
|
-
// Right
|
|
82
|
-
const moveX = gsap.quickTo(el.current, "x", { duration: 0.5, ease: "power3" });
|
|
83
|
-
onMouseMove = (e) => moveX(e.clientX);
|
|
84
|
-
```
|
|
85
|
-
|
|
86
|
-
State is for *what exists* — is the modal open, which tab is active. GSAP owns
|
|
87
|
-
*how it moves*. Crossing that line is the most common performance bug in
|
|
88
|
-
animated React.
|
|
89
|
-
|
|
90
|
-
## The RSC boundary
|
|
91
|
-
|
|
92
|
-
A component that animates is `"use client"`. Keep it a leaf so the page above it
|
|
93
|
-
stays a Server Component.
|
|
94
|
-
|
|
95
|
-
**If the target is currently a Server Component, converting it is part of the
|
|
96
|
-
cost, and you have to price it before you design.** Two questions:
|
|
97
|
-
|
|
98
|
-
1. *Is the library already on this route?* Grep for existing GSAP use in the
|
|
99
|
-
sections the page renders. If a sibling already loads `gsap` and
|
|
100
|
-
`ScrollTrigger`, the marginal cost is this component's own JS — a kilobyte or
|
|
101
|
-
two — rather than thirty-odd. That usually settles the argument. If nothing
|
|
102
|
-
on the route loads GSAP yet, this animation is buying the whole library for
|
|
103
|
-
one reveal, and it had better be worth it.
|
|
104
|
-
2. *Does anything claim otherwise?* A comment saying the section ships no client
|
|
105
|
-
JavaScript stops being true the moment you add the hook. Update it in the
|
|
106
|
-
same change — a stale claim in a docblock is worse than none, because the
|
|
107
|
-
next person believes it.
|
|
108
|
-
|
|
109
|
-
State both in the Notes section of your answer. "It becomes a client component"
|
|
110
|
-
is a fact the reader needs, not a detail.
|
|
111
|
-
|
|
112
|
-
### Reaching the element
|
|
113
|
-
|
|
114
|
-
A shared component that does not forward props has nowhere to hang a `data-*`
|
|
115
|
-
attribute. Widening it to take the rest of its element's props is usually right
|
|
116
|
-
and is often already the house pattern — check a sibling before inventing one.
|
|
117
|
-
Finding the element positionally instead (`row.firstElementChild`,
|
|
118
|
-
`querySelectorAll("path")[3]`) is a selector that breaks silently the next time
|
|
119
|
-
the markup moves.
|
|
120
|
-
|
|
121
|
-
Two consequences worth planning for:
|
|
122
|
-
|
|
123
|
-
- **Data and copy come down as props.** A client component importing a content
|
|
124
|
-
or locale module bundles every locale into the browser payload. Resolve on the
|
|
125
|
-
server, pass what the visitor needs. The same applies to large generated data
|
|
126
|
-
— path tables, keyframe sets: hand them down rather than importing them
|
|
127
|
-
client-side.
|
|
128
|
-
- **`"use client"` module scope still runs during SSR.** Registering plugins
|
|
129
|
-
there is fine — GSAP defers anything touching `window` — but reading `window`
|
|
130
|
-
or `document` at module scope is not.
|
|
131
|
-
|
|
132
|
-
## Reading the environment
|
|
133
|
-
|
|
134
|
-
Read direction and preferences from the DOM inside the effect, not from a
|
|
135
|
-
prop or a locale constant:
|
|
136
|
-
|
|
137
|
-
```ts
|
|
138
|
-
const isRtl = document.documentElement.dir === "rtl";
|
|
139
|
-
```
|
|
140
|
-
|
|
141
|
-
The layout stamps `dir` before first paint, and reading it there keeps the hook
|
|
142
|
-
independent of the i18n library.
|
|
143
|
-
|
|
144
|
-
## Cleanup: what the context already owns
|
|
145
|
-
|
|
146
|
-
Wider than it is usually given credit for, and worth knowing precisely —
|
|
147
|
-
otherwise you write cleanup that does nothing and still miss the real leaks.
|
|
148
|
-
|
|
149
|
-
While a context's function runs, GSAP points `_context` at that context, and
|
|
150
|
-
these register themselves with it on construction. `context.revert()` then
|
|
151
|
-
reverts them:
|
|
152
|
-
|
|
153
|
-
- tweens and timelines
|
|
154
|
-
- `ScrollTrigger`
|
|
155
|
-
- `Observer`
|
|
156
|
-
- `Draggable`
|
|
157
|
-
- `SplitText`
|
|
158
|
-
- `gsap.matchMedia()`
|
|
159
|
-
|
|
160
|
-
So inside a `useGSAP` body, none of those need a manual `.kill()` or
|
|
161
|
-
`.revert()`. An explicit one is redundant rather than wrong.
|
|
162
|
-
|
|
163
|
-
**These are genuinely yours**, because GSAP never sees them:
|
|
164
|
-
|
|
165
|
-
- `setTimeout` / `setInterval` — clear them
|
|
166
|
-
- `addEventListener` on `window` or `document` — remove them
|
|
167
|
-
- `ResizeObserver` / `IntersectionObserver` — `.disconnect()`
|
|
168
|
-
- Three.js geometries, materials, textures — dispose them
|
|
169
|
-
- **anything created outside the context body** — module scope, a plain
|
|
170
|
-
`useEffect`, or a handler that never went through `contextSafe`. That last one
|
|
171
|
-
is the real leak, and the one people write cleanup for the wrong things
|
|
172
|
-
instead of catching.
|
|
173
|
-
|
|
174
|
-
```ts
|
|
175
|
-
useGSAP(() => {
|
|
176
|
-
const mm = gsap.matchMedia(); // reverted with the context
|
|
177
|
-
const io = new ResizeObserver(onResize); // not — disconnect it
|
|
178
|
-
mm.add({ ... }, () => { ... });
|
|
179
|
-
io.observe(root.current!);
|
|
180
|
-
return () => io.disconnect();
|
|
181
|
-
}, { scope: root });
|
|
182
|
-
```
|
|
183
|
-
|
|
184
|
-
`node .claude/skills/gsap-creative-animation/scripts/audit-gsap.mjs <path>`
|
|
185
|
-
checks this distinction, among others.
|
|
186
|
-
|
|
187
|
-
## Unique ids
|
|
188
|
-
|
|
189
|
-
A scope does **not** reach inside plugin config. `motionPath.path` and
|
|
190
|
-
`morphSVG.shape` are resolved by the plugin against the whole document, so a
|
|
191
|
-
hardcoded id in a component rendered twice makes both instances drive the first
|
|
192
|
-
one's geometry — silently.
|
|
193
|
-
|
|
194
|
-
```ts
|
|
195
|
-
const uid = `rs${useId().replace(/[^a-zA-Z0-9]/g, "")}`;
|
|
196
|
-
```
|
|
197
|
-
|
|
198
|
-
React's generated id carries delimiters a CSS selector cannot hold, so strip it
|
|
199
|
-
to word characters. It is stable across server and client, which a counter is
|
|
200
|
-
not. Then `id={`${uid}-track`}` in the markup and `#${uid}-track` in the config.
|
|
201
|
-
|
|
202
|
-
## Route transitions
|
|
203
|
-
|
|
204
|
-
The App Router unmounts the old tree before the new one paints, so an exit
|
|
205
|
-
animation has nowhere to play. Options, in order of how much they cost:
|
|
206
|
-
|
|
207
|
-
1. **Animate entrance only.** Keyed off `usePathname()`. Honest, cheap, and what
|
|
208
|
-
most sites actually want.
|
|
209
|
-
2. **An overlay that outlives the route.** Mounted in the layout, so it is not
|
|
210
|
-
unmounted by the navigation. See [page-transition](../preset/page-transition.md).
|
|
211
|
-
3. **`router.push` behind a played-out timeline.** Intercept, play the exit,
|
|
212
|
-
navigate on complete. Breaks the back button's feel — use sparingly.
|
|
213
|
-
|
|
214
|
-
After any navigation, call `ScrollTrigger.refresh()` once the new layout has
|
|
215
|
-
settled.
|
|
216
|
-
|
|
217
|
-
## Hydration
|
|
218
|
-
|
|
219
|
-
Anything read from `localStorage`, `sessionStorage`, or `matchMedia` during
|
|
220
|
-
render makes the server and client disagree. Render the neutral state, then
|
|
221
|
-
correct it in the layout effect — before paint, so nothing is seen.
|
|
222
|
-
|
|
223
|
-
## Failures
|
|
224
|
-
|
|
225
|
-
| Symptom | Cause |
|
|
226
|
-
|---|---|
|
|
227
|
-
| Animation runs twice in dev | tweens created outside `useGSAP` — StrictMode |
|
|
228
|
-
| Animation leaks after navigation | ScrollTrigger or listener created outside the scope |
|
|
229
|
-
| Flash of the end state | `from` outside a layout effect |
|
|
230
|
-
| Two components animate each other | missing `scope`, or a shared hardcoded id |
|
|
231
|
-
| Hydration mismatch | storage or media query read during render |
|
|
232
|
-
| Janky pointer effect | React state in the handler |
|
|
233
|
-
| Hover sticks halfway | no `overwrite: "auto"` on the retriggered tween |
|
|
234
|
-
| `gsap.set` lands but `gsap.to` sits mid-flight — only under test | the harness waited on a timer while rAF was throttled, so the ticker never ran. Not a lifecycle bug: wait in frames — see "Timing in milliseconds, settling in frames" in performance.md |
|