@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.
@@ -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
@@ -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,