@mehshekari/gsap-motion 3.1.0 → 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 +4 -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/audit-gsap.mjs +21 -59
- package/skill/scripts/lib/ast.mjs +7 -1
- package/skill/scripts/lib/audit.mjs +91 -0
- package/skill/scripts/lib/rules.mjs +129 -3
- package/skill/scripts/lib/source.mjs +79 -8
- package/skill/reference/frameworks.md +0 -153
- package/skill/reference/react-nextjs.md +0 -234
- package/skill/reference/three-r3f.md +0 -161
package/README.md
CHANGED
|
@@ -28,7 +28,7 @@ The audit needs no install. Pin the version, so a new rule cannot fail your
|
|
|
28
28
|
build without warning:
|
|
29
29
|
|
|
30
30
|
```json
|
|
31
|
-
"lint": "eslint && npx @mehshekari/gsap-motion@3.
|
|
31
|
+
"lint": "eslint && npx @mehshekari/gsap-motion@3.2.0 audit src --quiet"
|
|
32
32
|
```
|
|
33
33
|
|
|
34
34
|
Or install it with `npm install --save-dev --save-exact @mehshekari/gsap-motion`.
|
|
@@ -38,6 +38,9 @@ The command it adds is `gsap-motion`:
|
|
|
38
38
|
"lint": "eslint && gsap-motion audit src --quiet"
|
|
39
39
|
```
|
|
40
40
|
|
|
41
|
+
The same rules also run inside ESLint, so findings appear in your editor:
|
|
42
|
+
[`@mehshekari/eslint-plugin-gsap-motion`](https://www.npmjs.com/package/@mehshekari/eslint-plugin-gsap-motion).
|
|
43
|
+
|
|
41
44
|
## Claude Code plugin
|
|
42
45
|
|
|
43
46
|
The same skill is also a Claude Code plugin:
|
package/bin/gsap-motion.mjs
CHANGED
|
@@ -257,6 +257,23 @@ function doctor() {
|
|
|
257
257
|
);
|
|
258
258
|
}
|
|
259
259
|
|
|
260
|
+
/**
|
|
261
|
+
* Which adapters the skill loads here, read the way SKILL.md's setup step 3
|
|
262
|
+
* describes. Deterministic, so the stack is looked up rather than guessed.
|
|
263
|
+
*/
|
|
264
|
+
const picked = new Set();
|
|
265
|
+
if (deps.next) picked.add("react").add("next");
|
|
266
|
+
if (deps["@react-three/fiber"]) picked.add("react").add("r3f");
|
|
267
|
+
else if (deps.three) picked.add("three");
|
|
268
|
+
if (deps.react) picked.add("react");
|
|
269
|
+
if (deps.vue || deps.nuxt) picked.add("vue");
|
|
270
|
+
if (deps.svelte || deps["@sveltejs/kit"]) picked.add("svelte");
|
|
271
|
+
if (deps.astro) picked.add("astro");
|
|
272
|
+
if (picked.size === 0) picked.add("vanilla");
|
|
273
|
+
|
|
274
|
+
const ORDER = ["react", "next", "r3f", "three", "vue", "svelte", "astro", "vanilla"];
|
|
275
|
+
report("ok", `Adapters: ${ORDER.filter((name) => picked.has(name)).join(", ")}`);
|
|
276
|
+
|
|
260
277
|
const rules = existsSync(join(process.cwd(), "ANIMATION.md"));
|
|
261
278
|
report(
|
|
262
279
|
rules ? "ok" : "info",
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -4,7 +4,7 @@ description: Senior motion engineer for GSAP. Turns a visual or interaction idea
|
|
|
4
4
|
license: MIT. See LICENSE; GSAP itself is licensed separately, see NOTICE.md
|
|
5
5
|
compatibility: Any Agent Skills client; written for Claude Code. GSAP 3.13+, where every plugin is free. React guidance targets @gsap/react 2.x, React 18/19 and the Next.js App Router. The audit scripts need Node.js 18+ and nothing else.
|
|
6
6
|
metadata:
|
|
7
|
-
version: "3.
|
|
7
|
+
version: "3.2.0"
|
|
8
8
|
verified-gsap: "3.15"
|
|
9
9
|
verified-gsap-react: "2.1"
|
|
10
10
|
---
|
|
@@ -47,9 +47,13 @@ Core principles:
|
|
|
47
47
|
- **One element, one tween or interaction** — a hover, a fade, a retime:
|
|
48
48
|
[motion-design.md](reference/motion-design.md) and the command's own
|
|
49
49
|
reference. Nothing else.
|
|
50
|
-
- **A component or section:** also
|
|
51
|
-
[react
|
|
52
|
-
|
|
50
|
+
- **A component or section:** also this project's adapter, from
|
|
51
|
+
`package.json`: `next` → [react](adapter/react.md) +
|
|
52
|
+
[next](adapter/next.md); `@react-three/fiber` → react +
|
|
53
|
+
[r3f](adapter/r3f.md); `three` → [three](adapter/three.md); `nuxt` →
|
|
54
|
+
[vue](adapter/vue.md); `svelte` → [svelte](adapter/svelte.md); `astro` →
|
|
55
|
+
[astro](adapter/astro.md) plus any island's own; nothing detected →
|
|
56
|
+
[vanilla](adapter/vanilla.md).
|
|
53
57
|
- **A sequence, scroll scene, intro, transition or audit:** also
|
|
54
58
|
[core-gsap.md](reference/core-gsap.md), and what
|
|
55
59
|
[routing.md](reference/routing.md) adds for that request.
|
|
@@ -68,7 +72,7 @@ Core principles:
|
|
|
68
72
|
| `transition [target]` | Build | Page, route, modal and layout transitions | [reference/flip.md](reference/flip.md) |
|
|
69
73
|
| `loader` | Build | Loading and progress animation | [preset/loader.md](preset/loader.md) |
|
|
70
74
|
| `intro` | Build | Site or section entry sequence | [preset/cinematic.md](preset/cinematic.md) |
|
|
71
|
-
| `three [target]` | Build | GSAP orchestrating Three.js / R3F | [
|
|
75
|
+
| `three [target]` | Build | GSAP orchestrating Three.js / R3F | [adapter/three.md](adapter/three.md) |
|
|
72
76
|
| `audit [target]` | Evaluate | Check an existing animation: leaks, cost, a11y | [reference/performance.md](reference/performance.md) |
|
|
73
77
|
| `tune [target]` | Refine | Retime, re-ease, fix rhythm — no new motion | [reference/motion-design.md](reference/motion-design.md) |
|
|
74
78
|
| `strip [target]` | Fix | Remove motion that is not earning its place | [reference/motion-design.md](reference/motion-design.md) |
|
|
@@ -134,11 +138,11 @@ after that. Never quietly substitute, and never ship both.
|
|
|
134
138
|
[core-gsap](reference/core-gsap.md) · [timeline](reference/timeline.md)
|
|
135
139
|
- **Domains** — [scrolltrigger](reference/scrolltrigger.md) ·
|
|
136
140
|
[svg](reference/svg.md) · [text](reference/text.md) ·
|
|
137
|
-
[interaction](reference/interaction.md) · [flip](reference/flip.md)
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
141
|
+
[interaction](reference/interaction.md) · [flip](reference/flip.md)
|
|
142
|
+
- **Adapters**, one per stack, chosen in setup step 3 — the same questions for
|
|
143
|
+
each: lifecycle, scope, server rendering, per-frame values, routes, failures,
|
|
144
|
+
audit.
|
|
145
|
+
- **Constraints** — [performance](reference/performance.md) ·
|
|
142
146
|
[accessibility](reference/accessibility.md) ·
|
|
143
147
|
[project-rules](reference/project-rules.md)
|
|
144
148
|
- **Presets**, starting points to adapt — [reveal](preset/reveal.md) ·
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# Astro
|
|
2
|
+
|
|
3
|
+
Astro ships no JavaScript unless you ask for it, and its pages can be swapped
|
|
4
|
+
without a reload. Both facts decide where an animation lives.
|
|
5
|
+
|
|
6
|
+
## Where it is created
|
|
7
|
+
|
|
8
|
+
In a component's `<script>`, which Astro bundles and defers. With view
|
|
9
|
+
transitions enabled, a page swap does not re-run it, so build on the page-load
|
|
10
|
+
event rather than at the top level:
|
|
11
|
+
|
|
12
|
+
```astro
|
|
13
|
+
<script>
|
|
14
|
+
import gsap from "gsap";
|
|
15
|
+
|
|
16
|
+
let ctx;
|
|
17
|
+
|
|
18
|
+
document.addEventListener("astro:page-load", () => {
|
|
19
|
+
ctx = gsap.context(() => {
|
|
20
|
+
gsap.from("[data-item]", { autoAlpha: 0, y: 24, stagger: 0.06 });
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
document.addEventListener("astro:before-swap", () => ctx?.revert());
|
|
25
|
+
</script>
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
`astro:page-load` fires on the first load and after every swap, so this one
|
|
29
|
+
shape covers both. Without view transitions the event still fires once, so the
|
|
30
|
+
same code is correct either way.
|
|
31
|
+
|
|
32
|
+
Inside a React, Vue or Svelte island, use that framework's adapter instead —
|
|
33
|
+
the island owns its own lifecycle.
|
|
34
|
+
|
|
35
|
+
## Where it is torn down
|
|
36
|
+
|
|
37
|
+
`astro:before-swap`, with `ctx.revert()`. Skipping it leaves the previous page's
|
|
38
|
+
ScrollTriggers and listeners bound to elements that are about to be replaced,
|
|
39
|
+
and they accumulate for as long as the visit lasts.
|
|
40
|
+
|
|
41
|
+
Timers, listeners and observers are yours, in the same handler.
|
|
42
|
+
|
|
43
|
+
## Scope
|
|
44
|
+
|
|
45
|
+
Astro's `<script>` is page-wide, not component-scoped: a component used twice on
|
|
46
|
+
a page runs its script once, and selector strings match both instances. Either
|
|
47
|
+
animate them as a set — which is usually what you want — or pass the root in
|
|
48
|
+
with a `data-*` attribute and build one context per matching element.
|
|
49
|
+
|
|
50
|
+
Plugin config is not scoped either, so generate ids per instance rather than
|
|
51
|
+
hardcoding them.
|
|
52
|
+
|
|
53
|
+
## Server rendering and hydration
|
|
54
|
+
|
|
55
|
+
Astro components render to HTML on the server and ship no client JavaScript of
|
|
56
|
+
their own, so there is no hydration to mismatch. The script runs only in the
|
|
57
|
+
browser: reading `window` in it is safe, and the markup it animates is already
|
|
58
|
+
painted.
|
|
59
|
+
|
|
60
|
+
`client:*` directives decide when an island hydrates. An animation inside one
|
|
61
|
+
does not run until it does — `client:visible` on a section that animates on
|
|
62
|
+
entrance is a race worth avoiding; prefer `client:load` there, or keep the
|
|
63
|
+
animation in the page's own script.
|
|
64
|
+
|
|
65
|
+
## Reaching the element
|
|
66
|
+
|
|
67
|
+
`data-*` attributes in the markup, queried inside the context. Astro scopes its
|
|
68
|
+
component styles by adding its own attributes to elements, so a class in the
|
|
69
|
+
markup is not always the class in the DOM — a `data-*` attribute you wrote is.
|
|
70
|
+
|
|
71
|
+
## Every frame
|
|
72
|
+
|
|
73
|
+
`gsap.quickTo` or `quickSetter`, created once inside the context. There is no
|
|
74
|
+
component state here to get wrong, which removes the most common React mistake
|
|
75
|
+
and leaves the ordinary one: allocating a tween per event.
|
|
76
|
+
|
|
77
|
+
## Page and route changes
|
|
78
|
+
|
|
79
|
+
The events are the contract:
|
|
80
|
+
|
|
81
|
+
- `astro:page-load` — build, on first load and after every swap
|
|
82
|
+
- `astro:before-swap` — revert, before the old page goes
|
|
83
|
+
- `astro:after-swap` — for anything that must run against the new DOM before it
|
|
84
|
+
paints, such as `ScrollTrigger.refresh()`
|
|
85
|
+
|
|
86
|
+
View transitions are opt-in: they come from Astro's router component in the
|
|
87
|
+
layout, named `<ClientRouter />` in current versions and `<ViewTransitions />`
|
|
88
|
+
in older ones. Check which one the project imports before telling anyone to add
|
|
89
|
+
it.
|
|
90
|
+
|
|
91
|
+
## Failures
|
|
92
|
+
|
|
93
|
+
| Symptom | Cause |
|
|
94
|
+
|---|---|
|
|
95
|
+
| Animation runs on the first page only | built at the top of `<script>` instead of on `astro:page-load` |
|
|
96
|
+
| Triggers pile up while browsing | no `astro:before-swap` revert |
|
|
97
|
+
| The script never runs | `is:inline`, which skips bundling, so the bare import fails |
|
|
98
|
+
| Both instances animate as one | a page-wide script with a document-wide selector |
|
|
99
|
+
| An entrance animation starts late | the island hydrates on `client:visible` |
|
|
100
|
+
|
|
101
|
+
## What the audit covers
|
|
102
|
+
|
|
103
|
+
From 3.2 the audit reads the `<script>` block of `.astro` files, with offsets
|
|
104
|
+
kept, so reported lines match the file. It checks that a context built on
|
|
105
|
+
`astro:page-load` is reverted on `astro:before-swap`, along with every rule that
|
|
106
|
+
is not React-specific.
|
|
107
|
+
|
|
108
|
+
## Versions
|
|
109
|
+
|
|
110
|
+
Verified against `gsap@3.15`. Written for `astro@7`; the swap events are the
|
|
111
|
+
same in Astro 5 and later, though the router component was renamed.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
# Next.js
|
|
2
|
+
|
|
3
|
+
The App Router, with Server Components. Read [react.md](react.md) first: this is
|
|
4
|
+
only what Next adds.
|
|
5
|
+
|
|
6
|
+
## Where it is created
|
|
7
|
+
|
|
8
|
+
In a `"use client"` component, in the `useGSAP` body react.md describes. Keep
|
|
9
|
+
that component a **leaf**, so the page above it stays a Server Component.
|
|
10
|
+
|
|
11
|
+
**Converting a Server Component is part of the cost, and it is priced before the
|
|
12
|
+
design.** *Is GSAP already on this route?* If a sibling section loads `gsap` and
|
|
13
|
+
`ScrollTrigger`, the marginal cost is this component's own JavaScript — a
|
|
14
|
+
kilobyte or two rather than thirty-odd, which usually settles the argument. If
|
|
15
|
+
nothing on the route loads GSAP, this animation buys the whole library for one
|
|
16
|
+
reveal. *Does anything claim otherwise?* A comment saying the section ships no
|
|
17
|
+
client JavaScript stops being true the moment you add the hook; update it in the
|
|
18
|
+
same change.
|
|
19
|
+
|
|
20
|
+
Say so in the Notes of your answer: "it becomes a client component" is a fact
|
|
21
|
+
the reader needs.
|
|
22
|
+
|
|
23
|
+
## Where it is torn down
|
|
24
|
+
|
|
25
|
+
By the context, as in react.md. A navigation unmounts the page, so anything that
|
|
26
|
+
must survive one belongs in the layout.
|
|
27
|
+
|
|
28
|
+
## Scope
|
|
29
|
+
|
|
30
|
+
As in react.md, and `useId` matters more here: the markup is rendered on the
|
|
31
|
+
server and again on the client, and a counter would disagree between them.
|
|
32
|
+
|
|
33
|
+
## Server rendering and hydration
|
|
34
|
+
|
|
35
|
+
- **`"use client"` module scope still runs during server rendering.**
|
|
36
|
+
Registering plugins there is fine — GSAP defers anything touching `window` —
|
|
37
|
+
but reading `window` or `document` at module scope is not.
|
|
38
|
+
- **Data and copy come down as props.** A client component importing a content
|
|
39
|
+
or locale module bundles every locale into the browser payload. Large
|
|
40
|
+
generated data — path tables, keyframe sets — is handed down too.
|
|
41
|
+
- Storage and media queries are read after mount, never during render.
|
|
42
|
+
|
|
43
|
+
## Reaching the element
|
|
44
|
+
|
|
45
|
+
`next/image` renders the `<img>` itself and controls its `src`, `srcset` and
|
|
46
|
+
sizing:
|
|
47
|
+
|
|
48
|
+
- **Animate a wrapper you own**, not the image, when the effect needs a mask, a
|
|
49
|
+
clip or a transform that must survive the placeholder-to-loaded swap. Under
|
|
50
|
+
`fill` the image is positioned against the nearest positioned ancestor, so
|
|
51
|
+
that wrapper is already there.
|
|
52
|
+
- **A reveal that depends on the image waits for load**, with a short budget
|
|
53
|
+
after which it plays anyway. A cached image can finish before the hook runs,
|
|
54
|
+
and a mask opening over an empty box reads as a bug.
|
|
55
|
+
- If the animation starts by hiding a hero image, **measure what it does to
|
|
56
|
+
LCP** first. A mask over an image that is already painted usually avoids the
|
|
57
|
+
question.
|
|
58
|
+
|
|
59
|
+
## Every frame
|
|
60
|
+
|
|
61
|
+
Refs and `quickTo`, never React state — and it costs more here, since state held
|
|
62
|
+
in a layout re-renders every page under it.
|
|
63
|
+
|
|
64
|
+
## Page and route changes
|
|
65
|
+
|
|
66
|
+
The App Router unmounts the old tree before the new one paints, so an exit
|
|
67
|
+
animation has nowhere to play. Options, by cost:
|
|
68
|
+
|
|
69
|
+
1. **Animate entrance only**, keyed off `usePathname()`. Honest, cheap, and what
|
|
70
|
+
most sites want.
|
|
71
|
+
2. **An overlay that outlives the route**, mounted in the layout so the
|
|
72
|
+
navigation does not unmount it. See
|
|
73
|
+
[page-transition](../preset/page-transition.md).
|
|
74
|
+
3. **`router.push` behind a played-out timeline** — intercept, play the exit,
|
|
75
|
+
navigate on complete. Breaks the back button's feel; use sparingly.
|
|
76
|
+
|
|
77
|
+
Then `ScrollTrigger.refresh()` once the new layout settles.
|
|
78
|
+
|
|
79
|
+
## Failures
|
|
80
|
+
|
|
81
|
+
| Symptom | Cause |
|
|
82
|
+
|---|---|
|
|
83
|
+
| `window is not defined` during a build | `window` read at module scope in a client component |
|
|
84
|
+
| Hydration mismatch | storage, a media query or a random value read during render |
|
|
85
|
+
| The whole page ships as client JavaScript | `"use client"` on the page or layout instead of a leaf |
|
|
86
|
+
| A reveal opens over an empty box | the tween did not wait for the image to load |
|
|
87
|
+
| Positions are wrong after a navigation | no `ScrollTrigger.refresh()` once it settled |
|
|
88
|
+
| The exit animation never plays | written in the page the navigation unmounts |
|
|
89
|
+
|
|
90
|
+
## What the audit covers
|
|
91
|
+
|
|
92
|
+
The React rules from react.md, on the same files. It reads `.ts`, `.tsx`, `.js`,
|
|
93
|
+
`.jsx` and `.mjs`, so `"use client"` placement, `next.config` and route
|
|
94
|
+
structure are yours to review.
|
|
95
|
+
|
|
96
|
+
## Versions
|
|
97
|
+
|
|
98
|
+
Verified against `gsap@3.15` and `@gsap/react@2.1`. Written for `next@15` and
|
|
99
|
+
`next@16`, App Router.
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
# React Three Fiber
|
|
2
|
+
|
|
3
|
+
R3F is React's renderer for Three, so both lifecycles apply: read
|
|
4
|
+
[react.md](react.md) for the context and cleanup rules, and
|
|
5
|
+
[three.md](three.md) for what GSAP may tween on a Three object. This file is the
|
|
6
|
+
part that is neither.
|
|
7
|
+
|
|
8
|
+
## Where it is created
|
|
9
|
+
|
|
10
|
+
In a `useGSAP` body inside the `<Canvas>` subtree, against refs to the objects:
|
|
11
|
+
|
|
12
|
+
```tsx
|
|
13
|
+
"use client";
|
|
14
|
+
|
|
15
|
+
import { useGSAP } from "@gsap/react";
|
|
16
|
+
import gsap from "gsap";
|
|
17
|
+
import { useRef } from "react";
|
|
18
|
+
import type { Mesh } from "three";
|
|
19
|
+
|
|
20
|
+
export function Box() {
|
|
21
|
+
const mesh = useRef<Mesh>(null);
|
|
22
|
+
|
|
23
|
+
useGSAP(() => {
|
|
24
|
+
if (!mesh.current) return;
|
|
25
|
+
gsap.to(mesh.current.rotation, {
|
|
26
|
+
y: Math.PI * 2,
|
|
27
|
+
duration: 6,
|
|
28
|
+
ease: "none",
|
|
29
|
+
repeat: -1,
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return (
|
|
34
|
+
<mesh ref={mesh}>
|
|
35
|
+
<boxGeometry />
|
|
36
|
+
<meshStandardMaterial />
|
|
37
|
+
</mesh>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
A camera move is where GSAP earns its place — easing a camera by hand is how
|
|
43
|
+
scroll-driven 3D ends up feeling mechanical:
|
|
44
|
+
|
|
45
|
+
```ts
|
|
46
|
+
const { camera, invalidate } = useThree();
|
|
47
|
+
|
|
48
|
+
gsap.timeline({ defaults: { ease: "power2.inOut", duration: 2 } })
|
|
49
|
+
.to(camera.position, { x: 0, y: 1.5, z: 4 })
|
|
50
|
+
.to(camera.rotation, { x: -0.2 }, "<");
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Where it is torn down
|
|
54
|
+
|
|
55
|
+
The `useGSAP` context reverts the tweens, as in react.md.
|
|
56
|
+
|
|
57
|
+
R3F disposes what it created for elements it rendered, when they unmount.
|
|
58
|
+
Anything you built yourself — a geometry, material or texture made outside JSX,
|
|
59
|
+
a loader's result you cached — is yours to dispose.
|
|
60
|
+
|
|
61
|
+
## Scope
|
|
62
|
+
|
|
63
|
+
There are no selector strings here: targets are refs. `{ scope: root }` still
|
|
64
|
+
matters for any DOM overlay the component renders beside the canvas.
|
|
65
|
+
|
|
66
|
+
## Server rendering and hydration
|
|
67
|
+
|
|
68
|
+
The canvas is client-only. Import the component dynamically so Three is not in
|
|
69
|
+
the initial bundle, and render a fallback while it loads — that fallback is also
|
|
70
|
+
the reduced-motion and low-power design, so it is not wasted work.
|
|
71
|
+
|
|
72
|
+
## Reaching the element
|
|
73
|
+
|
|
74
|
+
Refs, held where the object is declared. `useThree` reaches the camera, the
|
|
75
|
+
renderer and the scene without prop-drilling. Traversing by name binds to the
|
|
76
|
+
exporter's names, which the next export changes.
|
|
77
|
+
|
|
78
|
+
## Every frame
|
|
79
|
+
|
|
80
|
+
**Never drive a 3D transform through React state.** `useState` per frame
|
|
81
|
+
re-renders the React tree for a value Three reads directly. Refs and GSAP only.
|
|
82
|
+
|
|
83
|
+
`useFrame` and GSAP are complementary, not competing: `useFrame` for continuous
|
|
84
|
+
logic that follows the clock, GSAP for transitions between states. Inside
|
|
85
|
+
`useFrame`, the rules are the ones for any per-frame scope — no allocation, no
|
|
86
|
+
state, no `setState` from a callback.
|
|
87
|
+
|
|
88
|
+
With `frameloop="demand"`, the canvas renders only when something asks it to, so
|
|
89
|
+
a GSAP tween that changes a Three value must ask:
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
gsap.to(mesh.current.position, { y: 1, onUpdate: invalidate });
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Without that, the tween runs and nothing is drawn — the most confusing failure
|
|
96
|
+
in this file, because every value is correct.
|
|
97
|
+
|
|
98
|
+
## Page and route changes
|
|
99
|
+
|
|
100
|
+
The canvas unmounts with the route, and R3F disposes its own objects. What
|
|
101
|
+
survives is anything you created outside JSX, plus any ScrollTrigger built
|
|
102
|
+
against a DOM element that is now gone — which the context reverts, provided it
|
|
103
|
+
was created inside the body.
|
|
104
|
+
|
|
105
|
+
## Failures
|
|
106
|
+
|
|
107
|
+
| Symptom | Cause |
|
|
108
|
+
|---|---|
|
|
109
|
+
| Values change but nothing is drawn | `frameloop="demand"` with no `invalidate` from `onUpdate` |
|
|
110
|
+
| The whole tree re-renders while animating | 3D values held in React state |
|
|
111
|
+
| Nothing moves | tweened the object instead of `object.position` / `.rotation` |
|
|
112
|
+
| The tween starts before the object exists | no early return on a null ref |
|
|
113
|
+
| Memory grows across route changes | objects created outside JSX, never disposed |
|
|
114
|
+
| Scroll-driven camera drifts out of sync | a scrub with an ease on a child, instead of `ease: "none"` |
|
|
115
|
+
|
|
116
|
+
## What the audit covers
|
|
117
|
+
|
|
118
|
+
The React rules from react.md, since these are `.tsx` files. From 3.2,
|
|
119
|
+
`useFrame` counts as a per-frame scope, so React state set inside it is reported
|
|
120
|
+
as `state-per-event`.
|
|
121
|
+
|
|
122
|
+
Disposal, GPU cost and the render loop are not checkable — read for them, and
|
|
123
|
+
measure with [performance.md](../reference/performance.md).
|
|
124
|
+
|
|
125
|
+
## Versions
|
|
126
|
+
|
|
127
|
+
Verified against `gsap@3.15` and `@gsap/react@2.1`. Written for
|
|
128
|
+
`@react-three/fiber@8` and `@react-three/fiber@9`.
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# React
|
|
2
|
+
|
|
3
|
+
Animation is a client concern, and the interesting problems are all lifecycle.
|
|
4
|
+
This covers any React app — Vite, Remix, Next. With Next, read
|
|
5
|
+
[next.md](next.md) too; with React Three Fiber, [r3f.md](r3f.md).
|
|
6
|
+
|
|
7
|
+
## Where it is created
|
|
8
|
+
|
|
9
|
+
`useGSAP`, from `@gsap/react`: `useLayoutEffect` with an SSR fallback, plus
|
|
10
|
+
automatic `gsap.context()` cleanup — which is what makes it survive StrictMode's
|
|
11
|
+
double invoke in development.
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { useGSAP } from "@gsap/react";
|
|
15
|
+
import gsap from "gsap";
|
|
16
|
+
import { useRef } from "react";
|
|
17
|
+
|
|
18
|
+
gsap.registerPlugin(useGSAP); // module scope, once
|
|
19
|
+
|
|
20
|
+
export function useReveal() {
|
|
21
|
+
const root = useRef<HTMLDivElement>(null);
|
|
22
|
+
|
|
23
|
+
useGSAP(() => {
|
|
24
|
+
gsap.from("[data-item]", { y: 20, stagger: 0.05 }); // scoped to root
|
|
25
|
+
}, { scope: root });
|
|
26
|
+
|
|
27
|
+
return root;
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
It is a layout effect, so it runs **before paint**: a `from` or a `set` lands
|
|
32
|
+
with no flash of the end state. Its options are `scope`, `dependencies` (the
|
|
33
|
+
body re-runs on change) and `revertOnUpdate` (the previous run reverts first).
|
|
34
|
+
|
|
35
|
+
A tween created *outside* the body — in a handler, a promise, a callback — is
|
|
36
|
+
never reverted. Wrap that function in `contextSafe`, from the same hook, and the
|
|
37
|
+
context owns it again. Only wrap functions that *create* animations: playing an
|
|
38
|
+
existing timeline creates nothing. `contextSafe` runs during render, so reading a
|
|
39
|
+
ref inside the callback trips React's "cannot access refs during render" rule —
|
|
40
|
+
build the timeline in the body, keep it on a ref, and expose a `useCallback`
|
|
41
|
+
that plays it.
|
|
42
|
+
|
|
43
|
+
## Where it is torn down
|
|
44
|
+
|
|
45
|
+
`useGSAP` reverts its context on unmount. Tweens, timelines, `ScrollTrigger`,
|
|
46
|
+
`Observer`, `Draggable`, `SplitText` and `gsap.matchMedia()` register themselves
|
|
47
|
+
with the context while its function runs and are reverted with it, so none needs
|
|
48
|
+
a manual `kill()` inside the body.
|
|
49
|
+
|
|
50
|
+
**These are yours**, because GSAP never sees them: timers, listeners on `window`
|
|
51
|
+
or `document`, `ResizeObserver` and `IntersectionObserver` — and **anything
|
|
52
|
+
created outside the body**, at module scope, in a plain `useEffect`, or in a
|
|
53
|
+
handler that never went through `contextSafe`. That last one is the real leak,
|
|
54
|
+
and the one people write cleanup for the wrong things instead of catching.
|
|
55
|
+
|
|
56
|
+
## Scope
|
|
57
|
+
|
|
58
|
+
`{ scope: root }` is not optional in practice: selector strings resolve inside
|
|
59
|
+
the component, so two instances cannot animate each other's elements.
|
|
60
|
+
|
|
61
|
+
A scope does **not** reach inside plugin config: `motionPath.path` and
|
|
62
|
+
`morphSVG.shape` resolve against the whole document, so a hardcoded id in a
|
|
63
|
+
component rendered twice makes both instances drive the first one's geometry,
|
|
64
|
+
silently. Generate one per instance from `useId()`, stripped to word characters
|
|
65
|
+
— React's id carries delimiters a CSS selector cannot hold, and unlike a counter
|
|
66
|
+
it is stable across server and client.
|
|
67
|
+
|
|
68
|
+
## Server rendering and hydration
|
|
69
|
+
|
|
70
|
+
Reading `localStorage`, `sessionStorage` or `matchMedia` **during render** makes
|
|
71
|
+
the server and the client disagree. Render the neutral state and correct it in
|
|
72
|
+
the layout effect, before paint, so nothing is seen.
|
|
73
|
+
|
|
74
|
+
Read direction and preferences from the DOM inside the effect, never from a prop:
|
|
75
|
+
`document.documentElement.dir === "rtl"` keeps the hook independent of the i18n
|
|
76
|
+
library, and the layout stamps `dir` before first paint.
|
|
77
|
+
|
|
78
|
+
## Reaching the element
|
|
79
|
+
|
|
80
|
+
A shared component that does not forward props has nowhere to hang a `data-*`
|
|
81
|
+
attribute; widening it to take its element's remaining props is usually right,
|
|
82
|
+
and often already the house pattern. A positional selector
|
|
83
|
+
(`row.firstElementChild`) breaks the next time the markup moves.
|
|
84
|
+
|
|
85
|
+
## Every frame
|
|
86
|
+
|
|
87
|
+
React state for a value that changes every frame is a re-render per frame.
|
|
88
|
+
|
|
89
|
+
```ts
|
|
90
|
+
// Wrong
|
|
91
|
+
const [x, setX] = useState(0);
|
|
92
|
+
onMouseMove = (e) => setX(e.clientX);
|
|
93
|
+
|
|
94
|
+
// Right
|
|
95
|
+
const moveX = gsap.quickTo(el.current, "x", { duration: 0.5, ease: "power3" });
|
|
96
|
+
onMouseMove = (e) => moveX(e.clientX);
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
State is for *what exists* — is the modal open, which tab is active. GSAP owns
|
|
100
|
+
*how it moves*. Crossing that line is the most common performance bug in
|
|
101
|
+
animated React.
|
|
102
|
+
|
|
103
|
+
## Page and route changes
|
|
104
|
+
|
|
105
|
+
A client router unmounts the outgoing tree, so an exit animation needs something
|
|
106
|
+
that outlives the route — an overlay in the layout, not in the page. See
|
|
107
|
+
[page-transition](../preset/page-transition.md), and [next.md](next.md) for the
|
|
108
|
+
App Router. After a navigation, `ScrollTrigger.refresh()` once it settles.
|
|
109
|
+
|
|
110
|
+
## Failures
|
|
111
|
+
|
|
112
|
+
| Symptom | Cause |
|
|
113
|
+
|---|---|
|
|
114
|
+
| Animation runs twice in dev | tweens created outside `useGSAP` — StrictMode |
|
|
115
|
+
| Animation leaks after navigation | ScrollTrigger or listener created outside the scope |
|
|
116
|
+
| Flash of the end state | `from` outside a layout effect |
|
|
117
|
+
| Two components animate each other | missing `scope`, or a shared hardcoded id |
|
|
118
|
+
| Hydration mismatch | storage or a media query read during render |
|
|
119
|
+
| Janky pointer effect | React state in the handler |
|
|
120
|
+
| Hover sticks halfway | no `overwrite: "auto"` on the retriggered tween |
|
|
121
|
+
|
|
122
|
+
## What the audit covers
|
|
123
|
+
|
|
124
|
+
`orphan-tween`, `state-per-event` and `shared-plugin-id` are React-specific: the
|
|
125
|
+
audit applies them to `.jsx` and `.tsx` files, and to any file importing `react`
|
|
126
|
+
or `@gsap/react`. Every other rule applies to every file it reads. It follows a
|
|
127
|
+
helper within one file — a function only a `useGSAP` body calls counts as inside
|
|
128
|
+
the context — but never into another file.
|
|
129
|
+
|
|
130
|
+
## Versions
|
|
131
|
+
|
|
132
|
+
Verified against `gsap@3.15` and `@gsap/react@2.1`. Written for `react@18` and
|
|
133
|
+
`react@19`.
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# Svelte and SvelteKit
|
|
2
|
+
|
|
3
|
+
Svelte 5 runs effects in runes mode; `onMount` still works and reads the same in
|
|
4
|
+
both versions. Either way the animation is created after mount, in a context
|
|
5
|
+
that is reverted when the component is destroyed.
|
|
6
|
+
|
|
7
|
+
## Where it is created
|
|
8
|
+
|
|
9
|
+
```svelte
|
|
10
|
+
<script>
|
|
11
|
+
import gsap from "gsap";
|
|
12
|
+
|
|
13
|
+
let root;
|
|
14
|
+
|
|
15
|
+
$effect(() => {
|
|
16
|
+
const ctx = gsap.context(() => {
|
|
17
|
+
gsap.from("[data-item]", { autoAlpha: 0, y: 24, stagger: 0.06 });
|
|
18
|
+
}, root);
|
|
19
|
+
|
|
20
|
+
return () => ctx.revert();
|
|
21
|
+
});
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<section bind:this={root}>…</section>
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
`$effect` runs after the component is mounted in the browser, and the function
|
|
28
|
+
it returns runs before the next run and when the component is destroyed. In
|
|
29
|
+
Svelte 4, or in a codebase that has not moved to runes, the same body goes in
|
|
30
|
+
`onMount` and returns the same cleanup.
|
|
31
|
+
|
|
32
|
+
Check how the project declares a bound element before copying this: `let root`
|
|
33
|
+
in classic syntax, `let root = $state()` in runes.
|
|
34
|
+
|
|
35
|
+
## Where it is torn down
|
|
36
|
+
|
|
37
|
+
The function returned from `$effect` or from `onMount`, calling `ctx.revert()`.
|
|
38
|
+
That reverts every tween, ScrollTrigger, Observer, Draggable, SplitText and
|
|
39
|
+
matchMedia created inside.
|
|
40
|
+
|
|
41
|
+
An effect re-runs whenever a rune it read changes. If the body creates
|
|
42
|
+
animations, either keep it free of reactive reads, or accept the re-run and let
|
|
43
|
+
the returned cleanup revert the previous one first — which it does, before the
|
|
44
|
+
body runs again.
|
|
45
|
+
|
|
46
|
+
Timers, listeners and observers are yours, in the same cleanup.
|
|
47
|
+
|
|
48
|
+
## Scope
|
|
49
|
+
|
|
50
|
+
`gsap.context(fn, root)` scopes selector strings to the component's element.
|
|
51
|
+
Plugin config is not scoped: `motionPath.path` and `morphSVG.shape` resolve
|
|
52
|
+
against the whole document, so generate an id per instance — from whatever
|
|
53
|
+
unique-id helper your Svelte version provides, or a module counter — rather than
|
|
54
|
+
hardcoding one.
|
|
55
|
+
|
|
56
|
+
## Server rendering and hydration
|
|
57
|
+
|
|
58
|
+
Neither `$effect` nor `onMount` runs during server rendering, so this is safe
|
|
59
|
+
under SvelteKit as written. Do not read `window`, `document` or `matchMedia` at
|
|
60
|
+
the top level of `<script>`: that runs on the server. Read them inside the
|
|
61
|
+
effect.
|
|
62
|
+
|
|
63
|
+
Render the neutral state on the server and correct it after mount, so the
|
|
64
|
+
hydrated markup matches what was sent.
|
|
65
|
+
|
|
66
|
+
## Reaching the element
|
|
67
|
+
|
|
68
|
+
`bind:this` for the root, `data-*` attributes for the parts. Svelte scopes CSS
|
|
69
|
+
classes by compiling them, so a class selector in a tween may not match what is
|
|
70
|
+
in the DOM — a `data-*` attribute is stable and says what it is for.
|
|
71
|
+
|
|
72
|
+
## Every frame
|
|
73
|
+
|
|
74
|
+
Refs, not `$state`. Writing a rune every frame re-renders the component;
|
|
75
|
+
`gsap.quickTo` writes straight to the element. State is for what exists — is the
|
|
76
|
+
drawer open, which tab is active.
|
|
77
|
+
|
|
78
|
+
## Page and route changes
|
|
79
|
+
|
|
80
|
+
SvelteKit destroys the outgoing component, so the context goes with it. For an
|
|
81
|
+
exit animation, put the overlay in a layout, which navigation does not destroy,
|
|
82
|
+
or hold the navigation while a timeline plays with the router's own hooks.
|
|
83
|
+
|
|
84
|
+
After navigation, call `ScrollTrigger.refresh()` once the new page settles.
|
|
85
|
+
|
|
86
|
+
## Failures
|
|
87
|
+
|
|
88
|
+
| Symptom | Cause |
|
|
89
|
+
|---|---|
|
|
90
|
+
| `window is not defined` during a build | `window` read at the top of `<script>` instead of inside the effect |
|
|
91
|
+
| Animations stack up on every state change | an effect that reads a rune, with no cleanup reverting the previous run |
|
|
92
|
+
| Tween targets nothing | a class selector the compiler rewrote; use `data-*` |
|
|
93
|
+
| Hydration mismatch | markup derived from storage or a media query during render |
|
|
94
|
+
|
|
95
|
+
## What the audit covers
|
|
96
|
+
|
|
97
|
+
From 3.2 the audit reads the `<script>` block of `.svelte` files, with offsets
|
|
98
|
+
kept, so reported lines match the file. Every rule that is not React-specific
|
|
99
|
+
applies; `orphan-tween`, `state-per-event` and `shared-plugin-id` do not run
|
|
100
|
+
here.
|
|
101
|
+
|
|
102
|
+
## Versions
|
|
103
|
+
|
|
104
|
+
Verified against `gsap@3.15`. Written for `svelte@5`, with the `onMount` form
|
|
105
|
+
for `svelte@4`.
|