@mehshekari/gsap-motion 4.3.0 → 4.5.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 +20 -2
- package/package.json +1 -1
- package/skill/SKILL.md +8 -8
- package/skill/adapter/next.md +1 -2
- package/skill/adapter/r3f.md +4 -1
- package/skill/adapter/react.md +8 -5
- package/skill/adapter/three.md +2 -0
- package/skill/example/magnetic-button.md +101 -101
- package/skill/preset/cursor.md +184 -184
- package/skill/preset/magnetic.md +155 -155
- package/skill/reference/devices.md +10 -0
- package/skill/reference/flip.md +220 -220
- package/skill/reference/interaction.md +1 -0
- package/skill/reference/performance.md +87 -0
- package/skill/reference/refine.md +63 -0
- package/skill/reference/routing.md +11 -1
- package/skill/reference/scrolltrigger.md +79 -0
- package/skill/scripts/audit-gsap.mjs +27 -3
- package/skill/scripts/capture-motion.mjs +248 -44
- package/skill/scripts/lib/ast.mjs +82 -12
- package/skill/scripts/lib/rules.mjs +311 -7
- package/skill/scripts/lib/source.mjs +9 -1
- package/skill/scripts/review-motion.mjs +224 -25
- package/skill/template/useScrollScene.ts +126 -126
- package/skill/template/useTimeline.ts +110 -110
|
@@ -71,6 +71,16 @@ Device memory and core count (`navigator.deviceMemory`, `hardwareConcurrency`)
|
|
|
71
71
|
are hints, not facts: they are absent on some browsers and rounded on others.
|
|
72
72
|
Use them to pick a tier, never to decide whether a thing works.
|
|
73
73
|
|
|
74
|
+
**Data saving is about bytes, not motion.** `navigator.connection?.saveData` is
|
|
75
|
+
true when the visitor has turned data saving on — read it where it exists, and
|
|
76
|
+
treat its absence as false, since only Chromium has it. Skip what motion costs
|
|
77
|
+
to download — a video background, large textures, a 3D scene's models — and
|
|
78
|
+
ship the poster frame. Keep transforms and fades: they cost nothing to fetch.
|
|
79
|
+
|
|
80
|
+
A weak device lowers cost before it loses content: a capped pixel ratio, fewer
|
|
81
|
+
particles, no post-processing or blur, then a poster in place of the scene. Cut
|
|
82
|
+
a beat of the story last, and the same beat on every device.
|
|
83
|
+
|
|
74
84
|
## Where to read each axis
|
|
75
85
|
|
|
76
86
|
`prefers-reduced-motion`, `hover` and `pointer` are media queries, so
|
package/skill/reference/flip.md
CHANGED
|
@@ -1,220 +1,220 @@
|
|
|
1
|
-
# Flip
|
|
2
|
-
|
|
3
|
-
Animates between two layouts you did not have to compute. You change the DOM or
|
|
4
|
-
the CSS however you like, and Flip works out the transform that makes the old
|
|
5
|
-
state look like the new one, then animates it away.
|
|
6
|
-
|
|
7
|
-
Use it when the animation's meaning is **continuity** — this thing is still that
|
|
8
|
-
thing. A thumbnail becoming a hero image, a card expanding into a panel, an item
|
|
9
|
-
moving between lists, a grid reflowing on filter.
|
|
10
|
-
|
|
11
|
-
```ts
|
|
12
|
-
import { Flip } from "gsap/Flip";
|
|
13
|
-
gsap.registerPlugin(Flip);
|
|
14
|
-
```
|
|
15
|
-
|
|
16
|
-
## The pattern
|
|
17
|
-
|
|
18
|
-
Three steps, always in this order:
|
|
19
|
-
|
|
20
|
-
```ts
|
|
21
|
-
const state = Flip.getState("[data-card]"); // 1. record
|
|
22
|
-
|
|
23
|
-
container.classList.toggle("expanded"); // 2. change — anything at all
|
|
24
|
-
|
|
25
|
-
Flip.from(state, { // 3. animate the difference
|
|
26
|
-
duration: 0.6,
|
|
27
|
-
ease: "power3.inOut",
|
|
28
|
-
absolute: true,
|
|
29
|
-
});
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
Step 2 can be a class toggle, a React re-render, a reparent, a sort — Flip does
|
|
33
|
-
not care how the layout changed, only what it was and what it is.
|
|
34
|
-
|
|
35
|
-
## Options that matter
|
|
36
|
-
|
|
37
|
-
```ts
|
|
38
|
-
Flip.from(state, {
|
|
39
|
-
duration: 0.6,
|
|
40
|
-
ease: "power3.inOut",
|
|
41
|
-
absolute: true, // position: absolute during the flip
|
|
42
|
-
nested: true, // children also move — compensates their transforms
|
|
43
|
-
scale: true, // scale instead of animating width/height
|
|
44
|
-
stagger: 0.05,
|
|
45
|
-
spin: true,
|
|
46
|
-
simple: true, // skip rotation/skew maths when there is none
|
|
47
|
-
onEnter: (els) => gsap.fromTo(els, { opacity: 0 }, { opacity: 1 }),
|
|
48
|
-
onLeave: (els) => gsap.to(els, { opacity: 0 }),
|
|
49
|
-
});
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
- **`absolute: true`** takes the moving elements out of flow for the duration.
|
|
53
|
-
Without it, siblings reflow during the animation and the whole grid jitters.
|
|
54
|
-
Reach for it whenever more than one element moves.
|
|
55
|
-
- **`scale: true`** animates a transform instead of `width`/`height`. That is a
|
|
56
|
-
GPU-friendly property against a layout-triggering one — almost always the
|
|
57
|
-
right choice. The cost is that borders and text scale too; for a card with a
|
|
58
|
-
1px border, animating size may look better.
|
|
59
|
-
- **`nested: true`** when the flipped elements contain their own flipped
|
|
60
|
-
children, so the child transform is compensated rather than compounded.
|
|
61
|
-
- **`onEnter` / `onLeave`** handle elements that did not exist in one of the two
|
|
62
|
-
states. Flip cannot interpolate something that has no "before" — these are
|
|
63
|
-
where you fade them.
|
|
64
|
-
|
|
65
|
-
## Shared element between routes or views
|
|
66
|
-
|
|
67
|
-
The element must be identifiable in both states. `data-flip-id` is how Flip
|
|
68
|
-
matches a node in the old state to a *different* node in the new one:
|
|
69
|
-
|
|
70
|
-
```tsx
|
|
71
|
-
<img data-flip-id="hero" … /> // in the grid
|
|
72
|
-
<img data-flip-id="hero" … /> // in the detail view
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
```ts
|
|
76
|
-
const state = Flip.getState("[data-flip-id]");
|
|
77
|
-
setOpen(true); // React swaps the tree
|
|
78
|
-
Flip.from(state, { duration: 0.6, absolute: true });
|
|
79
|
-
```
|
|
80
|
-
|
|
81
|
-
In React, run `Flip.from` **after** the DOM has updated — in a layout effect
|
|
82
|
-
keyed on the state that changed, so it measures the new layout before paint:
|
|
83
|
-
|
|
84
|
-
```ts
|
|
85
|
-
useGSAP(() => {
|
|
86
|
-
if (!state.current) return;
|
|
87
|
-
Flip.from(state.current, { duration: 0.6, absolute: true });
|
|
88
|
-
}, { dependencies: [open] });
|
|
89
|
-
```
|
|
90
|
-
|
|
91
|
-
## Filtering a grid
|
|
92
|
-
|
|
93
|
-
The case Flip is best at, and where hand-rolling is worst:
|
|
94
|
-
|
|
95
|
-
```ts
|
|
96
|
-
const state = Flip.getState(items);
|
|
97
|
-
items.forEach((el) => {
|
|
98
|
-
el.style.display = el.dataset.tag === filter || filter === "all" ? "" : "none";
|
|
99
|
-
});
|
|
100
|
-
Flip.from(state, {
|
|
101
|
-
duration: 0.5,
|
|
102
|
-
scale: true,
|
|
103
|
-
absolute: true,
|
|
104
|
-
ease: "power2.inOut",
|
|
105
|
-
onEnter: (els) => gsap.fromTo(els, { opacity: 0, scale: 0.9 }, { opacity: 1, scale: 1 }),
|
|
106
|
-
onLeave: (els) => gsap.to(els, { opacity: 0, scale: 0.9 }),
|
|
107
|
-
});
|
|
108
|
-
```
|
|
109
|
-
|
|
110
|
-
## Holding the container's height
|
|
111
|
-
|
|
112
|
-
`absolute: true` lifts every target out of flow, so a container that took its
|
|
113
|
-
height from them collapses to nothing and the whole page below jumps up. Flip's
|
|
114
|
-
own docs prescribe holding the height and tweening it alongside:
|
|
115
|
-
|
|
116
|
-
```ts
|
|
117
|
-
tl.fromTo(grid, { height: fromHeight }, { height: toHeight, duration: D }, 0);
|
|
118
|
-
```
|
|
119
|
-
|
|
120
|
-
**Release it on Flip's completion, never on the parent timeline's.** Flip
|
|
121
|
-
restores the targets to flow as the last child of its own timeline
|
|
122
|
-
(`_setFinalStates`, added with `animation.call()` at its end — internals,
|
|
123
|
-
verified against gsap 3.15), and its
|
|
124
|
-
`onComplete` fires immediately after that:
|
|
125
|
-
|
|
126
|
-
```ts
|
|
127
|
-
tl.add(
|
|
128
|
-
Flip.from(state, {
|
|
129
|
-
targets: shells,
|
|
130
|
-
absolute: true,
|
|
131
|
-
onComplete: () => gsap.set(grid, { clearProps: "height" }),
|
|
132
|
-
}),
|
|
133
|
-
0,
|
|
134
|
-
);
|
|
135
|
-
```
|
|
136
|
-
|
|
137
|
-
Hanging it on the parent's `onComplete` only looks equivalent. The parent ends
|
|
138
|
-
when its **longest** child ends, so the gap between "Flip put things back in
|
|
139
|
-
flow" and "the height was released" becomes whatever else happens to be on that
|
|
140
|
-
timeline. Add a long sibling and it works; take that sibling away — a branch
|
|
141
|
-
that skipped, an async tween that never got added — and the release lands on the
|
|
142
|
-
same frame as the restore, for a one-frame collapse that only reproduces on the
|
|
143
|
-
path where the sibling is missing. A card-to-panel Flip had exactly this:
|
|
144
|
-
correct on every click but the first, where an image had not decoded and a
|
|
145
|
-
sibling image pass was never added.
|
|
146
|
-
|
|
147
|
-
The general rule: **tie the release of a held layout property to the event that
|
|
148
|
-
actually restores the layout, not to a timeline whose length is incidental.**
|
|
149
|
-
|
|
150
|
-
### The held height distorts the layout Flip is about to measure
|
|
151
|
-
|
|
152
|
-
This one is worse, because it produces a wrong animation rather than a flicker.
|
|
153
|
-
|
|
154
|
-
`gsap.fromTo(grid, { height: from }, { height: to })` renders its start value
|
|
155
|
-
immediately, so by the time `Flip.from` measures where the targets should land,
|
|
156
|
-
the container is already pinned to the **old** height. A grid or flex container
|
|
157
|
-
that is taller than its content **stretches its auto rows to fill it** —
|
|
158
|
-
`align-content` defaults to `normal`, which behaves as `stretch`. Flip therefore
|
|
159
|
-
measures a stretched layout, animates everything to those positions, and the
|
|
160
|
-
elements snap when it releases them into the real one.
|
|
161
|
-
|
|
162
|
-
```
|
|
163
|
-
held 744, natural 632, two rows → each row +56px
|
|
164
|
-
Flip's target: 138 + 476 + 24 = 638
|
|
165
|
-
where they go: 138 + 420 + 24 = 582 ← a 56px snap on release
|
|
166
|
-
```
|
|
167
|
-
|
|
168
|
-
The fix is on the container, not the animation:
|
|
169
|
-
|
|
170
|
-
```tsx
|
|
171
|
-
<ul className="grid content-start gap-6"> {/* align-content: start */}
|
|
172
|
-
```
|
|
173
|
-
|
|
174
|
-
The held height then sits as space at the foot instead of inflating every row,
|
|
175
|
-
and it is inert whenever the height is not being held.
|
|
176
|
-
|
|
177
|
-
It reproduces only when the held height **exceeds** the natural one — opening a
|
|
178
|
-
panel that makes the grid shorter. Close it again, or switch between two open
|
|
179
|
-
states, and the container is shorter than its content, nothing stretches, and
|
|
180
|
-
the bug hides. That asymmetry is why it reads as "only on the first click".
|
|
181
|
-
|
|
182
|
-
## `Flip.fit`
|
|
183
|
-
|
|
184
|
-
Makes one element match another's position and size without changing the DOM —
|
|
185
|
-
useful for an overlay that must land exactly on a card:
|
|
186
|
-
|
|
187
|
-
```ts
|
|
188
|
-
Flip.fit(overlay, card, { duration: 0.5, scale: true, ease: "power3.inOut" });
|
|
189
|
-
```
|
|
190
|
-
|
|
191
|
-
## When not to use it
|
|
192
|
-
|
|
193
|
-
- **A simple show/hide.** Flip measures every target twice; a fade does not need
|
|
194
|
-
that.
|
|
195
|
-
- **Something that is not the same object.** If the visitor would not say "that
|
|
196
|
-
became this", continuity is the wrong story and Flip is just an expensive
|
|
197
|
-
transition.
|
|
198
|
-
- **Very large sets.** Two full layout reads plus a transform per element. A
|
|
199
|
-
hundred items is fine; a thousand is not.
|
|
200
|
-
|
|
201
|
-
## Accessibility
|
|
202
|
-
|
|
203
|
-
Flip moves things across the screen, which is exactly the motion
|
|
204
|
-
`prefers-reduced-motion` exists for. Under `reduce`, apply the layout change
|
|
205
|
-
with `duration: 0` — the state still changes, the travel does not. Never skip
|
|
206
|
-
the change itself.
|
|
207
|
-
|
|
208
|
-
Focus stays on the element being flipped, which is correct. Just make sure the
|
|
209
|
-
focus ring is not clipped by an `overflow: hidden` ancestor during the flip.
|
|
210
|
-
|
|
211
|
-
## Failures
|
|
212
|
-
|
|
213
|
-
| Symptom | Cause |
|
|
214
|
-
|---|---|
|
|
215
|
-
| Siblings jitter during the flip | missing `absolute: true` |
|
|
216
|
-
| Nothing animates | state captured after the change, not before |
|
|
217
|
-
| New items pop in | no `onEnter` |
|
|
218
|
-
| Children double-transform | missing `nested: true` |
|
|
219
|
-
| Blurry text mid-flight | `scale: true` on text-heavy elements — animate size |
|
|
220
|
-
| Wrong in React | `Flip.from` ran before the DOM updated |
|
|
1
|
+
# Flip
|
|
2
|
+
|
|
3
|
+
Animates between two layouts you did not have to compute. You change the DOM or
|
|
4
|
+
the CSS however you like, and Flip works out the transform that makes the old
|
|
5
|
+
state look like the new one, then animates it away.
|
|
6
|
+
|
|
7
|
+
Use it when the animation's meaning is **continuity** — this thing is still that
|
|
8
|
+
thing. A thumbnail becoming a hero image, a card expanding into a panel, an item
|
|
9
|
+
moving between lists, a grid reflowing on filter.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
import { Flip } from "gsap/Flip";
|
|
13
|
+
gsap.registerPlugin(Flip);
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## The pattern
|
|
17
|
+
|
|
18
|
+
Three steps, always in this order:
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
const state = Flip.getState("[data-card]"); // 1. record
|
|
22
|
+
|
|
23
|
+
container.classList.toggle("expanded"); // 2. change — anything at all
|
|
24
|
+
|
|
25
|
+
Flip.from(state, { // 3. animate the difference
|
|
26
|
+
duration: 0.6,
|
|
27
|
+
ease: "power3.inOut",
|
|
28
|
+
absolute: true,
|
|
29
|
+
});
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Step 2 can be a class toggle, a React re-render, a reparent, a sort — Flip does
|
|
33
|
+
not care how the layout changed, only what it was and what it is.
|
|
34
|
+
|
|
35
|
+
## Options that matter
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
Flip.from(state, {
|
|
39
|
+
duration: 0.6,
|
|
40
|
+
ease: "power3.inOut",
|
|
41
|
+
absolute: true, // position: absolute during the flip
|
|
42
|
+
nested: true, // children also move — compensates their transforms
|
|
43
|
+
scale: true, // scale instead of animating width/height
|
|
44
|
+
stagger: 0.05,
|
|
45
|
+
spin: true,
|
|
46
|
+
simple: true, // skip rotation/skew maths when there is none
|
|
47
|
+
onEnter: (els) => gsap.fromTo(els, { opacity: 0 }, { opacity: 1 }),
|
|
48
|
+
onLeave: (els) => gsap.to(els, { opacity: 0 }),
|
|
49
|
+
});
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
- **`absolute: true`** takes the moving elements out of flow for the duration.
|
|
53
|
+
Without it, siblings reflow during the animation and the whole grid jitters.
|
|
54
|
+
Reach for it whenever more than one element moves.
|
|
55
|
+
- **`scale: true`** animates a transform instead of `width`/`height`. That is a
|
|
56
|
+
GPU-friendly property against a layout-triggering one — almost always the
|
|
57
|
+
right choice. The cost is that borders and text scale too; for a card with a
|
|
58
|
+
1px border, animating size may look better.
|
|
59
|
+
- **`nested: true`** when the flipped elements contain their own flipped
|
|
60
|
+
children, so the child transform is compensated rather than compounded.
|
|
61
|
+
- **`onEnter` / `onLeave`** handle elements that did not exist in one of the two
|
|
62
|
+
states. Flip cannot interpolate something that has no "before" — these are
|
|
63
|
+
where you fade them.
|
|
64
|
+
|
|
65
|
+
## Shared element between routes or views
|
|
66
|
+
|
|
67
|
+
The element must be identifiable in both states. `data-flip-id` is how Flip
|
|
68
|
+
matches a node in the old state to a *different* node in the new one:
|
|
69
|
+
|
|
70
|
+
```tsx
|
|
71
|
+
<img data-flip-id="hero" … /> // in the grid
|
|
72
|
+
<img data-flip-id="hero" … /> // in the detail view
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
const state = Flip.getState("[data-flip-id]");
|
|
77
|
+
setOpen(true); // React swaps the tree
|
|
78
|
+
Flip.from(state, { duration: 0.6, absolute: true });
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
In React, run `Flip.from` **after** the DOM has updated — in a layout effect
|
|
82
|
+
keyed on the state that changed, so it measures the new layout before paint:
|
|
83
|
+
|
|
84
|
+
```ts
|
|
85
|
+
useGSAP(() => {
|
|
86
|
+
if (!state.current) return;
|
|
87
|
+
Flip.from(state.current, { duration: 0.6, absolute: true });
|
|
88
|
+
}, { dependencies: [open] });
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
## Filtering a grid
|
|
92
|
+
|
|
93
|
+
The case Flip is best at, and where hand-rolling is worst:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
const state = Flip.getState(items);
|
|
97
|
+
items.forEach((el) => {
|
|
98
|
+
el.style.display = el.dataset.tag === filter || filter === "all" ? "" : "none";
|
|
99
|
+
});
|
|
100
|
+
Flip.from(state, {
|
|
101
|
+
duration: 0.5,
|
|
102
|
+
scale: true,
|
|
103
|
+
absolute: true,
|
|
104
|
+
ease: "power2.inOut",
|
|
105
|
+
onEnter: (els) => gsap.fromTo(els, { opacity: 0, scale: 0.9 }, { opacity: 1, scale: 1 }),
|
|
106
|
+
onLeave: (els) => gsap.to(els, { opacity: 0, scale: 0.9 }),
|
|
107
|
+
});
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## Holding the container's height
|
|
111
|
+
|
|
112
|
+
`absolute: true` lifts every target out of flow, so a container that took its
|
|
113
|
+
height from them collapses to nothing and the whole page below jumps up. Flip's
|
|
114
|
+
own docs prescribe holding the height and tweening it alongside:
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
tl.fromTo(grid, { height: fromHeight }, { height: toHeight, duration: D }, 0);
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
**Release it on Flip's completion, never on the parent timeline's.** Flip
|
|
121
|
+
restores the targets to flow as the last child of its own timeline
|
|
122
|
+
(`_setFinalStates`, added with `animation.call()` at its end — internals,
|
|
123
|
+
verified against gsap 3.15), and its
|
|
124
|
+
`onComplete` fires immediately after that:
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
tl.add(
|
|
128
|
+
Flip.from(state, {
|
|
129
|
+
targets: shells,
|
|
130
|
+
absolute: true,
|
|
131
|
+
onComplete: () => gsap.set(grid, { clearProps: "height" }),
|
|
132
|
+
}),
|
|
133
|
+
0,
|
|
134
|
+
);
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Hanging it on the parent's `onComplete` only looks equivalent. The parent ends
|
|
138
|
+
when its **longest** child ends, so the gap between "Flip put things back in
|
|
139
|
+
flow" and "the height was released" becomes whatever else happens to be on that
|
|
140
|
+
timeline. Add a long sibling and it works; take that sibling away — a branch
|
|
141
|
+
that skipped, an async tween that never got added — and the release lands on the
|
|
142
|
+
same frame as the restore, for a one-frame collapse that only reproduces on the
|
|
143
|
+
path where the sibling is missing. A card-to-panel Flip had exactly this:
|
|
144
|
+
correct on every click but the first, where an image had not decoded and a
|
|
145
|
+
sibling image pass was never added.
|
|
146
|
+
|
|
147
|
+
The general rule: **tie the release of a held layout property to the event that
|
|
148
|
+
actually restores the layout, not to a timeline whose length is incidental.**
|
|
149
|
+
|
|
150
|
+
### The held height distorts the layout Flip is about to measure
|
|
151
|
+
|
|
152
|
+
This one is worse, because it produces a wrong animation rather than a flicker.
|
|
153
|
+
|
|
154
|
+
`gsap.fromTo(grid, { height: from }, { height: to })` renders its start value
|
|
155
|
+
immediately, so by the time `Flip.from` measures where the targets should land,
|
|
156
|
+
the container is already pinned to the **old** height. A grid or flex container
|
|
157
|
+
that is taller than its content **stretches its auto rows to fill it** —
|
|
158
|
+
`align-content` defaults to `normal`, which behaves as `stretch`. Flip therefore
|
|
159
|
+
measures a stretched layout, animates everything to those positions, and the
|
|
160
|
+
elements snap when it releases them into the real one.
|
|
161
|
+
|
|
162
|
+
```
|
|
163
|
+
held 744, natural 632, two rows → each row +56px
|
|
164
|
+
Flip's target: 138 + 476 + 24 = 638
|
|
165
|
+
where they go: 138 + 420 + 24 = 582 ← a 56px snap on release
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
The fix is on the container, not the animation:
|
|
169
|
+
|
|
170
|
+
```tsx
|
|
171
|
+
<ul className="grid content-start gap-6"> {/* align-content: start */}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
The held height then sits as space at the foot instead of inflating every row,
|
|
175
|
+
and it is inert whenever the height is not being held.
|
|
176
|
+
|
|
177
|
+
It reproduces only when the held height **exceeds** the natural one — opening a
|
|
178
|
+
panel that makes the grid shorter. Close it again, or switch between two open
|
|
179
|
+
states, and the container is shorter than its content, nothing stretches, and
|
|
180
|
+
the bug hides. That asymmetry is why it reads as "only on the first click".
|
|
181
|
+
|
|
182
|
+
## `Flip.fit`
|
|
183
|
+
|
|
184
|
+
Makes one element match another's position and size without changing the DOM —
|
|
185
|
+
useful for an overlay that must land exactly on a card:
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
Flip.fit(overlay, card, { duration: 0.5, scale: true, ease: "power3.inOut" });
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## When not to use it
|
|
192
|
+
|
|
193
|
+
- **A simple show/hide.** Flip measures every target twice; a fade does not need
|
|
194
|
+
that.
|
|
195
|
+
- **Something that is not the same object.** If the visitor would not say "that
|
|
196
|
+
became this", continuity is the wrong story and Flip is just an expensive
|
|
197
|
+
transition.
|
|
198
|
+
- **Very large sets.** Two full layout reads plus a transform per element. A
|
|
199
|
+
hundred items is fine; a thousand is not.
|
|
200
|
+
|
|
201
|
+
## Accessibility
|
|
202
|
+
|
|
203
|
+
Flip moves things across the screen, which is exactly the motion
|
|
204
|
+
`prefers-reduced-motion` exists for. Under `reduce`, apply the layout change
|
|
205
|
+
with `duration: 0` — the state still changes, the travel does not. Never skip
|
|
206
|
+
the change itself.
|
|
207
|
+
|
|
208
|
+
Focus stays on the element being flipped, which is correct. Just make sure the
|
|
209
|
+
focus ring is not clipped by an `overflow: hidden` ancestor during the flip.
|
|
210
|
+
|
|
211
|
+
## Failures
|
|
212
|
+
|
|
213
|
+
| Symptom | Cause |
|
|
214
|
+
|---|---|
|
|
215
|
+
| Siblings jitter during the flip | missing `absolute: true` |
|
|
216
|
+
| Nothing animates | state captured after the change, not before |
|
|
217
|
+
| New items pop in | no `onEnter` |
|
|
218
|
+
| Children double-transform | missing `nested: true` |
|
|
219
|
+
| Blurry text mid-flight | `scale: true` on text-heavy elements — animate size |
|
|
220
|
+
| Wrong in React | `Flip.from` ran before the DOM updated |
|
|
@@ -187,4 +187,5 @@ Cheap, and the styling stays in CSS where it can follow the theme.
|
|
|
187
187
|
| Magnetic pull feels wrong | strength too high, or rect measured once |
|
|
188
188
|
| Follower stuck in a corner | mounted on a touch device |
|
|
189
189
|
| Hover sticks after tap | no `(hover: hover)` gate |
|
|
190
|
+
| A hover class's scale does nothing | GSAP transformed the element and set CSS `scale` to `none` — see [performance.md](performance.md#scale-rotate-and-translate-are-not-transform) |
|
|
190
191
|
| Effects survive navigation | `Observer`/`Draggable` not killed, listeners not removed |
|
|
@@ -51,6 +51,25 @@ Two ways out:
|
|
|
51
51
|
The same asymmetry applies to `left`/`x` and to any percentage `width` or
|
|
52
52
|
`height` swapped for a scale.
|
|
53
53
|
|
|
54
|
+
### `scale`, `rotate` and `translate` are not `transform`
|
|
55
|
+
|
|
56
|
+
They are CSS properties of their own, and they **compose** with `transform`
|
|
57
|
+
rather than being replaced by it. Tailwind v4 compiles its `scale-*`,
|
|
58
|
+
`rotate-*` and `translate-*` utilities to them. GSAP accommodates them: the
|
|
59
|
+
first time it reads an element's transform, it folds their computed values into
|
|
60
|
+
its own and sets all three to `none` inline (verified against gsap 3.15). Two
|
|
61
|
+
silent failures follow:
|
|
62
|
+
|
|
63
|
+
- **Code that writes `style.transform` itself** — a rAF loop, a `useFrame`, a
|
|
64
|
+
scroll store — still composes with a class's `scale`. `scale-y-0` plus an
|
|
65
|
+
inline `scaleY(0.6)` is still zero, so the element never shows. Give the
|
|
66
|
+
initial state in the property the code writes: `gsap.set`, or an arbitrary
|
|
67
|
+
`transform` class in place of the utility.
|
|
68
|
+
- **On an element GSAP has transformed, those classes stop working.** The inline
|
|
69
|
+
`none` outranks them, so a `hover:scale-105`, or a breakpoint's
|
|
70
|
+
`translate-x-*`, does nothing from then on. Animate those states with GSAP
|
|
71
|
+
too, or put them on a wrapper GSAP never touches.
|
|
72
|
+
|
|
54
73
|
## The pointer rule
|
|
55
74
|
|
|
56
75
|
One tween per pointer event allocates an object per frame and creates tweens
|
|
@@ -187,6 +206,74 @@ If the trace shows time in Layout, you are animating a layout property. If it
|
|
|
187
206
|
shows Paint, something is repainting — usually a filter or a shadow. If it shows
|
|
188
207
|
Scripting, it is your handler, and usually React.
|
|
189
208
|
|
|
209
|
+
## WebGL: the first frame of each material
|
|
210
|
+
|
|
211
|
+
Three compiles a material's shader the first time it draws it, and uploads a
|
|
212
|
+
texture the first time it samples it. Both run on the main thread, inside that
|
|
213
|
+
frame's render — so an object first seen mid-scroll costs one frame of hundreds
|
|
214
|
+
of milliseconds, once, and never shows again in a trace recorded after it.
|
|
215
|
+
|
|
216
|
+
Compile ahead, behind the loader or before scroll can reach the object:
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
await renderer.compileAsync(scene, camera); // R3F: gl, scene, camera from useThree
|
|
220
|
+
renderer.initTexture(texture); // uploads now, not on first sample
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
- **Objects with `visible: false` are compiled too** — `compile` walks every
|
|
224
|
+
mesh. Lights are not: a light that is off at compile time and turned on later
|
|
225
|
+
changes the lighting state, and every lit material recompiles then.
|
|
226
|
+
- **Inside a render target it is a different program.** Drawing into one turns
|
|
227
|
+
tone mapping off and outputs the working colour space, so post-processing,
|
|
228
|
+
a portal or a render-to-texture uses a variant the screen compile never made.
|
|
229
|
+
Compile once more with any render target bound:
|
|
230
|
+
|
|
231
|
+
```ts
|
|
232
|
+
const warm = new WebGLRenderTarget(1, 1);
|
|
233
|
+
renderer.setRenderTarget(warm);
|
|
234
|
+
await renderer.compileAsync(scene, camera);
|
|
235
|
+
renderer.setRenderTarget(null);
|
|
236
|
+
warm.dispose();
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
- **Fog, shadows, clipping planes, a new environment map**, and a changed light
|
|
240
|
+
count or tone mapping, each change the program. Set them before compiling.
|
|
241
|
+
- `compileAsync` waits on `KHR_parallel_shader_compile` where it exists. Where it
|
|
242
|
+
does not, the work still happens on the main thread — which is why it belongs
|
|
243
|
+
before the scroll, not at the moment the object appears.
|
|
244
|
+
- What cannot be warmed can be timed: mount the heavy object on ScrollTrigger's
|
|
245
|
+
`scrollEnd` event, when a dropped frame costs nothing visible.
|
|
246
|
+
|
|
247
|
+
Verified against `three@0.186`.
|
|
248
|
+
|
|
249
|
+
## WebGL: losing the context, and reading it back
|
|
250
|
+
|
|
251
|
+
**Every canvas is a WebGL context, and the browser caps how many a page may
|
|
252
|
+
hold.** Past the cap it drops the oldest — a scene mounted earlier goes black,
|
|
253
|
+
with only a console warning. One canvas drawing several views (scissored
|
|
254
|
+
viewports) costs one context; a canvas per section costs one each. Remounting
|
|
255
|
+
canvases on scroll creates new ones, so check that unmount releases each.
|
|
256
|
+
|
|
257
|
+
A context can also be lost to the GPU process — a driver reset, memory pressure.
|
|
258
|
+
Three handles the event: it stops rendering on `webglcontextlost` and rebuilds
|
|
259
|
+
its state on `webglcontextrestored`, re-uploading from what the CPU still holds.
|
|
260
|
+
Two things it cannot bring back: an image you released after upload, and what a
|
|
261
|
+
render target contained. And it only draws again if something renders — with
|
|
262
|
+
R3F's `frameloop="demand"`, invalidate on restore. Test both events on purpose:
|
|
263
|
+
|
|
264
|
+
```ts
|
|
265
|
+
renderer.forceContextLoss(); // then
|
|
266
|
+
renderer.forceContextRestore(); // the scene should come back by itself
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
**Reading pixels back stalls.** `readPixels`, `readRenderTargetPixels`, and a
|
|
270
|
+
`getImageData` on a canvas WebGL drew into all make the CPU wait for every GPU
|
|
271
|
+
command queued before them. Read rarely, read a small region, and prefer
|
|
272
|
+
`readRenderTargetPixelsAsync`, which waits on a fence instead of blocking. A 2D
|
|
273
|
+
canvas you read often takes `getContext("2d", { willReadFrequently: true })`.
|
|
274
|
+
|
|
275
|
+
Verified against `three@0.186`.
|
|
276
|
+
|
|
190
277
|
## Budget
|
|
191
278
|
|
|
192
279
|
- A frame means everything in it — your handler, style, layout, paint,
|
|
@@ -75,6 +75,40 @@ Reach the state first, and change the conditions that usually break things:
|
|
|
75
75
|
- `--reduced`, `--dark`, `--mobile`, `--cpu 4`. `--mobile` makes input touch,
|
|
76
76
|
so it cannot be combined with `--hover`: a coarse pointer has no hover, which
|
|
77
77
|
is the thing worth knowing.
|
|
78
|
+
- `--viewport 1440x900` for a desktop layout. Without it the window is whatever
|
|
79
|
+
headless Chrome opens at — about 760×480 — where many sites switch to their
|
|
80
|
+
compact layout. Every report prints the viewport it ran at.
|
|
81
|
+
- `--wait` is a fixed wait after navigation; it does not detect a loader. Raise
|
|
82
|
+
it, or use `--seek`.
|
|
83
|
+
- **A step that did nothing fails the run.** Nothing matched, no match on
|
|
84
|
+
screen, or an invalid selector: it is listed last, under *Not done*, and the
|
|
85
|
+
run exits 3 — 2 for an invalid selector. A hidden match is passed over for
|
|
86
|
+
the next one, and one off screen is scrolled to first.
|
|
87
|
+
- **Cost on a production build, not the dev server.** In development React's
|
|
88
|
+
StrictMode mounts every effect twice — two contexts, two WebGL contexts — and
|
|
89
|
+
the bundle is unminified, so what a dev server measures is not what ships.
|
|
90
|
+
Motion and timing can be watched on either.
|
|
91
|
+
- `--headed` for anything WebGL. Headless Chrome draws in software — on one
|
|
92
|
+
Windows machine, the Basic Render Driver headless and the Intel GPU headed —
|
|
93
|
+
so a 3D scene's frames, rate and long frames describe the wrong thing. A
|
|
94
|
+
report warns when the page has a canvas and the run was headless. The DOM's
|
|
95
|
+
layout and style numbers do not depend on it.
|
|
96
|
+
- `--seek <name>` for a moment too short or too far into the timeline for the
|
|
97
|
+
clock to land on. `--at` then means seconds of the timeline: before each
|
|
98
|
+
capture it calls `window.<name>(t)` and waits two rendered frames. A bundle
|
|
99
|
+
never exposes `gsap`, so the page has to export the function — in
|
|
100
|
+
development only, removed with the context:
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
if (process.env.NODE_ENV !== "production") {
|
|
104
|
+
Object.assign(window, { __seek: (t: number) => tl.pause(t) });
|
|
105
|
+
// and in the cleanup: Reflect.deleteProperty(window, "__seek");
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Against a production build it is missing, and the run says so rather than
|
|
110
|
+
capturing the wrong frames. Through an MCP browser, call it from
|
|
111
|
+
`evaluate_script` instead.
|
|
78
112
|
|
|
79
113
|
**Without a browser, report the stage as not run.** Not "fine".
|
|
80
114
|
|
|
@@ -99,6 +133,8 @@ that no source rule can reach.
|
|
|
99
133
|
| **X% of the screen in motion** | Everything moving at once reads as noise, not as choreography. |
|
|
100
134
|
| **seams** | A step far larger than that motion's own rhythm: a loop put back by hand instead of carried. |
|
|
101
135
|
| **motion over text** | Something painted over a line a reader is in the middle of. Hit-tested, so it is what is really on top. |
|
|
136
|
+
| **frames over 50ms** | Long Animation Frames, with the sampler's own time taken out of each: the rest is the page's. "Not measured" means the browser has no such API, not that nothing was long. |
|
|
137
|
+
| **layout shift** | The browser's own shift total, input excluded. Sampling reads layout but never moves it, so this is the page's alone — a pin swapping its position at a section edge shows here. |
|
|
102
138
|
|
|
103
139
|
Three things it deliberately does not do, so you do not read them into it:
|
|
104
140
|
|
|
@@ -113,6 +149,33 @@ Three things it deliberately does not do, so you do not read them into it:
|
|
|
113
149
|
A clean report is evidence, not proof: it watched one window, on one machine, at
|
|
114
150
|
one size. Say which.
|
|
115
151
|
|
|
152
|
+
## In a browser you already drive
|
|
153
|
+
|
|
154
|
+
Both scripts open a Chrome of their own. Where a project allows only one — a
|
|
155
|
+
DevTools MCP server, say — they print what they would have run instead, and
|
|
156
|
+
open nothing:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
node <skill-dir>/scripts/review-motion.mjs --probe install # start recording
|
|
160
|
+
node <skill-dir>/scripts/review-motion.mjs --probe read # the findings
|
|
161
|
+
node <skill-dir>/scripts/capture-motion.mjs --probe timeline # the running timeline
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Each prints one function. Run `install` through the tool's `evaluate_script`,
|
|
165
|
+
then reach the state — scroll, hover, wait — with that tool's own input, and
|
|
166
|
+
then `read`. It returns what `review --json` would: the same conclusions,
|
|
167
|
+
computed in the page, so the frame log never leaves it.
|
|
168
|
+
|
|
169
|
+
What changes, so the report says it:
|
|
170
|
+
|
|
171
|
+
- **First motion counts from `install`**, not from navigation. A reload discards
|
|
172
|
+
the recorder; install again after one.
|
|
173
|
+
- **Emulation is the driving tool's**: its CPU throttling, viewport and
|
|
174
|
+
media features, set before `install`.
|
|
175
|
+
- **No layout or style counts.** Those come from the DevTools protocol, which a
|
|
176
|
+
page cannot reach — read them from the tool's own performance trace. Long
|
|
177
|
+
frames and layout shifts are observed in the page, so `read` has them.
|
|
178
|
+
|
|
116
179
|
## When to stop
|
|
117
180
|
|
|
118
181
|
Stop when every check is clean, or after **three rounds**, reporting what is
|