@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,216 @@
|
|
|
1
|
+
# Performance
|
|
2
|
+
|
|
3
|
+
A frame is 16.7ms at 60Hz, 8.3ms at 120Hz, and **6.2ms at 165Hz** — a rate
|
|
4
|
+
ordinary gaming and creator monitors ship with now. GSAP drives tweens at
|
|
5
|
+
whatever rate the display reports, so the budget you actually have is often less
|
|
6
|
+
than half the figure people quote. Everything below is about staying inside it.
|
|
7
|
+
|
|
8
|
+
That also makes 60Hz the wrong thing to design against in both directions: a
|
|
9
|
+
high-refresh display hides jank a 60Hz laptop shows, and a mid-range phone
|
|
10
|
+
throttling to 30Hz shows jank neither reveals.
|
|
11
|
+
|
|
12
|
+
## What animating a property costs
|
|
13
|
+
|
|
14
|
+
| Property | Browser does | Cost |
|
|
15
|
+
|---|---|---|
|
|
16
|
+
| `transform`, `opacity` | composite only | cheap — animate freely |
|
|
17
|
+
| `filter`, `backdrop-filter` | paint, often per frame | expensive over large areas |
|
|
18
|
+
| `background-position`, `box-shadow`, `border-radius` | repaint | moderate |
|
|
19
|
+
| `width`, `height`, `top`, `left`, `margin`, `padding` | **layout** | expensive — avoid |
|
|
20
|
+
|
|
21
|
+
The substitutions that matter:
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
{ left: 100 } → { x: 100 }
|
|
25
|
+
{ width: "50%" } → { scaleX: 0.5, transformOrigin: "0% 50%" }
|
|
26
|
+
{ top: 0 } → { y: 0 }
|
|
27
|
+
{ display } → { autoAlpha }
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`autoAlpha` is `opacity` plus `visibility`, so a hidden element stops taking
|
|
31
|
+
pointer events without a layout-triggering `display` change.
|
|
32
|
+
|
|
33
|
+
Animating `height: auto` is the classic trap. Use a `scaleY` on a wrapper, a
|
|
34
|
+
`grid-template-rows: 0fr → 1fr` transition, or Flip with `scale: true`.
|
|
35
|
+
|
|
36
|
+
### The swap is not always one for one
|
|
37
|
+
|
|
38
|
+
`top: 50%` is half the **containing block**. `y: "50%"` and `yPercent: 50` are
|
|
39
|
+
half the **element itself**. Swap them on a 1px rule and its full-height travel
|
|
40
|
+
becomes half a pixel — silently, and worse than the layout cost it replaced.
|
|
41
|
+
|
|
42
|
+
Two ways out:
|
|
43
|
+
|
|
44
|
+
1. **Give the moving element its container's height** and translate that, with
|
|
45
|
+
the visible mark pinned inside it. The percentages then agree and nothing has
|
|
46
|
+
to be measured. A scanner line is the usual case: a full-height box
|
|
47
|
+
carrying a 1px line at its top, moved with `yPercent`.
|
|
48
|
+
2. **Measure the container once** and translate in pixels — then keep the
|
|
49
|
+
measurement fresh across resizes yourself.
|
|
50
|
+
|
|
51
|
+
The same asymmetry applies to `left`/`x` and to any percentage `width` or
|
|
52
|
+
`height` swapped for a scale.
|
|
53
|
+
|
|
54
|
+
## The pointer rule
|
|
55
|
+
|
|
56
|
+
One tween per pointer event allocates an object per frame and creates tweens
|
|
57
|
+
that fight each other. Create the retargeting function once:
|
|
58
|
+
|
|
59
|
+
```ts
|
|
60
|
+
const xTo = gsap.quickTo(el, "x", { duration: 0.5, ease: "power3" });
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
See [interaction.md](interaction.md).
|
|
64
|
+
|
|
65
|
+
## The React rule
|
|
66
|
+
|
|
67
|
+
State that changes every frame is a re-render every frame — the whole subtree
|
|
68
|
+
reconciles so one element can move 3px.
|
|
69
|
+
|
|
70
|
+
```ts
|
|
71
|
+
// Wrong
|
|
72
|
+
const [x, setX] = useState(0);
|
|
73
|
+
|
|
74
|
+
// Right
|
|
75
|
+
const xTo = gsap.quickTo(ref.current, "x", { duration: 0.4 });
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
State owns *what exists*. GSAP owns *how it moves*.
|
|
79
|
+
|
|
80
|
+
## ScrollTrigger cost
|
|
81
|
+
|
|
82
|
+
- **One trigger for one visual moment.** Forty cards revealing together is one
|
|
83
|
+
trigger with a stagger, not forty triggers. Each carries its own start/end
|
|
84
|
+
maths on every scroll.
|
|
85
|
+
- Anything measuring layout inside `onUpdate` runs on every scroll frame. Cache
|
|
86
|
+
it, or move it to `onRefresh`.
|
|
87
|
+
- `scrub: 1` is smoother *and* cheaper than `scrub: true` — it batches into the
|
|
88
|
+
ticker instead of tracking exactly.
|
|
89
|
+
- Pinning adds wrapper elements. Pinning many sections adds many.
|
|
90
|
+
|
|
91
|
+
## `will-change`
|
|
92
|
+
|
|
93
|
+
Promotes an element to its own compositor layer. Useful in small doses,
|
|
94
|
+
counterproductive in large ones — every layer costs memory, and dozens can be
|
|
95
|
+
slower than none.
|
|
96
|
+
|
|
97
|
+
GSAP sets it automatically during a tween when it helps. Add it by hand only for
|
|
98
|
+
something that animates continuously, and never on a long list.
|
|
99
|
+
|
|
100
|
+
## Filters and blur
|
|
101
|
+
|
|
102
|
+
`filter: blur()` is repainted every frame and scales with area. A blurred
|
|
103
|
+
full-screen backdrop animating is one of the most reliable ways to drop frames
|
|
104
|
+
on a mid-range phone.
|
|
105
|
+
|
|
106
|
+
Alternatives: a pre-blurred image, a `radial-gradient`, a lower-resolution
|
|
107
|
+
layer scaled up, or animating `opacity` between two static states — one sharp,
|
|
108
|
+
one blurred.
|
|
109
|
+
|
|
110
|
+
## SVG cost
|
|
111
|
+
|
|
112
|
+
Large or complex SVG animation is CPU work, not GPU work. Watch for:
|
|
113
|
+
|
|
114
|
+
- Paths with thousands of nodes — simplify in the design tool first.
|
|
115
|
+
- Filters (`feGaussianBlur`, `feTurbulence`) animating — very expensive.
|
|
116
|
+
- `MorphSVGPlugin.convertToPath()` at runtime on every mount — convert in the
|
|
117
|
+
source file.
|
|
118
|
+
- Many elements on motion paths — each is a per-frame path calculation.
|
|
119
|
+
|
|
120
|
+
## Measuring
|
|
121
|
+
|
|
122
|
+
Do not optimise from intuition — and time your instrumentation in
|
|
123
|
+
**milliseconds, never frames**. A fixed frame count is a different duration on
|
|
124
|
+
every display: 90 frames is 1.5s at 60Hz and 0.56s at 165Hz, which is the
|
|
125
|
+
difference between capturing the end of an animation and stopping just before
|
|
126
|
+
the part that was going wrong.
|
|
127
|
+
|
|
128
|
+
```js
|
|
129
|
+
const t0 = performance.now();
|
|
130
|
+
const tick = () => {
|
|
131
|
+
const ms = performance.now() - t0;
|
|
132
|
+
/* record */
|
|
133
|
+
if (ms < 4000) requestAnimationFrame(tick);
|
|
134
|
+
};
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Counting frames to infer a refresh rate does not work either: an eased tween
|
|
138
|
+
rounds to its end value long before it mathematically finishes, so "frames until
|
|
139
|
+
it stopped changing" undercounts badly.
|
|
140
|
+
|
|
141
|
+
### Timing in milliseconds, settling in frames
|
|
142
|
+
|
|
143
|
+
The rule above is about *how long* something takes. *Has it finished?* is a
|
|
144
|
+
different question, and a timer answers it wrongly.
|
|
145
|
+
|
|
146
|
+
GSAP's ticker runs on `requestAnimationFrame`. A page driven by automation, or a
|
|
147
|
+
tab in the background, throttles or pauses rAF while `setTimeout` keeps firing
|
|
148
|
+
on schedule. So a harness that waits with `setTimeout(done, 800)` and then reads
|
|
149
|
+
computed styles sees every `gsap.set` exactly where it belongs and every
|
|
150
|
+
`gsap.to` frozen part-way — a pill holding the last target's width, an opacity
|
|
151
|
+
still at its class value. That reads precisely like a lifecycle bug, and it is
|
|
152
|
+
not one.
|
|
153
|
+
|
|
154
|
+
It is an expensive misdiagnosis. A sliding nav pill checked this way was
|
|
155
|
+
blamed on a `useGSAP` revert under StrictMode, refactored onto a plain effect,
|
|
156
|
+
and given a lint waiver — and all three were undone once the same test waited
|
|
157
|
+
in frames. The original code had been right throughout.
|
|
158
|
+
|
|
159
|
+
Wait for a duration, but only let the wait end on a rendered frame:
|
|
160
|
+
|
|
161
|
+
```js
|
|
162
|
+
const settle = (ms) =>
|
|
163
|
+
new Promise((resolve) => {
|
|
164
|
+
const t0 = performance.now();
|
|
165
|
+
const tick = () =>
|
|
166
|
+
performance.now() - t0 >= ms ? resolve() : requestAnimationFrame(tick);
|
|
167
|
+
requestAnimationFrame(tick);
|
|
168
|
+
});
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
That keeps the duration independent of the display, and guarantees the ticker
|
|
172
|
+
had frames to run in. If it never resolves, the page is not rendering at all —
|
|
173
|
+
bring it to the front. A hang is a loud failure; a timer's stale reading is a
|
|
174
|
+
silent one.
|
|
175
|
+
|
|
176
|
+
**If `gsap.set` lands and `gsap.to` does not, suspect the harness before the
|
|
177
|
+
code.** Read the value twice a few frames apart: unchanged and short of its
|
|
178
|
+
target means the ticker is not running.
|
|
179
|
+
|
|
180
|
+
1. **Performance panel, 4× or 6× CPU throttling.** Record the interaction. Long
|
|
181
|
+
tasks and "Recalculate Style" / "Layout" bars are the signal.
|
|
182
|
+
2. **Rendering panel → Paint flashing** shows what is repainting.
|
|
183
|
+
3. **Layers panel** shows how many compositor layers exist.
|
|
184
|
+
4. Test on a real mid-range phone. A desktop hides almost every problem here.
|
|
185
|
+
|
|
186
|
+
If the trace shows time in Layout, you are animating a layout property. If it
|
|
187
|
+
shows Paint, something is repainting — usually a filter or a shadow. If it shows
|
|
188
|
+
Scripting, it is your handler, and usually React.
|
|
189
|
+
|
|
190
|
+
## Budget
|
|
191
|
+
|
|
192
|
+
- A frame means everything in it — your handler, style, layout, paint,
|
|
193
|
+
composite — inside the display's interval: 16.7ms at 60Hz, 6.2ms at 165Hz.
|
|
194
|
+
- A handful of transform/opacity tweens costs almost nothing.
|
|
195
|
+
- One full-screen blur can cost the whole budget on a phone.
|
|
196
|
+
- Dozens of simultaneous timelines are fine; dozens of simultaneous *layouts*
|
|
197
|
+
are not.
|
|
198
|
+
|
|
199
|
+
## Loading
|
|
200
|
+
|
|
201
|
+
GSAP core is roughly 23KB min+gzip. Plugins are extra, so import from subpaths —
|
|
202
|
+
`gsap/ScrollTrigger`, never `gsap/all` — and only in the component that needs
|
|
203
|
+
them. A component that is below the fold can be dynamically imported with its
|
|
204
|
+
animation code.
|
|
205
|
+
|
|
206
|
+
## Checklist
|
|
207
|
+
|
|
208
|
+
- Only `transform` and `opacity` in anything continuous or scroll-driven?
|
|
209
|
+
- One tween retargeted for pointer, not one per event?
|
|
210
|
+
- No React state changing per frame?
|
|
211
|
+
- One ScrollTrigger per visual moment?
|
|
212
|
+
- No layout measurement inside `onUpdate`?
|
|
213
|
+
- Filters limited in area, or avoided?
|
|
214
|
+
- Everything reverted on unmount — no orphaned triggers or listeners?
|
|
215
|
+
- Verified on a throttled CPU and a real phone?
|
|
216
|
+
- End states read after waiting in rendered frames, not on a timer?
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# Project rules
|
|
2
|
+
|
|
3
|
+
This skill is project-agnostic. The project it is working in is not: it has a
|
|
4
|
+
linter, colour tokens, a folder layout, a text direction, and usually a motion
|
|
5
|
+
language already on the page. Those outrank anything in this skill.
|
|
6
|
+
|
|
7
|
+
## Finding them
|
|
8
|
+
|
|
9
|
+
Before writing code, look for the project's animation rules, in this order:
|
|
10
|
+
|
|
11
|
+
1. **`ANIMATION.md` at the repository root.**
|
|
12
|
+
2. **A file an agent-instructions file points to** — `AGENTS.md`, `CLAUDE.md`,
|
|
13
|
+
`.cursor/rules`, or similar — for animation or motion.
|
|
14
|
+
3. **Nothing.** Derive them (below), and say in your answer which ones you
|
|
15
|
+
assumed.
|
|
16
|
+
|
|
17
|
+
Read the whole file. Where it disagrees with this skill, the project wins. If a
|
|
18
|
+
project rule looks wrong, say so — do not quietly follow the skill instead.
|
|
19
|
+
|
|
20
|
+
## Deriving them when there is no file
|
|
21
|
+
|
|
22
|
+
A few minutes here is cheaper than a page with two motion languages.
|
|
23
|
+
|
|
24
|
+
- **What fails the build.** Read the `lint`, `check` and `test` scripts in
|
|
25
|
+
`package.json` and whatever they run. A rule that fails CI is a constraint,
|
|
26
|
+
not a preference.
|
|
27
|
+
- **Colour.** CSS variables, a Tailwind theme, a design-token file? An
|
|
28
|
+
animation that tweens to a literal the theme cannot reach breaks dark mode.
|
|
29
|
+
- **Direction.** Is `dir="rtl"` ever set? If so, the RTL rules below apply.
|
|
30
|
+
- **Where code goes.** Where do existing hooks and client components live, and
|
|
31
|
+
where does copy come from?
|
|
32
|
+
- **The house motion language.** Grep for `gsap.`, `ScrollTrigger` and
|
|
33
|
+
`useGSAP`. The trigger point, ease, durations and stagger shape already on the
|
|
34
|
+
page are the precedent — step 2 of the pipeline in SKILL.md.
|
|
35
|
+
|
|
36
|
+
## Writing an `ANIMATION.md`
|
|
37
|
+
|
|
38
|
+
If the project will be animated more than once, suggest creating one. A useful
|
|
39
|
+
one is short, and each section is an instruction rather than an essay:
|
|
40
|
+
|
|
41
|
+
````markdown
|
|
42
|
+
# Animation rules
|
|
43
|
+
|
|
44
|
+
## What fails the build
|
|
45
|
+
<!-- Lint rules that catch animation or SVG code, and their escape hatches. -->
|
|
46
|
+
|
|
47
|
+
## Colour
|
|
48
|
+
<!-- Where colours come from; what an SVG paint may be. -->
|
|
49
|
+
|
|
50
|
+
## Direction
|
|
51
|
+
<!-- LTR, RTL or both; how the layout mirrors (logical CSS, `rtl:` variants). -->
|
|
52
|
+
|
|
53
|
+
## Where animation code lives
|
|
54
|
+
<!-- Hook and component folders; the client/server boundary; where copy comes from. -->
|
|
55
|
+
|
|
56
|
+
## House motion language
|
|
57
|
+
| | |
|
|
58
|
+
|---|---|
|
|
59
|
+
| Trigger | e.g. `start: "top 75%"`, plays once |
|
|
60
|
+
| Ease | e.g. `power3.out` |
|
|
61
|
+
| Durations | heading / body / groups |
|
|
62
|
+
| Stagger | `amount` (a total) or `each` (per item) |
|
|
63
|
+
| Reduced motion | what the reduced branch shows |
|
|
64
|
+
|
|
65
|
+
## Verify with
|
|
66
|
+
<!-- The commands to run before calling it done. -->
|
|
67
|
+
|
|
68
|
+
## Precedents
|
|
69
|
+
<!-- Files that already do this well, by path, and what each one taught. -->
|
|
70
|
+
````
|
|
71
|
+
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
## Rules worth knowing without a file
|
|
75
|
+
|
|
76
|
+
Each of these fails silently in any project that has the condition.
|
|
77
|
+
|
|
78
|
+
### RTL: everything with a handedness is decided, not inherited
|
|
79
|
+
|
|
80
|
+
GSAP transforms are physical — `x` means left-to-right on every page. That is
|
|
81
|
+
fine; leaving them unmirrored on a right-to-left page is not.
|
|
82
|
+
|
|
83
|
+
| Has a direction | Mirror by |
|
|
84
|
+
|---|---|
|
|
85
|
+
| `x` / `xPercent` travel | negating the value |
|
|
86
|
+
| `stagger` reading order | `from: "end"` |
|
|
87
|
+
| MotionPath | `start: 1, end: 0` — no second path needed |
|
|
88
|
+
| Horizontal ScrollTrigger | `+100 *` instead of `-100 *` |
|
|
89
|
+
| Marquee | negating the target `x` |
|
|
90
|
+
| `clipPath` wipe | `inset(0 100% 0 0)` becomes `inset(0 0 0 100%)` |
|
|
91
|
+
| Flip | nothing — it measures both real layouts |
|
|
92
|
+
|
|
93
|
+
```ts
|
|
94
|
+
const isRtl = document.documentElement.dir === "rtl";
|
|
95
|
+
gsap.from("[data-item]", { x: isRtl ? 40 : -40, stagger: 0.06 });
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Read `dir` off the document rather than importing a locale: the layout sets it,
|
|
99
|
+
and the hook stays independent of the i18n library. Vertical motion needs no
|
|
100
|
+
mirroring at all, which is a good reason to prefer it.
|
|
101
|
+
|
|
102
|
+
### A CSS transform is not a base GSAP adds to
|
|
103
|
+
|
|
104
|
+
Projects that mirror with CSS often put the whole arrangement in a transform:
|
|
105
|
+
|
|
106
|
+
```tsx
|
|
107
|
+
<div className="translate-x-3.5 rotate-3 rtl:-translate-x-3.5 rtl:-rotate-3" />
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
A GSAP transform **replaces that matrix rather than composing with it**, and
|
|
111
|
+
takes the `rtl:` variant with it, because an inline style outranks the class.
|
|
112
|
+
Animate `x` or `rotation` on that element and the mirroring is gone — in one
|
|
113
|
+
direction only, with nothing failing in the other.
|
|
114
|
+
|
|
115
|
+
Three ways out, in order:
|
|
116
|
+
|
|
117
|
+
1. **Animate a property CSS is not using.** `autoAlpha` on an element whose
|
|
118
|
+
transform CSS owns costs nothing and breaks nothing. Usually enough.
|
|
119
|
+
2. **Animate a wrapper.** The wrapper carries the GSAP transform; the child
|
|
120
|
+
keeps its CSS one.
|
|
121
|
+
3. **Move the arrangement into GSAP entirely**, mirroring it yourself from
|
|
122
|
+
`document.documentElement.dir`. Only worth it when the offset itself is what
|
|
123
|
+
animates, and it costs the no-JavaScript rendering.
|
|
124
|
+
|
|
125
|
+
`gsap.from(el, { x: 0 })` looks like it solves this. It animates to the right
|
|
126
|
+
place and then leaves an inline transform behind that no longer mirrors.
|
|
127
|
+
|
|
128
|
+
### Positioned from a measurement, anchored physically
|
|
129
|
+
|
|
130
|
+
A marker that slides to what it points at — a nav pill, a tab underline — is
|
|
131
|
+
best placed from the target's own geometry: `x: link.offsetLeft`,
|
|
132
|
+
`width: link.offsetWidth`. Measuring needs no `isRtl` branch, because the layout
|
|
133
|
+
is already mirrored by the time you read it.
|
|
134
|
+
|
|
135
|
+
But `offsetLeft` is physical, and so is `x`, so the marker's CSS anchor must be
|
|
136
|
+
physical too: `left: 0`. A lint rule that enforces logical CSS will suggest
|
|
137
|
+
`inset-inline-start: 0` instead, which resolves to `right: 0` on an RTL page
|
|
138
|
+
while the measurement still counts from the left — and the marker lands
|
|
139
|
+
off-screen in one direction only. Keep the physical anchor and use the
|
|
140
|
+
project's waiver, with this reason.
|
|
141
|
+
|
|
142
|
+
### Hue literals in SVGs
|
|
143
|
+
|
|
144
|
+
If the project requires colours to come from tokens, an inline SVG is the
|
|
145
|
+
easiest place to break that: it arrives as one paste with the literal in forty
|
|
146
|
+
attributes, sometimes hidden in an export tool's `<style>` block. Run
|
|
147
|
+
`audit-svg.mjs --hues` on the file first, and repaint with `currentColor` or
|
|
148
|
+
the project's CSS variables.
|
|
149
|
+
|
|
150
|
+
### Waivers carry reasons
|
|
151
|
+
|
|
152
|
+
The skill's `audit-gsap.mjs` waives a rule with its id plus `-ok` — for
|
|
153
|
+
example `shared-plugin-id-ok` — on the flagged line or in the eight above, and
|
|
154
|
+
every waiver carries a sentence saying why:
|
|
155
|
+
|
|
156
|
+
```ts
|
|
157
|
+
/* shared-plugin-id-ok — one instance only: this is the page-level backdrop,
|
|
158
|
+
mounted once by the root layout and never by a component. */
|
|
159
|
+
gsap.to("#dot", { motionPath: { path: "#track" } });
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
A bare token with no reason is a rule someone switched off rather than
|
|
163
|
+
answered. If the project has its own waiver convention, follow that one.
|
|
164
|
+
|
|
165
|
+
### Templates under a dot-directory are not typechecked
|
|
166
|
+
|
|
167
|
+
TypeScript's default globs skip dot-directories such as `.claude/`, so the
|
|
168
|
+
skill's `template/*.ts` files are invisible to `tsc` unless the project's
|
|
169
|
+
`tsconfig.json` names them in `include`. Snippets inside Markdown are never
|
|
170
|
+
checked — treat them as starting points, and let the project's own checks be
|
|
171
|
+
the authority once the code is in its source tree.
|
|
172
|
+
|
|
173
|
+
## Before you call it done
|
|
174
|
+
|
|
175
|
+
Run the project's checks and `audit-gsap.mjs` on what you changed. Then watch
|
|
176
|
+
it run: both themes if there are two, with reduced motion emulated, at phone
|
|
177
|
+
width. A green build says the code compiles, not that the animation is right.
|
|
178
|
+
If you have not seen it move, say so rather than implying you have.
|
|
@@ -0,0 +1,234 @@
|
|
|
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 |
|