@mehshekari/gsap-motion 4.2.1 → 4.4.2
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 +7 -0
- 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 +7 -2
- 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 +15 -2
- package/skill/reference/flip.md +220 -220
- package/skill/reference/interaction.md +8 -0
- package/skill/reference/motion-design.md +1 -2
- package/skill/reference/performance.md +87 -0
- package/skill/reference/refine.md +54 -0
- package/skill/reference/routing.md +11 -1
- package/skill/reference/scrolltrigger.md +79 -0
- package/skill/scripts/audit-gsap.mjs +4 -0
- package/skill/scripts/capture-motion.mjs +120 -10
- package/skill/scripts/lib/ast.mjs +58 -9
- package/skill/scripts/lib/rules.mjs +263 -35
- package/skill/scripts/review-motion.mjs +167 -8
- package/skill/template/useScrollScene.ts +126 -126
- package/skill/template/useTimeline.ts +110 -110
|
@@ -23,13 +23,16 @@ mm.add(
|
|
|
23
23
|
hoverable: "(hover: hover) and (pointer: fine)",
|
|
24
24
|
motion: "(prefers-reduced-motion: no-preference)",
|
|
25
25
|
},
|
|
26
|
-
(context) => {
|
|
26
|
+
(context, contextSafe) => {
|
|
27
27
|
const { wide, hoverable, motion } = context.conditions;
|
|
28
|
-
// …one branch, three facts
|
|
28
|
+
// …one branch, three facts. A handler that tweens later: contextSafe(fn).
|
|
29
29
|
},
|
|
30
30
|
);
|
|
31
31
|
```
|
|
32
32
|
|
|
33
|
+
`contextSafe` is the **second** argument. The context has none, and
|
|
34
|
+
`context.add(fn)` runs `fn` at once: either one ships a blank page.
|
|
35
|
+
|
|
33
36
|
`matchMedia` re-runs the body when any condition changes and reverts what the
|
|
34
37
|
previous run created, so a visitor who rotates a phone, plugs in a mouse or
|
|
35
38
|
turns on reduced motion gets the right version without a reload.
|
|
@@ -68,6 +71,16 @@ Device memory and core count (`navigator.deviceMemory`, `hardwareConcurrency`)
|
|
|
68
71
|
are hints, not facts: they are absent on some browsers and rounded on others.
|
|
69
72
|
Use them to pick a tier, never to decide whether a thing works.
|
|
70
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
|
+
|
|
71
84
|
## Where to read each axis
|
|
72
85
|
|
|
73
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 |
|
|
@@ -13,6 +13,11 @@ The recovery is what makes it feel physical. A button that springs toward the
|
|
|
13
13
|
cursor and snaps back on leave feels broken; the same button easing back over
|
|
14
14
|
0.6s feels alive.
|
|
15
15
|
|
|
16
|
+
**A layer that answers the pointer follows it** — on both axes, toward the
|
|
17
|
+
cursor. One axis, or the opposite way, reads as resistance: in blind judging, a
|
|
18
|
+
card that followed beat one that drifted away every time. Depth comes from how
|
|
19
|
+
*far* each layer follows, not which way.
|
|
20
|
+
|
|
16
21
|
## The rule: `quickTo`, not a tween per event
|
|
17
22
|
|
|
18
23
|
```ts
|
|
@@ -168,6 +173,8 @@ Cheap, and the styling stays in CSS where it can follow the theme.
|
|
|
168
173
|
- Never destroy the focus ring with a transform that moves the element out from
|
|
169
174
|
under it.
|
|
170
175
|
- Gate hover effects on `(hover: hover)`. On touch, `:hover` sticks after a tap.
|
|
176
|
+
Input is its own axis, read in one `gsap.matchMedia` with layout and motion —
|
|
177
|
+
see [devices.md](devices.md).
|
|
171
178
|
- Under `prefers-reduced-motion: reduce`, keep the state change and drop the
|
|
172
179
|
travel — see [accessibility.md](accessibility.md).
|
|
173
180
|
|
|
@@ -180,4 +187,5 @@ Cheap, and the styling stays in CSS where it can follow the theme.
|
|
|
180
187
|
| Magnetic pull feels wrong | strength too high, or rect measured once |
|
|
181
188
|
| Follower stuck in a corner | mounted on a touch device |
|
|
182
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) |
|
|
183
191
|
| Effects survive navigation | `Observer`/`Draggable` not killed, listeners not removed |
|
|
@@ -142,11 +142,10 @@ Before calling an animation done:
|
|
|
142
142
|
- Does it say one of the five things at the top of this file?
|
|
143
143
|
- Is the rhythm intentional, or are the delays arbitrary?
|
|
144
144
|
- Does anything looping use `ease: "none"`?
|
|
145
|
+
- Does an ambient loop move only decoration, never the copy being read?
|
|
145
146
|
- Is the seam of the loop invisible?
|
|
146
147
|
- Do entrances use `out` easing and exits `in`?
|
|
147
148
|
- Does one element carry the emphasis, rather than all of them?
|
|
148
149
|
- Does it still read at 400px wide, and on a touch device with no hover?
|
|
149
150
|
- Does the reduced-motion branch still communicate the same thing?
|
|
150
|
-
- Is everything reverted on unmount?
|
|
151
|
-
- Is every plugin justified, and would a transform have done it?
|
|
152
151
|
- Watch it five times. Does anything start to irritate?
|
|
@@ -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,
|