phase 0.4.1 → 0.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 +10 -1264
- package/dist/{debounce-C_ZHP-07.js → debounce-BkVwTxwI.js} +25 -18
- package/dist/debounce-BkVwTxwI.js.map +1 -0
- package/dist/index-DE0O-FZ4.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/react.js +1 -1
- package/package.json +1 -1
- package/dist/debounce-C_ZHP-07.js.map +0 -1
package/README.md
CHANGED
|
@@ -1,79 +1,12 @@
|
|
|
1
1
|
<p align="center">
|
|
2
|
-
<img src=".github/assets/phase-header.png" alt="phase" />
|
|
2
|
+
<img src="https://raw.githubusercontent.com/vercel-labs/phase/main/.github/assets/phase-header.png" alt="phase" />
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
# ▲ phase
|
|
6
6
|
|
|
7
7
|
> **Status: Alpha.** APIs are evolving rapidly. Expect breaking changes.
|
|
8
8
|
|
|
9
|
-
Phase is a lightweight, lifecycle-aware UI performance layer for the web. It includes tools
|
|
10
|
-
|
|
11
|
-
## Why phase
|
|
12
|
-
|
|
13
|
-
You can't accidentally tank the main thread, leak an observer, jank on scroll, or ignore reduced motion. The hard parts are handled for you, so the slow path isn't even reachable:
|
|
14
|
-
|
|
15
|
-
- **Pauses when unseen.** Off-screen or in a background tab, work stops and CPU drops to zero.
|
|
16
|
-
- **Respects reduced motion by default.** Accessibility is built in, not an opt-in.
|
|
17
|
-
- **Never forces a reflow.** No `getBoundingClientRect`, no layout thrash, anywhere in the package.
|
|
18
|
-
- **Zero re-renders from the frame loop.** Per-frame work writes to refs and the DOM, never React state.
|
|
19
|
-
- **Frame-locked shared clock.** Every animation on the page reads one clock, so nothing drifts out of sync.
|
|
20
|
-
- **Renders only what matters.** Skip painting off-screen content, mount non-critical UI when idle.
|
|
21
|
-
|
|
22
|
-
Each guarantee is a [tested invariant](#guarantees), not an aspiration. Every export stays [sub-kilobyte to a few kilobytes](#bundle-size).
|
|
23
|
-
|
|
24
|
-
## Table of contents
|
|
25
|
-
|
|
26
|
-
- [Install](#install)
|
|
27
|
-
- [Getting started](#getting-started)
|
|
28
|
-
- [Philosophy](#philosophy)
|
|
29
|
-
- [Scope](#scope)
|
|
30
|
-
- [Entry points](#entry-points)
|
|
31
|
-
- [Core API](#core-api)
|
|
32
|
-
- [createLoop](#createloop)
|
|
33
|
-
- [createTicker](#createticker)
|
|
34
|
-
- [createSight](#createsight)
|
|
35
|
-
- [createLifecycle](#createlifecycle)
|
|
36
|
-
- [createScrollProgress](#createscrollprogress)
|
|
37
|
-
- [createScroll](#createscroll)
|
|
38
|
-
- [createThrottle](#createthrottle)
|
|
39
|
-
- [createDebounce](#createdebounce)
|
|
40
|
-
- [createRenderState](#createrenderstate)
|
|
41
|
-
- [createDevicePixelRatio](#createdevicepixelratio)
|
|
42
|
-
- [createMutation](#createmutation)
|
|
43
|
-
- [createPointer](#createpointer)
|
|
44
|
-
- [whenIdle](#whenidle)
|
|
45
|
-
- [prefersReducedMotion](#prefersreducedmotion)
|
|
46
|
-
- [Easing and math](#easing-and-math)
|
|
47
|
-
- [Choosing a primitive](#choosing-a-primitive)
|
|
48
|
-
- [React hooks](#react-hooks)
|
|
49
|
-
- [useLoop](#useloop)
|
|
50
|
-
- [useLifecycle](#uselifecycle)
|
|
51
|
-
- [useCanvas](#usecanvas)
|
|
52
|
-
- [useTween](#usetween)
|
|
53
|
-
- [usePresence](#usepresence)
|
|
54
|
-
- [useScrollProgress](#usescrollprogress)
|
|
55
|
-
- [useScroll](#usescroll)
|
|
56
|
-
- [useThrottledCallback](#usethrottledcallback)
|
|
57
|
-
- [useDebouncedCallback](#usedebouncedcallback)
|
|
58
|
-
- [useMutation](#usemutation)
|
|
59
|
-
- [usePointer](#usepointer)
|
|
60
|
-
- [Observation and utility hooks](#observation-and-utility-hooks)
|
|
61
|
-
- [React components](#react-components)
|
|
62
|
-
- [How animations work](#how-animations-work)
|
|
63
|
-
- [Presence](#presence)
|
|
64
|
-
- [WhenVisible](#whenvisible)
|
|
65
|
-
- [Swap](#swap)
|
|
66
|
-
- [Rendering](#rendering)
|
|
67
|
-
- [Defer](#defer)
|
|
68
|
-
- [WhenIdle](#whenidle-1)
|
|
69
|
-
- [useIdle](#useidle)
|
|
70
|
-
- [useWhenIdle](#usewhenidle)
|
|
71
|
-
- [useRenderState](#userenderstate)
|
|
72
|
-
- [Guarantees](#guarantees)
|
|
73
|
-
- [Errors](#errors)
|
|
74
|
-
- [Relationship to View Transitions](#relationship-to-view-transitions)
|
|
75
|
-
- [Bundle size](#bundle-size)
|
|
76
|
-
- [Agent skill](#agent-skill)
|
|
9
|
+
Phase is a lightweight, lifecycle-aware UI performance layer for the web. It includes tools and guidance to optimize render performance, build performant animations, and manage layout and off-screen resources.
|
|
77
10
|
|
|
78
11
|
## Install
|
|
79
12
|
|
|
@@ -99,1200 +32,13 @@ function Orbit({ radius }) {
|
|
|
99
32
|
}
|
|
100
33
|
```
|
|
101
34
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
- **Pauses when invisible.** Scrolled off-screen or background tab? Zero CPU consumed.
|
|
105
|
-
- **Respects reduced motion.** Accessibility is the default, not an opt-in.
|
|
106
|
-
- **Resumes without teleporting.** Elapsed time freezes during pause, picks up where it left off.
|
|
107
|
-
- **Clean teardown.** Unmount the component and walk away. Nothing leaks.
|
|
108
|
-
|
|
109
|
-
## Philosophy
|
|
110
|
-
|
|
111
|
-
Every primitive in `phase` exposes its state as a **phase** (a single string: `idle`, `running`, `paused`, `active`, `exiting`...) paired with a **reason** explaining _why_ that transition happened.
|
|
112
|
-
|
|
113
|
-
```ts
|
|
114
|
-
const { phase, phaseReason } = useLoop({ onTick: draw });
|
|
115
|
-
|
|
116
|
-
// phase: 'paused' phaseReason: 'sight' → off-screen
|
|
117
|
-
// phase: 'paused' phaseReason: 'reduced-motion' → user disabled motion
|
|
118
|
-
// phase: 'running' phaseReason: 'resumed' → came back into view
|
|
119
|
-
```
|
|
120
|
-
|
|
121
|
-
One string replaces `if (running && visible && !paused && !prefersReducedMotion && mounted)`.
|
|
122
|
-
|
|
123
|
-
Each of those signals is also a CPU and battery decision. Animating while off-screen, ignoring reduced motion, or running after unmount is what burns cycles and causes jank. `phase` composes them once, correctly, instead of leaving each call site to get the conjunction right.
|
|
124
|
-
|
|
125
|
-
Safe behavior is automatic. Visibility awareness, reduced motion, observer cleanup, and delta clamping are defaults, not opt-ins. Bypassing reduced motion requires an explicit `reducedMotion: 'ignore'` in the diff.
|
|
126
|
-
|
|
127
|
-
## Scope
|
|
128
|
-
|
|
129
|
-
`phase` composes signals (visibility, focus, reduced motion, frame budget) into a coherent lifecycle with a reason for every state transition.
|
|
130
|
-
|
|
131
|
-
**Handles:** lifecycle state, timing, visibility, scroll visibility-ratio, reduced motion, observer pooling, quality signals, frame loops.
|
|
132
|
-
|
|
133
|
-
**Does not handle:** spring physics, gesture systems, declarative keyframe orchestration. Reach for a dedicated library (e.g. `motion`) when you need those.
|
|
134
|
-
|
|
135
|
-
This narrow scope is deliberate. Shipping only the performance-critical plumbing (and nothing else) is what keeps every export [sub-kilobyte to a few kilobytes](#bundle-size).
|
|
136
|
-
|
|
137
|
-
### Admission criteria
|
|
138
|
-
|
|
139
|
-
Every export must pass all four:
|
|
140
|
-
|
|
141
|
-
1. **Wraps a browser API that is easy to misuse** and causes measurable perf regressions without careful handling.
|
|
142
|
-
2. **Manages a lifecycle** (browser: visibility-pausing, reduced-motion, observer pooling; render: preventing re-renders, stable identities; CSS: containment state).
|
|
143
|
-
3. **Makes the safe path shorter than the raw path.** The primitive is less code and less error-prone than the browser API directly.
|
|
144
|
-
4. **Stays individually lean.** Every export is measured and budgeted in `.size-limit.json`. CI rejects regressions. Every byte must justify itself.
|
|
145
|
-
|
|
146
|
-
If a gap fails any criterion, phase closes it in the [skill](#agent-skill) (audit rules, recipes, scanner signals) rather than shipping code.
|
|
147
|
-
|
|
148
|
-
### Export taxonomy
|
|
149
|
-
|
|
150
|
-
| Category | What it covers | Examples |
|
|
151
|
-
| ----------- | -------------------------------------------- | --------------------------------------------------- |
|
|
152
|
-
| Timing | Frame clocks and animation loops | createLoop, useLoop, useCanvas, useTween |
|
|
153
|
-
| Observation | Reactive wrappers around browser observers | useSight, useSize, useScrollProgress, useMediaQuery |
|
|
154
|
-
| Lifecycle | Activation signals composed from IO+MQL+rIC | useLifecycle, useIdle, useWhenIdle |
|
|
155
|
-
| Composition | Mount/unmount orchestration with transitions | Presence, Swap, WhenVisible, WhenIdle, Defer |
|
|
156
|
-
| Math | Pure easing and interpolation functions | lerp, clamp, easeOutCubic |
|
|
157
|
-
| Utility | React ref/callback patterns for phase users | useSyncedRef, useStableCallback |
|
|
158
|
-
|
|
159
|
-
## Entry points
|
|
160
|
-
|
|
161
|
-
| Import | Contents |
|
|
162
|
-
| ------------- | ------------------------------------------------------------------------------- |
|
|
163
|
-
| `phase` | Framework-agnostic timing, observation, lifecycle, scheduling, math, and errors |
|
|
164
|
-
| `phase/ease` | Easing functions and math utilities only |
|
|
165
|
-
| `phase/react` | React hooks and components |
|
|
166
|
-
|
|
167
|
-
Each entry point is independently tree-shakeable. Importing `phase/ease` in a server component pulls zero browser APIs.
|
|
168
|
-
|
|
169
|
-
## Core API
|
|
170
|
-
|
|
171
|
-
### createLoop
|
|
172
|
-
|
|
173
|
-
The main primitive. Composes a ticker, visibility observer, and reduced-motion listener into a lifecycle-aware animation loop.
|
|
174
|
-
|
|
175
|
-
```ts
|
|
176
|
-
import { createLoop } from 'phase';
|
|
177
|
-
|
|
178
|
-
const loop = createLoop({
|
|
179
|
-
target: el,
|
|
180
|
-
onTick: (frame) => {
|
|
181
|
-
// frame.time — browser rAF timestamp
|
|
182
|
-
// frame.delta — ms since last tick (clamped to 40ms)
|
|
183
|
-
// frame.elapsed — ms since start (paused time excluded)
|
|
184
|
-
// frame.frame — frame count
|
|
185
|
-
},
|
|
186
|
-
});
|
|
187
|
-
|
|
188
|
-
loop.start();
|
|
189
|
-
// loop.phase === 'running'
|
|
190
|
-
// loop.phaseReason === 'started'
|
|
191
|
-
```
|
|
192
|
-
|
|
193
|
-
#### Loop phases
|
|
194
|
-
|
|
195
|
-
| Phase | Meaning | Possible reasons |
|
|
196
|
-
| --------- | -------------------------------- | ------------------------------------- |
|
|
197
|
-
| `idle` | Created but not started | `initial` |
|
|
198
|
-
| `running` | Actively ticking | `started`, `resumed` |
|
|
199
|
-
| `paused` | Temporarily stopped, will resume | `sight`, `reduced-motion`, `degraded` |
|
|
200
|
-
| `stopped` | Permanently disposed | `manual`, `disposed` |
|
|
201
|
-
|
|
202
|
-
#### Quality signals
|
|
203
|
-
|
|
204
|
-
`phase` and `quality` are orthogonal. A loop can be `running` + `degraded` (still animating, but at reduced fidelity to preserve resources).
|
|
205
|
-
|
|
206
|
-
| Quality | Meaning | What changes |
|
|
207
|
-
| ---------- | --------------------- | --------------------------------- |
|
|
208
|
-
| `full` | Normal operation | Configured FPS, full DPR |
|
|
209
|
-
| `degraded` | Resources constrained | FPS capped to 30, DPR drops to 1x |
|
|
210
|
-
|
|
211
|
-
Two signals trigger degradation:
|
|
212
|
-
|
|
213
|
-
| Trigger | `qualityReason` | When | Recovery |
|
|
214
|
-
| ------------ | ---------------- | ---------------------------------------------- | ------------------------ |
|
|
215
|
-
| Window blur | `'unfocused'` | User switches to another window | Recovers on window focus |
|
|
216
|
-
| Frame budget | `'frame-budget'` | 3+ consecutive frames exceed the 16.6ms budget | Does not auto-recover |
|
|
217
|
-
|
|
218
|
-
Read `loop.quality` and `loop.qualityReason` to adapt rendering (fewer particles, lower-fidelity shaders, skip non-essential visual passes).
|
|
219
|
-
|
|
220
|
-
#### The `degraded` option
|
|
221
|
-
|
|
222
|
-
Controls the loop's response when quality degrades. Same three-value pattern as `reducedMotion`.
|
|
223
|
-
|
|
224
|
-
| Value | Behavior | Use case |
|
|
225
|
-
| ------------ | ---------------------------------------------------- | --------------------------------------------------- |
|
|
226
|
-
| `'throttle'` | Cap FPS (default 30, configurable via `degradedFps`) | Most animations. Still runs, only slower |
|
|
227
|
-
| `'pause'` | Pause the loop entirely | Heavy canvas/WebGL. If it can't run well, don't run |
|
|
228
|
-
| `'ignore'` | Keep running at full quality | Critical UI that must never degrade |
|
|
229
|
-
|
|
230
|
-
```ts
|
|
231
|
-
createLoop({
|
|
232
|
-
target: el,
|
|
233
|
-
onTick: draw,
|
|
234
|
-
degraded: 'throttle', // default
|
|
235
|
-
degradedFps: 20, // only accepted when degraded is 'throttle'
|
|
236
|
-
});
|
|
237
|
-
```
|
|
238
|
-
|
|
239
|
-
#### Loop options
|
|
240
|
-
|
|
241
|
-
| Option | Type | Default | Description |
|
|
242
|
-
| --------------- | ----------------------------------- | ------------ | ------------------------------------------------------------- |
|
|
243
|
-
| `target` | `Element \| Document` | required | Element to observe for visibility, or `document` for the page |
|
|
244
|
-
| `onTick` | `(frame: FrameState) => void` | required | Called each frame while running |
|
|
245
|
-
| `fps` | `number` | — | Cap frames per second |
|
|
246
|
-
| `reducedMotion` | `'pause' \| 'complete' \| 'ignore'` | `'pause'` | Behavior when user prefers reduced motion |
|
|
247
|
-
| `degraded` | `'throttle' \| 'pause' \| 'ignore'` | `'throttle'` | Behavior when quality degrades |
|
|
248
|
-
| `degradedFps` | `number` | `30` | FPS cap in degraded throttle mode |
|
|
249
|
-
| `onPhaseChange` | `(phase, reason) => void` | — | Called on every phase transition |
|
|
250
|
-
|
|
251
|
-
### createTicker
|
|
252
|
-
|
|
253
|
-
The low-level rAF clock underneath `createLoop`. Use it when you need a frame loop without visibility management (background processing, audio sync, non-visual timing).
|
|
254
|
-
|
|
255
|
-
```ts
|
|
256
|
-
import { createTicker } from 'phase';
|
|
257
|
-
|
|
258
|
-
const ticker = createTicker({
|
|
259
|
-
onTick: (frame) => {
|
|
260
|
-
/* runs every frame */
|
|
261
|
-
},
|
|
262
|
-
fps: 30,
|
|
263
|
-
});
|
|
264
|
-
ticker.start();
|
|
265
|
-
```
|
|
266
|
-
|
|
267
|
-
All tickers share a single `requestAnimationFrame` loop. Every subscriber receives the same browser-supplied timestamp each frame, so independent animations stay in visual sync.
|
|
268
|
-
|
|
269
|
-
#### Ticker phases
|
|
270
|
-
|
|
271
|
-
| Phase | Meaning | Transitions |
|
|
272
|
-
| --------- | ------------------------ | --------------------------- |
|
|
273
|
-
| `idle` | Created, not started | → `running` via `start()` |
|
|
274
|
-
| `running` | Actively ticking | → `paused` via `pause()` |
|
|
275
|
-
| `paused` | Suspended, resumable | → `running` via `resume()` |
|
|
276
|
-
| `stopped` | Terminal, cannot restart | via `stop()` from any state |
|
|
277
|
-
|
|
278
|
-
### createSight
|
|
279
|
-
|
|
280
|
-
Answers one question: is this element visible right now? Combines `document.visibilitychange`, `pageshow` (bfcache restore), and `IntersectionObserver` into a single phase.
|
|
281
|
-
|
|
282
|
-
```ts
|
|
283
|
-
import { createSight } from 'phase';
|
|
284
|
-
|
|
285
|
-
const sight = createSight({
|
|
286
|
-
target: el,
|
|
287
|
-
onPhaseChange: (phase, reason) => {
|
|
288
|
-
// phase: 'visible' | 'hidden' | 'unknown'
|
|
289
|
-
// reason: 'initial' | 'viewport' | 'document' | 'bfcache' | 'all-hidden'
|
|
290
|
-
},
|
|
291
|
-
});
|
|
292
|
-
```
|
|
293
|
-
|
|
294
|
-
`phase` is `'visible'` only when the document is visible AND the element is in the viewport. Uses a pooled `IntersectionObserver` (20 elements with the same options share one observer instance).
|
|
295
|
-
|
|
296
|
-
### createLifecycle
|
|
297
|
-
|
|
298
|
-
The activation decision for an animation, decoupled from who drives the frames. Composes visibility (`createSight`), reduced motion, and a manual pause into a single `active` / `paused` phase.
|
|
299
|
-
|
|
300
|
-
Use `createLifecycle` when you own your render loop (a three.js/WebGL renderer, a Web Worker, or any non-rAF work that should pause when off-screen or under reduced motion). When you want `phase` to drive the loop for you, use [`createLoop`](#createloop) instead.
|
|
301
|
-
|
|
302
|
-
```ts
|
|
303
|
-
import { createLifecycle } from 'phase';
|
|
304
|
-
|
|
305
|
-
const lifecycle = createLifecycle({
|
|
306
|
-
target: canvas,
|
|
307
|
-
onPhaseChange: (phase, reason) => {
|
|
308
|
-
// phase: 'idle' | 'active' | 'paused' | 'stopped'
|
|
309
|
-
// reason: 'started' | 'resumed' | 'sight' | 'reduced-motion' | 'manual' | 'disposed' | 'initial'
|
|
310
|
-
if (phase === 'active') renderer.start();
|
|
311
|
-
else renderer.stop(); // your loop, your teardown
|
|
312
|
-
},
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
// Manual pause (e.g. a panel opened over the hero):
|
|
316
|
-
lifecycle.pause();
|
|
317
|
-
lifecycle.resume();
|
|
318
|
-
|
|
319
|
-
// cleanup:
|
|
320
|
-
lifecycle.stop();
|
|
321
|
-
```
|
|
322
|
-
|
|
323
|
-
`createLoop` is built on `createLifecycle` (it adds a ticker and quality signals on top). Loop-level optimizations (shared clock, zero-allocation `FrameState`, delta clamping, FPS cap, strong pause) only apply when `phase` drives the loop. Lifecycle-level optimizations (pooled observers, composed document-visibility + bfcache + viewport, reduced motion) carry over to consumer-owned loops.
|
|
324
|
-
|
|
325
|
-
#### Lifecycle phases
|
|
326
|
-
|
|
327
|
-
| Phase | Meaning | Possible reasons |
|
|
328
|
-
| --------- | -------------------------------- | ----------------------------------- |
|
|
329
|
-
| `idle` | Created but not started | `initial` |
|
|
330
|
-
| `active` | Should be animating | `started`, `resumed` |
|
|
331
|
-
| `paused` | Off-screen, reduced motion, etc. | `sight`, `reduced-motion`, `manual` |
|
|
332
|
-
| `stopped` | Permanently disposed | `disposed` |
|
|
333
|
-
|
|
334
|
-
Pause priority is `reduced-motion` > `sight` > `manual`.
|
|
335
|
-
|
|
336
|
-
### createScrollProgress
|
|
337
|
-
|
|
338
|
-
Reports what fraction of an element is currently visible in the viewport (0–1), via the shared IntersectionObserver pool. Zero forced reflows, zero extra observers. Ideal for reveal/opacity effects.
|
|
339
|
-
|
|
340
|
-
> **Visibility ratio, not scroll offset.** This reports `intersectionRatio` (how much of an element is visible in the viewport), which plateaus for tall elements once they fill it. For a scroll container's _own_ offset (scrollbars, carousels, or the page via `target: 'page'` on the hook) use [`createScroll`](#createscroll); for CSS-declarative scroll-linked animation use the native `ScrollTimeline` API; for spring/gesture scroll use `motion`.
|
|
341
|
-
|
|
342
|
-
```ts
|
|
343
|
-
import { createScrollProgress } from 'phase';
|
|
344
|
-
|
|
345
|
-
const progress = createScrollProgress({
|
|
346
|
-
target: el,
|
|
347
|
-
onProgress: (ratio) => {
|
|
348
|
-
el.style.opacity = String(ratio);
|
|
349
|
-
},
|
|
350
|
-
});
|
|
351
|
-
|
|
352
|
-
// progress.ratio === 0.65 (synchronous read)
|
|
353
|
-
|
|
354
|
-
// cleanup:
|
|
355
|
-
progress.stop();
|
|
356
|
-
```
|
|
357
|
-
|
|
358
|
-
The `steps` option controls threshold granularity. Default `20` generates 21 evenly-spaced thresholds (0%, 5%, 10%, …, 100%). Multiple instances with the same `steps` share a single IO, adding zero extra observers.
|
|
359
|
-
|
|
360
|
-
#### ScrollProgress options
|
|
361
|
-
|
|
362
|
-
| Option | Type | Default | Description |
|
|
363
|
-
| ------------ | ----------------------------- | -------- | ---------------------------------------------- |
|
|
364
|
-
| `target` | `Element \| Document` | required | Element to observe, or `document` for the page |
|
|
365
|
-
| `onProgress` | `(ratio: number) => void` | required | Called at each threshold crossing |
|
|
366
|
-
| `steps` | `number` | `20` | Number of evenly-spaced thresholds |
|
|
367
|
-
| `root` | `Element \| Document \| null` | — | IO root element |
|
|
368
|
-
| `rootMargin` | `string` | — | IO root margin |
|
|
369
|
-
|
|
370
|
-
### createScroll
|
|
371
|
-
|
|
372
|
-
Tracks a scroll container's offset and progress. Reads `scrollLeft`/`scrollTop` once per rAF frame and reads the reflow-heavy geometry (`scrollWidth`/`clientWidth`) only on a coalesced resize or an explicit `measure()`, never on the scroll path. Auto-pauses off-screen via the shared IntersectionObserver pool. This is to `scroll` + `scrollWidth` what `createPointer` is to `pointermove` + `getBoundingClientRect`.
|
|
373
|
-
|
|
374
|
-
> **Scroll offset, not visibility ratio.** This reports the element's own scroll position (for scrollbars, carousels, position indicators). For _how much of an element is in the viewport_, use [`createScrollProgress`](#createscrollprogress); for CSS-declarative scroll-linked animation, use the native `ScrollTimeline` API.
|
|
375
|
-
|
|
376
|
-
```ts
|
|
377
|
-
import { createScroll } from 'phase';
|
|
378
|
-
|
|
379
|
-
const scroll = createScroll({
|
|
380
|
-
target: viewport,
|
|
381
|
-
onScroll: (s) => {
|
|
382
|
-
// thumb CSS needs `transform-origin: left` so scaleX anchors to the track start
|
|
383
|
-
thumb.style.transform = `translateX(${s.progressX * (1 - s.visibleX) * 100}%) scaleX(${s.visibleX})`;
|
|
384
|
-
prevButton.disabled = s.x <= 1;
|
|
385
|
-
nextButton.disabled = s.x >= s.maxX - 1;
|
|
386
|
-
},
|
|
387
|
-
});
|
|
388
|
-
|
|
389
|
-
// scroll.state.progressX === 0.5 (synchronous read)
|
|
390
|
-
|
|
391
|
-
// after mutating scrollable content:
|
|
392
|
-
scroll.measure();
|
|
393
|
-
|
|
394
|
-
// cleanup:
|
|
395
|
-
scroll.stop();
|
|
396
|
-
```
|
|
397
|
-
|
|
398
|
-
`onScroll` receives the same `ScrollState` object every frame (mutated in place, zero per-frame allocations): `x`, `y`, `maxX`, `maxY`, `progressX`, `progressY`, and the visible fractions `visibleX`/`visibleY` (`clientWidth / scrollWidth`, i.e. a scrollbar thumb's `scaleX`). The `ResizeObserver` recomputes geometry on container resize; call `measure()` after content changes that alter `scrollWidth`.
|
|
399
|
-
|
|
400
|
-
#### Scroll options
|
|
401
|
-
|
|
402
|
-
| Option | Type | Default | Description |
|
|
403
|
-
| --------------------- | ------------------------------ | --------- | -------------------------------------------------- |
|
|
404
|
-
| `target` | `Element \| Document` | required | Scroll container, or `document` for the page |
|
|
405
|
-
| `onScroll` | `(state: ScrollState) => void` | required | Called once per rAF frame with position + progress |
|
|
406
|
-
| `onPhaseChange` | `(phase, reason) => void` | — | Called on phase transitions |
|
|
407
|
-
| `visibility` | `'pause' \| 'ignore'` | `'pause'` | Pause tracking when off-screen, or ignore |
|
|
408
|
-
| `intersectionOptions` | `IntersectionObserverInit` | — | Forwarded to the visibility observer |
|
|
409
|
-
| `signal` | `AbortSignal` | — | Stops the tracker when aborted |
|
|
410
|
-
|
|
411
|
-
The options type is `CreateScrollOptions` (`ScrollOptions` is a `lib.dom` global and must not be shadowed).
|
|
412
|
-
|
|
413
|
-
Pass `document` to track the page scroller. Offsets and geometry then come from `document.scrollingElement`, and since the page is never off-screen, `visibility: 'pause'` reacts to tab visibility alone and creates no `IntersectionObserver`. Use it for scroll progress bars, condensing headers, and scroll-to-top affordances instead of a bare `window` scroll listener.
|
|
414
|
-
|
|
415
|
-
### createThrottle
|
|
416
|
-
|
|
417
|
-
Frame-aligned, visibility-aware throttle for event-driven work below frame rate (socket emits, worker messaging, expensive recompute). Leading calls fire synchronously; a pending trailing call fires with the latest value on the first animation frame at or past `interval`. Nothing is scheduled while the trigger is idle or the document is hidden.
|
|
418
|
-
|
|
419
|
-
> **Event-driven, not a loop.** This fires on the trigger and idles otherwise. To cap a continuous render loop, use `fps` on [`createLoop`](#createloop). To think in rates, `interval: 1000 / 20` reads as "at most 20 per second".
|
|
420
|
-
|
|
421
|
-
```ts
|
|
422
|
-
import { createThrottle } from 'phase';
|
|
423
|
-
|
|
424
|
-
const throttle = createThrottle({
|
|
425
|
-
callback: (state) => socket.emit('cursor', state.x, state.y),
|
|
426
|
-
interval: 50,
|
|
427
|
-
});
|
|
428
|
-
|
|
429
|
-
const pointer = createPointer({ element, onPointer: throttle.call });
|
|
430
|
-
|
|
431
|
-
// throttle.flush() fires a pending trailing call now
|
|
432
|
-
// throttle.cancel() discards it and resets the window
|
|
433
|
-
// cleanup:
|
|
434
|
-
throttle.stop();
|
|
435
|
-
```
|
|
436
|
-
|
|
437
|
-
When the document hides, a pending call is flushed with the latest value (default) or dropped per `hidden`. Calls made while hidden are recorded but fire nothing until the document is visible again.
|
|
438
|
-
|
|
439
|
-
#### Throttle options
|
|
440
|
-
|
|
441
|
-
| Option | Type | Default | Description |
|
|
442
|
-
| ---------- | ----------------------------------- | --------- | --------------------------------------------- |
|
|
443
|
-
| `callback` | `(value: T) => void` | required | Called with the latest value passed to `call` |
|
|
444
|
-
| `interval` | `number` | required | Minimum ms between invocations |
|
|
445
|
-
| `edge` | `'leading' \| 'trailing' \| 'both'` | `'both'` | Which edges fire |
|
|
446
|
-
| `hidden` | `'flush' \| 'drop'` | `'flush'` | Pending-call policy when the document hides |
|
|
447
|
-
| `signal` | `AbortSignal` | — | Stops the throttle when aborted |
|
|
448
|
-
|
|
449
|
-
### createDebounce
|
|
450
|
-
|
|
451
|
-
Visibility-aware trailing debounce: fires the callback with the latest value once `wait` ms pass without a new call. No timer runs while the document is hidden; the quiet period restarts on return. Use it for work that should wait out a burst, like reallocating canvas buffers after a resize stream settles.
|
|
452
|
-
|
|
453
|
-
```ts
|
|
454
|
-
import { createDebounce } from 'phase';
|
|
455
|
-
|
|
456
|
-
const debounce = createDebounce({
|
|
457
|
-
callback: (size) => reallocateBuffers(size),
|
|
458
|
-
wait: 250,
|
|
459
|
-
});
|
|
460
|
-
|
|
461
|
-
debounce.call({ width, height });
|
|
462
|
-
|
|
463
|
-
// cleanup:
|
|
464
|
-
debounce.stop();
|
|
465
|
-
```
|
|
466
|
-
|
|
467
|
-
Same surface as `createThrottle`: `flush()`, `cancel()`, a synchronous `pending` read, and terminal `stop()`.
|
|
468
|
-
|
|
469
|
-
#### Debounce options
|
|
470
|
-
|
|
471
|
-
| Option | Type | Default | Description |
|
|
472
|
-
| ---------- | -------------------- | --------- | --------------------------------------------- |
|
|
473
|
-
| `callback` | `(value: T) => void` | required | Called with the latest value passed to `call` |
|
|
474
|
-
| `wait` | `number` | required | Quiet period in ms; each call restarts it |
|
|
475
|
-
| `hidden` | `'flush' \| 'drop'` | `'flush'` | Pending-call policy when the document hides |
|
|
476
|
-
| `signal` | `AbortSignal` | — | Stops the debounce when aborted |
|
|
477
|
-
|
|
478
|
-
### createRenderState
|
|
479
|
-
|
|
480
|
-
Reports whether the browser is rendering an element or skipping it under `content-visibility: auto`. Use it to pause raw work inside deferred content; `phase` loops already pause themselves.
|
|
481
|
-
|
|
482
|
-
```ts
|
|
483
|
-
import { createRenderState } from 'phase';
|
|
484
|
-
|
|
485
|
-
const renderState = createRenderState({
|
|
486
|
-
target: el,
|
|
487
|
-
onPhaseChange: (phase) => {
|
|
488
|
-
if (phase === 'skipped') clock.pause();
|
|
489
|
-
else clock.resume();
|
|
490
|
-
},
|
|
491
|
-
});
|
|
492
|
-
|
|
493
|
-
renderState.stop();
|
|
494
|
-
```
|
|
495
|
-
|
|
496
|
-
It listens to `contentvisibilityautostatechange`, the browser's actual paint decision, without changing layout.
|
|
497
|
-
|
|
498
|
-
### createDevicePixelRatio
|
|
499
|
-
|
|
500
|
-
Tracks `devicePixelRatio` changes through a shared media-query subscription. Use it for framework-free canvas, WebGL, or worker renderers that own their buffer sizing.
|
|
501
|
-
|
|
502
|
-
```ts
|
|
503
|
-
import { createDevicePixelRatio } from 'phase';
|
|
504
|
-
|
|
505
|
-
const dpr = createDevicePixelRatio({
|
|
506
|
-
onChange: (value) => renderer.setPixelRatio(Math.min(value, 2)),
|
|
507
|
-
});
|
|
508
|
-
|
|
509
|
-
// dpr.dpr is always current
|
|
510
|
-
dpr.stop();
|
|
511
|
-
```
|
|
512
|
-
|
|
513
|
-
`useCanvas` handles DPR automatically; use this primitive only when you own the renderer.
|
|
514
|
-
|
|
515
|
-
### createMutation
|
|
516
|
-
|
|
517
|
-
A lifecycle-aware `MutationObserver`: records are coalesced into one callback per animation frame, observation pauses off-screen by default, and teardown is explicit.
|
|
518
|
-
|
|
519
|
-
```ts
|
|
520
|
-
import { createMutation } from 'phase';
|
|
521
|
-
|
|
522
|
-
const mutation = createMutation({
|
|
523
|
-
target: list,
|
|
524
|
-
mutation: { childList: true },
|
|
525
|
-
onMutations: (records) => syncItems(records),
|
|
526
|
-
});
|
|
527
|
-
|
|
528
|
-
mutation.stop();
|
|
529
|
-
```
|
|
530
|
-
|
|
531
|
-
Reserve it for structural or narrow attribute changes. For dimensions, use ResizeObserver-backed `useSize`; reading layout inside `onMutations` still forces a reflow.
|
|
532
|
-
|
|
533
|
-
### createPointer
|
|
534
|
-
|
|
535
|
-
Tracks pointer position relative to an element, batching high-frequency events into one callback and one bounds read per animation frame. It pauses when the element is off-screen.
|
|
536
|
-
|
|
537
|
-
```ts
|
|
538
|
-
import { createPointer } from 'phase';
|
|
539
|
-
|
|
540
|
-
const pointer = createPointer({
|
|
541
|
-
target: surface,
|
|
542
|
-
onPointer: (state) => {
|
|
543
|
-
cursor.style.transform = `translate(${state.x}px, ${state.y}px)`;
|
|
544
|
-
},
|
|
545
|
-
});
|
|
546
|
-
|
|
547
|
-
// pointer.state is always current
|
|
548
|
-
pointer.stop();
|
|
549
|
-
```
|
|
550
|
-
|
|
551
|
-
Use CSS `:hover` for hover state and a gesture library for drag physics. This primitive is for continuous element-relative coordinates.
|
|
552
|
-
|
|
553
|
-
### whenIdle
|
|
554
|
-
|
|
555
|
-
Runs one callback when the browser is idle, with a timeout fallback for browsers without `requestIdleCallback`. The returned function cancels pending work.
|
|
556
|
-
|
|
557
|
-
```ts
|
|
558
|
-
import { whenIdle } from 'phase';
|
|
559
|
-
|
|
560
|
-
const cancel = whenIdle(() => warmCache(), { timeout: 2000 });
|
|
561
|
-
cancel();
|
|
562
|
-
```
|
|
563
|
-
|
|
564
|
-
In React, use `useWhenIdle` for effects, `useIdle` for a boolean, or `WhenIdle` to mount a subtree.
|
|
565
|
-
|
|
566
|
-
### prefersReducedMotion
|
|
567
|
-
|
|
568
|
-
Returns `true` when reduced motion is enabled at the OS level. Use it to gate expensive setup or dynamic imports.
|
|
569
|
-
|
|
570
|
-
```ts
|
|
571
|
-
import { prefersReducedMotion } from 'phase';
|
|
572
|
-
|
|
573
|
-
if (!prefersReducedMotion()) {
|
|
574
|
-
const { startParticleSystem } = await import('./particles');
|
|
575
|
-
startParticleSystem(canvas);
|
|
576
|
-
}
|
|
577
|
-
```
|
|
578
|
-
|
|
579
|
-
All hooks and primitives consult this signal automatically. You only need it directly for conditional imports or setup logic.
|
|
580
|
-
|
|
581
|
-
## Easing and math
|
|
582
|
-
|
|
583
|
-
Pure functions with no browser APIs, side effects, or React. Safe in server components, build scripts, and tests.
|
|
584
|
-
|
|
585
|
-
```ts
|
|
586
|
-
import { lerp, clamp01, easeOutCubic, remap } from 'phase/ease';
|
|
587
|
-
```
|
|
588
|
-
|
|
589
|
-
### Easing functions
|
|
590
|
-
|
|
591
|
-
| Function | Character |
|
|
592
|
-
| ---------------- | ------------------------------- |
|
|
593
|
-
| `easeOutCubic` | Fast start, smooth deceleration |
|
|
594
|
-
| `easeOutQuart` | Sharper deceleration |
|
|
595
|
-
| `easeOutBack` | Overshoots target, snaps back |
|
|
596
|
-
| `easeInOutCubic` | Symmetric S-curve |
|
|
597
|
-
| `linear` | No easing (identity) |
|
|
598
|
-
|
|
599
|
-
All easing functions take a progress value (0–1) and return a curved progress value (0–1). They don't know about time, pixels, or anything else. They reshape a number.
|
|
600
|
-
|
|
601
|
-
### Math utilities
|
|
602
|
-
|
|
603
|
-
| Function | Description | Example |
|
|
604
|
-
| -------------------------------- | ------------------------------ | ---------------------------------- |
|
|
605
|
-
| `clamp(value, min, max)` | Constrain to range | `clamp(150, 0, 100)` → `100` |
|
|
606
|
-
| `clamp01(value)` | Constrain to 0–1 | `clamp01(-0.5)` → `0` |
|
|
607
|
-
| `lerp(start, end, t)` | Linear interpolation | `lerp(0, 100, 0.5)` → `50` |
|
|
608
|
-
| `inverseLerp(start, end, value)` | Where is value in range? (0–1) | `inverseLerp(0, 100, 75)` → `0.75` |
|
|
609
|
-
| `remap(options)` | Map from one range to another | Input range → output range |
|
|
610
|
-
|
|
611
|
-
### The pattern
|
|
612
|
-
|
|
613
|
-
```ts
|
|
614
|
-
const progress = clamp01(elapsed / duration); // normalize time to 0–1
|
|
615
|
-
const eased = easeOutCubic(progress); // reshape the curve
|
|
616
|
-
const value = lerp(startPos, endPos, eased); // map to your range
|
|
617
|
-
```
|
|
618
|
-
|
|
619
|
-
Easing, interpolation, and your value range are three separate concerns. `phase` keeps them separate so you can mix and match.
|
|
620
|
-
|
|
621
|
-
## Choosing a primitive
|
|
622
|
-
|
|
623
|
-
| Need | Use |
|
|
624
|
-
| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
|
625
|
-
| Check on-screen visibility | `useSight` (visibility only) |
|
|
626
|
-
| Run a frame loop via `phase` | `useLoop` (DOM) / `useCanvas` (canvas) |
|
|
627
|
-
| Pause/resume your own loop (WebGL, three.js, Web Worker) | `useLifecycle` (active/paused signal) |
|
|
628
|
-
| Animate a single value in render output | `useTween` |
|
|
629
|
-
| Animate mount/unmount transitions | `Presence` / `Swap` / `WhenVisible` |
|
|
630
|
-
| Skip painting off-screen content (keep in DOM) | `Defer` |
|
|
631
|
-
| Defer non-critical UI until the browser is idle | `WhenIdle` / `useIdle` |
|
|
632
|
-
| Run a side effect or prefetch when idle | `useWhenIdle` |
|
|
633
|
-
| Pause non-`phase` work inside a `Defer` subtree | `useRenderState` |
|
|
634
|
-
| React to DOM mutations without synchronous callback storms | `useMutation` |
|
|
635
|
-
| Track element-relative pointer position without per-event layout reads | `usePointer` |
|
|
636
|
-
| Track DPR for a renderer you own | `useDevicePixelRatio` |
|
|
637
|
-
| Check reduced motion for non-`phase` work | `usePrefersReducedMotion` |
|
|
638
|
-
| Subscribe to scroll, size, or media values reactively | `useScrollProgress` / `useSize` / `useContainerQuery` / `useMediaQuery` |
|
|
639
|
-
| Scroll/size/visibility without re-renders? | Same hooks with a callback (`onProgress` / `onResize` / `onVisibilityChange`), read via ref |
|
|
640
|
-
| Rate-limit event-driven work (sockets, workers) | `useThrottledCallback` |
|
|
641
|
-
| Run once after a burst settles (resize, typing) | `useDebouncedCallback` |
|
|
642
|
-
|
|
643
|
-
**`useSight` vs `useLifecycle`:** `useSight` reports pure visibility (for lazy-mounting, analytics, `WhenVisible`). `useLifecycle` folds in reduced motion and a manual pause, so you can't accidentally animate for users who asked not to. If you're gating an animation, use `useLifecycle`. If you're gating content, use `useSight`.
|
|
644
|
-
|
|
645
|
-
## React hooks
|
|
646
|
-
|
|
647
|
-
### useLoop
|
|
648
|
-
|
|
649
|
-
The primary React hook. Wraps `createLoop` with React lifecycle management.
|
|
650
|
-
|
|
651
|
-
```tsx
|
|
652
|
-
import { useLoop } from 'phase/react';
|
|
653
|
-
|
|
654
|
-
const { ref, phase, phaseReason } = useLoop({
|
|
655
|
-
onTick: (frame) => {
|
|
656
|
-
ref.current.style.transform = `translateX(${frame.elapsed * 0.1}px)`;
|
|
657
|
-
},
|
|
658
|
-
});
|
|
659
|
-
return <div ref={ref} />;
|
|
660
|
-
```
|
|
661
|
-
|
|
662
|
-
Attach the returned `ref` to the element you want to animate. To bring your own, pass `ref` in the options.
|
|
663
|
-
|
|
664
|
-
Your `onTick` callback always sees the latest props, state, and refs without restarting the loop (stored internally via `useSyncedRef`).
|
|
665
|
-
|
|
666
|
-
**Never call `setState` inside `onTick`.** It runs 60 times per second. Write to refs or the DOM directly. The only re-render trigger is `phase` changing (an infrequent lifecycle event).
|
|
667
|
-
|
|
668
|
-
### useLifecycle
|
|
669
|
-
|
|
670
|
-
The activation signal for a loop you own. Wraps [`createLifecycle`](#createlifecycle), returning `active` / `paused` so a consumer-owned render loop (WebGL, three.js, a Web Worker) can pause when off-screen or under reduced motion.
|
|
671
|
-
|
|
672
|
-
```tsx
|
|
673
|
-
import { useLifecycle } from 'phase/react';
|
|
674
|
-
|
|
675
|
-
function Hero() {
|
|
676
|
-
const { ref, isActive } = useLifecycle();
|
|
677
|
-
|
|
678
|
-
useEffect(() => {
|
|
679
|
-
if (!isActive) return; // off-screen / reduced motion / paused
|
|
680
|
-
let raf = requestAnimationFrame(function render() {
|
|
681
|
-
renderer.render();
|
|
682
|
-
raf = requestAnimationFrame(render);
|
|
683
|
-
});
|
|
684
|
-
return () => cancelAnimationFrame(raf);
|
|
685
|
-
}, [isActive]);
|
|
686
|
-
|
|
687
|
-
return <canvas ref={ref} />;
|
|
688
|
-
}
|
|
689
|
-
```
|
|
690
|
-
|
|
691
|
-
| Option | Type | Default | Description |
|
|
692
|
-
| --------------------- | -------------------------- | --------- | ----------------------------------------------------- |
|
|
693
|
-
| `ref` | `RefObject` | returned | Bring your own, or attach the returned `ref` |
|
|
694
|
-
| `reducedMotion` | `'pause' \| 'ignore'` | `'pause'` | Whether reduced motion pauses the lifecycle |
|
|
695
|
-
| `paused` | `boolean` | `false` | Manual pause (e.g. a panel opened over the animation) |
|
|
696
|
-
| `enabled` | `boolean` | `true` | When `false`, tears down and reports `idle` |
|
|
697
|
-
| `intersectionOptions` | `IntersectionObserverInit` | — | Forwarded to the underlying observer |
|
|
698
|
-
|
|
699
|
-
Returns `{ ref, phase, phaseReason, isActive }`. See [Choosing a primitive](#choosing-a-primitive) for `useSight` vs `useLifecycle`.
|
|
700
|
-
|
|
701
|
-
### useCanvas
|
|
702
|
-
|
|
703
|
-
Everything `useLoop` provides, plus DPR-aware buffer sizing, ResizeObserver coalescing, and GPU context loss recovery.
|
|
704
|
-
|
|
705
|
-
```tsx
|
|
706
|
-
import { useRef } from 'react';
|
|
707
|
-
import { useCanvas } from 'phase/react';
|
|
708
|
-
|
|
709
|
-
const containerRef = useRef(null);
|
|
710
|
-
const canvasRef = useRef(null);
|
|
711
|
-
|
|
712
|
-
const { phase } = useCanvas({
|
|
713
|
-
containerRef,
|
|
714
|
-
canvasRef,
|
|
715
|
-
draw: (ctx, frame, size) => {
|
|
716
|
-
ctx.clearRect(0, 0, size.width, size.height);
|
|
717
|
-
// ctx is already scaled for devicePixelRatio — draw in CSS pixels
|
|
718
|
-
},
|
|
719
|
-
});
|
|
720
|
-
|
|
721
|
-
return (
|
|
722
|
-
<div ref={containerRef}>
|
|
723
|
-
<canvas ref={canvasRef} />
|
|
724
|
-
</div>
|
|
725
|
-
);
|
|
726
|
-
```
|
|
727
|
-
|
|
728
|
-
`useCanvas` coordinates two elements (a sizing container and the canvas), so you pass both refs in.
|
|
729
|
-
|
|
730
|
-
| Concern | How useCanvas handles it |
|
|
731
|
-
| ------------ | -------------------------------------------------------------------------------------------------------------------------------- |
|
|
732
|
-
| DPR (retina) | Uses `devicePixelContentBoxSize` for exact physical pixels when available, falls back to `width * dpr`. Listens for DPR changes. |
|
|
733
|
-
| Resize | Shared ResizeObserver. Canvas resized on container change. No `getBoundingClientRect`. |
|
|
734
|
-
| Context loss | Listens for `contextlost`/`contextrestored`. Pauses on loss, recovers on restore. |
|
|
735
|
-
| Quality | When degraded, DPR drops to 1x automatically (halves GPU pixel count). |
|
|
736
|
-
|
|
737
|
-
Both hooks accept the same quality controls as `createLoop`: `degraded` and `degradedFps`. For heavy GPU work, consider `degraded: 'pause'`.
|
|
738
|
-
|
|
739
|
-
### useTween
|
|
740
|
-
|
|
741
|
-
Animates a number from A to B over a duration. Calls `setState` per frame (appropriate when the animated value is used in render output).
|
|
742
|
-
|
|
743
|
-
```tsx
|
|
744
|
-
import { useTween } from 'phase/react';
|
|
745
|
-
|
|
746
|
-
const opacity = useTween({ to: isVisible ? 1 : 0, duration: 300 });
|
|
747
|
-
```
|
|
748
|
-
|
|
749
|
-
Use `useTween` for single values where the render is cheap (counters, progress bars, opacity). Use `useLoop` when animating many elements or doing canvas work, since per-frame `setState` doesn't scale.
|
|
750
|
-
|
|
751
|
-
Reduced motion default: `'complete'` checks the preference when a tween starts and jumps to the destination when needed. Set `reducedMotion: 'ignore'` to skip the preference read. The exported `TweenReducedMotion` type is `'complete' | 'ignore'`; finite tweens do not support `'pause'` because freezing between endpoints leaves the value incomplete.
|
|
752
|
-
|
|
753
|
-
### usePresence
|
|
754
|
-
|
|
755
|
-
The hook behind `<Presence>`. Use directly when you need full control over mount/unmount lifecycle.
|
|
756
|
-
|
|
757
|
-
```tsx
|
|
758
|
-
import { usePresence } from 'phase/react';
|
|
759
|
-
|
|
760
|
-
const { phase, ref, mounted, enter } = usePresence({ show: isOpen });
|
|
761
|
-
if (!mounted) return null;
|
|
762
|
-
return (
|
|
763
|
-
<div
|
|
764
|
-
ref={ref}
|
|
765
|
-
data-phase={phase}
|
|
766
|
-
data-enter={enter === 'animate' ? 'animate' : undefined}
|
|
767
|
-
className="transition-opacity data-[enter=animate]:starting:opacity-0 data-[phase=exiting]:opacity-0"
|
|
768
|
-
/>
|
|
769
|
-
);
|
|
770
|
-
```
|
|
771
|
-
|
|
772
|
-
#### Presence phases
|
|
773
|
-
|
|
774
|
-
`idle` → `entered` → `exiting` → `exited`
|
|
775
|
-
|
|
776
|
-
| Phase | Meaning | `mounted` |
|
|
777
|
-
| --------- | ---------------------------------------- | --------- |
|
|
778
|
-
| `idle` | Not shown (initial or after reveal exit) | `false` |
|
|
779
|
-
| `entered` | Visible and active | `true` |
|
|
780
|
-
| `exiting` | Exit animation in progress | `true` |
|
|
781
|
-
| `exited` | Exit complete, ready for unmount | `false` |
|
|
782
|
-
|
|
783
|
-
#### Options
|
|
784
|
-
|
|
785
|
-
| Option | Type | Default | Description |
|
|
786
|
-
| --------------- | ------------------------ | ----------- | ---------------------------------- |
|
|
787
|
-
| `show` | `boolean` | required | Visibility toggle |
|
|
788
|
-
| `mode` | `'mount' \| 'reveal'` | `'mount'` | Unmount after exit or stay in DOM |
|
|
789
|
-
| `enter` | `'animate' \| 'instant'` | `'animate'` | First-mount behavior |
|
|
790
|
-
| `exitDuration` | `number` | `5000` | Safety timeout for exit (ms) |
|
|
791
|
-
| `reducedMotion` | `'respect' \| 'ignore'` | `'respect'` | Reduced motion preference handling |
|
|
792
|
-
|
|
793
|
-
### useScrollProgress
|
|
794
|
-
|
|
795
|
-
Element visibility ratio as a 0–1 value. Wraps `createScrollProgress` with React lifecycle management. This is a _visibility_ fraction (how much of the element is on screen); for a scroll container's own _position_ (scrollbars, carousels) use [`useScroll`](#usescroll) instead. See the [note on scope](#createscrollprogress) for the full distinction.
|
|
796
|
-
|
|
797
|
-
```tsx
|
|
798
|
-
import { useScrollProgress } from 'phase/react';
|
|
799
|
-
|
|
800
|
-
function FadeIn({ children }) {
|
|
801
|
-
const { ref, progress } = useScrollProgress();
|
|
802
|
-
return (
|
|
803
|
-
<div ref={ref} style={{ opacity: progress }}>
|
|
804
|
-
{children}
|
|
805
|
-
</div>
|
|
806
|
-
);
|
|
807
|
-
}
|
|
808
|
-
```
|
|
809
|
-
|
|
810
|
-
Re-renders only at threshold crossings (~20 per full viewport traversal at default steps). `progress` is `0` before first observation.
|
|
811
|
-
|
|
812
|
-
### useScroll
|
|
813
|
-
|
|
814
|
-
Scroll offset and progress for a scroll container. Wraps `createScroll` with React lifecycle management. Position is delivered imperatively via `onScroll` (never per-frame state); only the phase (`tracking`/`paused`) is reactive. Mirrors `usePointer`.
|
|
815
|
-
|
|
816
|
-
```tsx
|
|
817
|
-
import { useRef } from 'react';
|
|
818
|
-
import { useScroll } from 'phase/react';
|
|
819
|
-
|
|
820
|
-
function Carousel({ children }) {
|
|
821
|
-
// thumb uses `origin-left` so scaleX anchors to the track start
|
|
822
|
-
const thumbRef = useRef<HTMLDivElement>(null);
|
|
823
|
-
const { ref, measure } = useScroll<HTMLDivElement>({
|
|
824
|
-
onScroll: (s) => {
|
|
825
|
-
thumbRef.current?.style.setProperty(
|
|
826
|
-
'transform',
|
|
827
|
-
`translateX(${s.progressX * (1 - s.visibleX) * 100}%) scaleX(${s.visibleX})`,
|
|
828
|
-
);
|
|
829
|
-
},
|
|
830
|
-
});
|
|
831
|
-
|
|
832
|
-
return (
|
|
833
|
-
<div ref={ref} className="overflow-x-auto">
|
|
834
|
-
{children}
|
|
835
|
-
</div>
|
|
836
|
-
);
|
|
837
|
-
}
|
|
838
|
-
```
|
|
839
|
-
|
|
840
|
-
Scrolling writes to the DOM directly with zero re-renders. Read the latest position on demand from `stateRef.current` (e.g. inside a `useLoop` tick), and call `measure()` after changing scrollable content.
|
|
841
|
-
|
|
842
|
-
### useThrottledCallback
|
|
843
|
-
|
|
844
|
-
Wraps `createThrottle` with React lifecycle management. Returns a stable-identity throttled function (with `flush()` and `cancel()` attached) that drops directly into any callback slot and always invokes the latest `callback`.
|
|
845
|
-
|
|
846
|
-
```tsx
|
|
847
|
-
import { usePointer, useThrottledCallback } from 'phase/react';
|
|
848
|
-
|
|
849
|
-
function LiveCursor() {
|
|
850
|
-
const emit = useThrottledCallback(
|
|
851
|
-
(s: PointerState) => socket.emit('cursor', { x: s.x, y: s.y }),
|
|
852
|
-
{ interval: 50 },
|
|
853
|
-
);
|
|
854
|
-
const { ref } = usePointer({ onPointer: emit });
|
|
855
|
-
return <div ref={ref} />;
|
|
856
|
-
}
|
|
857
|
-
```
|
|
858
|
-
|
|
859
|
-
Unmount and option changes discard a pending trailing call. When the final value must land, flush in your own cleanup: `useEffect(() => () => emit.flush(), [emit])`.
|
|
860
|
-
|
|
861
|
-
### useDebouncedCallback
|
|
862
|
-
|
|
863
|
-
Wraps `createDebounce` with React lifecycle management. Same shape as `useThrottledCallback`, but fires once `wait` ms pass without a new call.
|
|
864
|
-
|
|
865
|
-
```tsx
|
|
866
|
-
import { useSize, useDebouncedCallback } from 'phase/react';
|
|
867
|
-
|
|
868
|
-
function SimulationCanvas() {
|
|
869
|
-
const realloc = useDebouncedCallback(
|
|
870
|
-
(size: Size) => reallocateBuffers(size),
|
|
871
|
-
{ wait: 250 },
|
|
872
|
-
);
|
|
873
|
-
const { ref } = useSize({ onResize: realloc });
|
|
874
|
-
return <canvas ref={ref} />;
|
|
875
|
-
}
|
|
876
|
-
```
|
|
877
|
-
|
|
878
|
-
### useMutation
|
|
879
|
-
|
|
880
|
-
Wraps `createMutation` with ref management and automatic teardown. Mutation records stay imperative—delivered once per animation frame—while only infrequent `observing` / `paused` phase changes re-render.
|
|
881
|
-
|
|
882
|
-
```tsx
|
|
883
|
-
import { useMutation } from 'phase/react';
|
|
884
|
-
|
|
885
|
-
const { ref, phase } = useMutation({
|
|
886
|
-
mutation: { childList: true },
|
|
887
|
-
onMutations: (records) => syncItems(records),
|
|
888
|
-
});
|
|
889
|
-
|
|
890
|
-
return <ul ref={ref} data-observer-phase={phase} />;
|
|
891
|
-
```
|
|
892
|
-
|
|
893
|
-
Observation pauses off-screen by default. Set `visibility: 'ignore'` only for document-level coordination that must continue in the background.
|
|
894
|
-
|
|
895
|
-
### usePointer
|
|
896
|
-
|
|
897
|
-
Element-relative pointer tracking without per-event layout reads or per-frame React state. Position is delivered through `onPointer` and mirrored in `stateRef`; only enter/leave phase changes re-render.
|
|
898
|
-
|
|
899
|
-
```tsx
|
|
900
|
-
import { usePointer } from 'phase/react';
|
|
901
|
-
|
|
902
|
-
const { ref } = usePointer({
|
|
903
|
-
onPointer: ({ x, y, active }) => {
|
|
904
|
-
cursorRef.current?.style.setProperty(
|
|
905
|
-
'transform',
|
|
906
|
-
`translate(${x}px, ${y}px)`,
|
|
907
|
-
);
|
|
908
|
-
cursorRef.current?.toggleAttribute('data-active', active);
|
|
909
|
-
},
|
|
910
|
-
});
|
|
911
|
-
|
|
912
|
-
return <div ref={ref}>{children}</div>;
|
|
913
|
-
```
|
|
914
|
-
|
|
915
|
-
Use it for custom cursors, canvas interaction, and tooltips—not simple hover or drag gestures.
|
|
916
|
-
|
|
917
|
-
### Observation and utility hooks
|
|
918
|
-
|
|
919
|
-
| Hook | Purpose |
|
|
920
|
-
| ------------------------- | ------------------------------------------------------------------------------------- |
|
|
921
|
-
| `useSight` | Element visibility as a phase. Pass `onVisibilityChange` for zero-re-render mode |
|
|
922
|
-
| `useSize` | Element dimensions via shared ResizeObserver. Pass `onResize` for zero-re-render mode |
|
|
923
|
-
| `useContainerQuery` | Breakpoint matching against element width |
|
|
924
|
-
| `useScrollProgress` | Element visibility ratio (0–1). Pass `onProgress` for zero-re-render mode |
|
|
925
|
-
| `useMediaQuery` | CSS media query subscription (shared MQL pool) |
|
|
926
|
-
| `usePrefersReducedMotion` | Reactive reduced-motion preference for non-`phase` animation |
|
|
927
|
-
| `useDevicePixelRatio` | Reactive DPR for renderers outside `useCanvas` |
|
|
928
|
-
| `useSyncedRef` | Ref always in sync with latest value |
|
|
929
|
-
| `useStableCallback` | Stable-identity function that calls latest closure |
|
|
930
|
-
|
|
931
|
-
`useSight`, `useSize`, and `useScrollProgress` each support a transient mode: pass a callback (`onVisibilityChange`, `onResize`, `onProgress`) and the hook delivers updates via callback with zero re-renders. The reactive state field is omitted from the return type so accessing it is a compile-time error. An always-current ref (`phaseRef`, `sizeRef`, `progressRef`) is available in both modes.
|
|
932
|
-
|
|
933
|
-
## React components
|
|
934
|
-
|
|
935
|
-
### How animations work
|
|
936
|
-
|
|
937
|
-
One CSS pattern covers enter and exit across `Presence`, `WhenVisible`, and `Swap`:
|
|
938
|
-
|
|
939
|
-
```tsx
|
|
940
|
-
className =
|
|
941
|
-
'transition-opacity data-[enter=animate]:starting:opacity-0 data-[phase=exiting]:opacity-0';
|
|
942
|
-
```
|
|
943
|
-
|
|
944
|
-
No `motion-reduce:` class needed because reduced motion is handled automatically.
|
|
945
|
-
|
|
946
|
-
**Enter:** CSS `@starting-style` animates the element natively when `data-enter="animate"` is present. Zero JS during the animation.
|
|
947
|
-
|
|
948
|
-
**Exit:** `phase` stamps `data-phase="exiting"`, waits for `transitionend`/`animationend` (or a safety timeout), then unmounts. JS coordination is required because CSS has no "animate then remove from DOM" primitive.
|
|
949
|
-
|
|
950
|
-
**Reduced motion:** `phase` suppresses `data-enter="animate"` and skips the exit animation (instant unmount). No consumer effort.
|
|
951
|
-
|
|
952
|
-
### Presence
|
|
953
|
-
|
|
954
|
-
Renders a `div` that manages its own mount/unmount lifecycle, stamping `data-phase` for exit and `data-enter="animate"` for enter.
|
|
955
|
-
|
|
956
|
-
```tsx
|
|
957
|
-
import { Presence } from 'phase/react';
|
|
958
|
-
|
|
959
|
-
<Presence
|
|
960
|
-
show={isOpen}
|
|
961
|
-
className="transition-opacity data-[enter=animate]:starting:opacity-0 data-[phase=exiting]:opacity-0"
|
|
962
|
-
>
|
|
963
|
-
Modal content
|
|
964
|
-
</Presence>;
|
|
965
|
-
```
|
|
966
|
-
|
|
967
|
-
| Prop | Type | Default | Description |
|
|
968
|
-
| --------------- | ------------------------ | ----------- | --------------------------------- |
|
|
969
|
-
| `show` | `boolean` | required | Visibility toggle |
|
|
970
|
-
| `mode` | `'mount' \| 'reveal'` | `'mount'` | Unmount after exit or stay in DOM |
|
|
971
|
-
| `enter` | `'animate' \| 'instant'` | `'animate'` | First-mount animation behavior |
|
|
972
|
-
| `exitDuration` | `number` | `5000` | Safety timeout for exit (ms) |
|
|
973
|
-
| `reducedMotion` | `'respect' \| 'ignore'` | `'respect'` | Reduced motion handling |
|
|
974
|
-
|
|
975
|
-
Two modes:
|
|
976
|
-
|
|
977
|
-
| Mode | Behavior | Use case |
|
|
978
|
-
| ---------- | -------------------------------------------------- | ---------------------------------------- |
|
|
979
|
-
| `'mount'` | Added to DOM on show, removed after exit completes | Modals, toasts, menus |
|
|
980
|
-
| `'reveal'` | Always in DOM, visibility toggled via phase | Scroll reveals, SEO content, IO re-entry |
|
|
981
|
-
|
|
982
|
-
### WhenVisible
|
|
983
|
-
|
|
984
|
-
Mounts children when the element enters the viewport. One-shot (once triggered, stays mounted). Uses the pooled IntersectionObserver via `useSight`.
|
|
985
|
-
|
|
986
|
-
```tsx
|
|
987
|
-
import { WhenVisible } from 'phase/react';
|
|
988
|
-
|
|
989
|
-
<WhenVisible
|
|
990
|
-
rootMargin="200px"
|
|
991
|
-
className="transition-opacity data-[enter=animate]:starting:opacity-0"
|
|
992
|
-
>
|
|
993
|
-
<HeavyInteractiveChart />
|
|
994
|
-
</WhenVisible>;
|
|
995
|
-
```
|
|
996
|
-
|
|
997
|
-
Common pattern for viewport-gated lazy loading:
|
|
998
|
-
|
|
999
|
-
```tsx
|
|
1000
|
-
const HeavyChart = lazy(() => import('./heavy-chart'));
|
|
1001
|
-
|
|
1002
|
-
<WhenVisible
|
|
1003
|
-
rootMargin="200px"
|
|
1004
|
-
className="transition-opacity data-[enter=animate]:starting:opacity-0"
|
|
1005
|
-
>
|
|
1006
|
-
<Suspense fallback={<Skeleton />}>
|
|
1007
|
-
<HeavyChart />
|
|
1008
|
-
</Suspense>
|
|
1009
|
-
</WhenVisible>;
|
|
1010
|
-
```
|
|
1011
|
-
|
|
1012
|
-
| Prop | Type | Default | Description |
|
|
1013
|
-
| ------------ | -------------------- | --------- | --------------------------------- |
|
|
1014
|
-
| `rootMargin` | `string` | `'200px'` | IO rootMargin (preload headroom) |
|
|
1015
|
-
| `threshold` | `number \| number[]` | — | IO threshold |
|
|
1016
|
-
| `root` | `Element \| null` | — | IO root element |
|
|
1017
|
-
| `fallback` | `ReactNode` | — | Shown while awaiting intersection |
|
|
1018
|
-
|
|
1019
|
-
Reduced motion is automatic: `data-enter="animate"` is not stamped when reduced motion is preferred.
|
|
1020
|
-
|
|
1021
|
-
### Swap
|
|
1022
|
-
|
|
1023
|
-
Coordinated exit-then-enter transitions. The old state fully exits before the new state enters (no overlap, no z-index issues).
|
|
1024
|
-
|
|
1025
|
-
```tsx
|
|
1026
|
-
import { Swap } from 'phase/react';
|
|
1027
|
-
|
|
1028
|
-
<Swap active={success ? 'success' : 'form'}>
|
|
1029
|
-
<Swap.State
|
|
1030
|
-
id="form"
|
|
1031
|
-
className="transition-all data-[phase=exiting]:opacity-0"
|
|
1032
|
-
>
|
|
1033
|
-
<Form />
|
|
1034
|
-
</Swap.State>
|
|
1035
|
-
<Swap.State
|
|
1036
|
-
id="success"
|
|
1037
|
-
className="transition-all data-[enter=animate]:starting:opacity-0 data-[phase=exiting]:opacity-0"
|
|
1038
|
-
>
|
|
1039
|
-
<SuccessMessage />
|
|
1040
|
-
</Swap.State>
|
|
1041
|
-
</Swap>;
|
|
1042
|
-
```
|
|
1043
|
-
|
|
1044
|
-
Rapid changes (A → B → C during A's exit) skip intermediate states and advance directly to the latest `active`. First state appears instantly (CLS prevention); subsequent states animate via `@starting-style`.
|
|
1045
|
-
|
|
1046
|
-
## Rendering
|
|
1047
|
-
|
|
1048
|
-
`phase` is the _when_ layer (when to animate, when to render, when to pause), built from one set of signals. Alongside `WhenVisible`, two helpers skip rendering work for off-screen content. They differ in how aggressively they skip and whether the content survives server rendering:
|
|
1049
|
-
|
|
1050
|
-
| Helper | Defers | In DOM? | In SSR HTML? | Reach for it when |
|
|
1051
|
-
| ------------- | ----------------------------------- | ------- | ------------ | -------------------------------------------------- |
|
|
1052
|
-
| `Defer` | browser render (style/layout/paint) | yes | yes | content must stay crawlable but need not paint yet |
|
|
1053
|
-
| `WhenIdle` | React mount until idle | no | no | non-critical UI that shouldn't block first paint |
|
|
1054
|
-
| `WhenVisible` | React mount until near viewport | no | no | viewport-gated lazy loading / reveals |
|
|
1055
|
-
|
|
1056
|
-
### Defer
|
|
1057
|
-
|
|
1058
|
-
Skips the browser's rendering work (style, layout, paint) for off-screen content via `content-visibility: auto`, using pure CSS with no JS or observers. Children stay in the DOM and are server-rendered.
|
|
1059
|
-
|
|
1060
|
-
```tsx
|
|
1061
|
-
import { Defer } from 'phase/react';
|
|
1062
|
-
|
|
1063
|
-
<Defer estimatedHeight="600px" className="my-section">
|
|
1064
|
-
<ArticleSection />
|
|
1065
|
-
</Defer>;
|
|
1066
|
-
|
|
1067
|
-
// Use `as` for semantic elements (no wrapper div needed)
|
|
1068
|
-
<ul>
|
|
1069
|
-
{items.map((item) => (
|
|
1070
|
-
<Defer as="li" key={item.id} estimatedHeight="80px">
|
|
1071
|
-
<ItemContent item={item} />
|
|
1072
|
-
</Defer>
|
|
1073
|
-
))}
|
|
1074
|
-
</ul>;
|
|
1075
|
-
```
|
|
1076
|
-
|
|
1077
|
-
| Prop | Type | Default | Description |
|
|
1078
|
-
| ----------------- | -------------------------------------------- | ---------- | ---------------------------------------------------------- |
|
|
1079
|
-
| `as` | `ElementType` | `'div'` | HTML element to render (`'li'`, `'tr'`, `'section'`, etc.) |
|
|
1080
|
-
| `estimatedHeight` | `string` | `'1000px'` | Reserved size before first paint (any CSS length) |
|
|
1081
|
-
| ...rest | `Omit<HTMLAttributes<HTMLElement>, 'style'>` | — | Standard HTML attributes except `style` (use `className`) |
|
|
1082
|
-
|
|
1083
|
-
`contain-intrinsic-size: auto <estimatedHeight>` reserves space, so there is no layout shift. The browser remembers the real size after first paint. `Defer` defers rendering only, not hydration or mounting. There is no `style` prop: the render-skip styles are encapsulated so they can't be overridden. Style the wrapper with `className`.
|
|
1084
|
-
|
|
1085
|
-
`content-visibility: auto` applies paint containment, which clips all overflow to the element's padding edge. Box shadows, negative margins, and positioned content that bleeds outside the boundary will be cut off. If your content needs to overflow, move it outside the `Defer` or skip `Defer` for that container.
|
|
1086
|
-
|
|
1087
|
-
**Animations inside a `Defer` keep running.** `content-visibility` skips paint, not JavaScript. `phase`'s own loops (`useLoop`, `useCanvas`, `useLifecycle`) already self-pause off-screen via their own visibility observer. For raw work (a hand-written `requestAnimationFrame` loop, `setInterval`), gate it with `useRenderState`.
|
|
1088
|
-
|
|
1089
|
-
### WhenIdle
|
|
1090
|
-
|
|
1091
|
-
Mounts children once the browser is idle after first paint. One-shot. Use it for non-critical UI that should not compete with the critical path. Backed by the `whenIdle` core utility (`requestIdleCallback`).
|
|
1092
|
-
|
|
1093
|
-
```tsx
|
|
1094
|
-
import { WhenIdle } from 'phase/react';
|
|
1095
|
-
|
|
1096
|
-
<WhenIdle
|
|
1097
|
-
fallback={<Skeleton />}
|
|
1098
|
-
className="transition-opacity data-[enter=animate]:starting:opacity-0"
|
|
1099
|
-
>
|
|
1100
|
-
<SecondaryPanel />
|
|
1101
|
-
</WhenIdle>;
|
|
1102
|
-
```
|
|
1103
|
-
|
|
1104
|
-
| Prop | Type | Default | Description |
|
|
1105
|
-
| ---------- | ----------- | ------- | ------------------------------------- |
|
|
1106
|
-
| `timeout` | `number` | — | Max ms to wait before mounting anyway |
|
|
1107
|
-
| `fallback` | `ReactNode` | — | Shown until the browser is idle |
|
|
1108
|
-
|
|
1109
|
-
Idle never fires during SSR, so `WhenIdle` children are absent from server HTML. Reserve it for non-critical content. For content that must be crawlable, use `Defer`. Reduced motion is automatic: `data-enter="animate"` is not stamped when reduced motion is preferred.
|
|
1110
|
-
|
|
1111
|
-
### useIdle
|
|
1112
|
-
|
|
1113
|
-
Returns `false`, then flips to `true` once the browser is idle. Use it when the idle signal belongs in render; use `WhenIdle` for a wrapper or `useWhenIdle` for an effect.
|
|
1114
|
-
|
|
1115
|
-
```tsx
|
|
1116
|
-
import { useIdle } from 'phase/react';
|
|
1117
|
-
|
|
1118
|
-
const idle = useIdle({ timeout: 2000 });
|
|
1119
|
-
return idle ? <SecondaryPanel /> : <Skeleton />;
|
|
1120
|
-
```
|
|
1121
|
-
|
|
1122
|
-
Like `WhenIdle`, idle-gated content is absent from server HTML and should be non-critical.
|
|
1123
|
-
|
|
1124
|
-
### useWhenIdle
|
|
1125
|
-
|
|
1126
|
-
Runs a callback once when the browser is idle after mount (the effect-shaped counterpart to `useIdle`). Use it for side effects (prefetching a chunk, warming a cache) rather than rendering. Cancels on unmount and always calls the latest callback.
|
|
1127
|
-
|
|
1128
|
-
```tsx
|
|
1129
|
-
import { lazy, Suspense, useState } from 'react';
|
|
1130
|
-
import { useWhenIdle } from 'phase/react';
|
|
1131
|
-
|
|
1132
|
-
const openPanel = () => import('./chat-panel');
|
|
1133
|
-
const ChatPanel = lazy(openPanel);
|
|
1134
|
-
|
|
1135
|
-
function Chat() {
|
|
1136
|
-
const [open, setOpen] = useState(false);
|
|
1137
|
-
useWhenIdle(() => void openPanel()); // prefetch the chunk during idle
|
|
1138
|
-
|
|
1139
|
-
return open ? (
|
|
1140
|
-
<Suspense fallback={<Skeleton />}>
|
|
1141
|
-
<ChatPanel />
|
|
1142
|
-
</Suspense>
|
|
1143
|
-
) : (
|
|
1144
|
-
<button onClick={() => setOpen(true)}>Open</button>
|
|
1145
|
-
);
|
|
1146
|
-
}
|
|
1147
|
-
```
|
|
1148
|
-
|
|
1149
|
-
It replaces the common (and leak-prone) hand-rolled `useEffect(() => { const id = requestIdleCallback(...); return () => cancelIdleCallback(id); }, [])`. `useWhenIdle` handles cancellation and the SSR guard. Reach for `useIdle` instead when you need to render from the idle signal.
|
|
1150
|
-
|
|
1151
|
-
### useRenderState
|
|
1152
|
-
|
|
1153
|
-
Reads whether the browser is rendering an element or skipping it under `content-visibility`. Pass it the `ref` from a `Defer` to pause **raw, non-phase** work when the subtree stops painting.
|
|
1154
|
-
|
|
1155
|
-
```tsx
|
|
1156
|
-
import { useRef, useEffect } from 'react';
|
|
1157
|
-
import { Defer, useRenderState } from 'phase/react';
|
|
1158
|
-
|
|
1159
|
-
function Chart() {
|
|
1160
|
-
const ref = useRef<HTMLDivElement>(null);
|
|
1161
|
-
const phase = useRenderState(ref); // 'rendered' | 'skipped'
|
|
1162
|
-
|
|
1163
|
-
useEffect(() => {
|
|
1164
|
-
if (phase === 'skipped') clock.pause();
|
|
1165
|
-
else clock.resume();
|
|
1166
|
-
}, [phase]);
|
|
1167
|
-
|
|
1168
|
-
return (
|
|
1169
|
-
<Defer ref={ref}>
|
|
1170
|
-
<RawCanvasThing />
|
|
1171
|
-
</Defer>
|
|
1172
|
-
);
|
|
1173
|
-
}
|
|
1174
|
-
```
|
|
1175
|
-
|
|
1176
|
-
`useRenderState` only listens and reports. It has no layout effect, so it never breaks `Defer`'s no-layout-shift guarantee. You rarely need it for `phase` loops, which already self-pause off-screen.
|
|
1177
|
-
|
|
1178
|
-
## Guarantees
|
|
1179
|
-
|
|
1180
|
-
These are the performance invariants behind [Why phase](#why-phase). They are tested in CI, not aspirations.
|
|
1181
|
-
|
|
1182
|
-
### Zero per-frame allocations
|
|
1183
|
-
|
|
1184
|
-
`FrameState` is created once and mutated in place every frame. No objects, arrays, closures, template literals, or spread operators in the tick path, and no GC pressure at 60 fps.
|
|
1185
|
-
|
|
1186
|
-
### Strong pause
|
|
1187
|
-
|
|
1188
|
-
When paused, the ticker calls `cancelAnimationFrame` and stops scheduling entirely. Zero callbacks fire, zero CPU consumed. This is not the "weak pause" pattern of scheduling rAF and returning early.
|
|
1189
|
-
|
|
1190
|
-
### Zero forced reflows
|
|
1191
|
-
|
|
1192
|
-
No `getBoundingClientRect()`, `offsetWidth`, `scrollWidth`, or `getComputedStyle()` anywhere in the package. All dimensions come from ResizeObserver (async, compositor-aligned) and all visibility from IntersectionObserver.
|
|
1193
|
-
|
|
1194
|
-
### Zero React re-renders from the frame loop
|
|
1195
|
-
|
|
1196
|
-
The rAF loop never triggers a React re-render. All per-frame state lives in refs; `onTick` writes to refs or the DOM directly. Only `phase` changes trigger re-renders (infrequent lifecycle transitions).
|
|
1197
|
-
|
|
1198
|
-
### Frame-locked shared clock
|
|
1199
|
-
|
|
1200
|
-
All tickers share one `requestAnimationFrame` loop and receive the same browser-supplied timestamp each frame, keeping multiple animations on the same page in visual sync.
|
|
1201
|
-
|
|
1202
|
-
### Delta clamping
|
|
1203
|
-
|
|
1204
|
-
When a loop resumes after a pause, `frame.delta` is clamped to 40 ms. Animations resume from where they left off with no teleporting.
|
|
1205
|
-
|
|
1206
|
-
## Errors
|
|
1207
|
-
|
|
1208
|
-
Every error includes a machine-readable `code` and an actionable message.
|
|
1209
|
-
|
|
1210
|
-
```ts
|
|
1211
|
-
import { PhaseError, isPhaseError } from 'phase';
|
|
1212
|
-
```
|
|
1213
|
-
|
|
1214
|
-
| Code | Trigger |
|
|
1215
|
-
| -------------------- | --------------------------------------------------- |
|
|
1216
|
-
| `server_context` | Calling a browser-only primitive during SSR |
|
|
1217
|
-
| `no_target` | Passing a null or undefined `target` to a primitive |
|
|
1218
|
-
| `conflicting_target` | Passing both `ref` and `target` to a hook |
|
|
1219
|
-
| `invalid_duration` | `useTween` duration is zero, negative, or NaN |
|
|
1220
|
-
| `ticker_stopped` | Calling `start`/`resume` on a stopped ticker |
|
|
1221
|
-
| `missing_context` | `<Swap.State>` used outside `<Swap>` |
|
|
1222
|
-
|
|
1223
|
-
## Relationship to View Transitions
|
|
1224
|
-
|
|
1225
|
-
`phase` doesn't wrap React's View Transition API, and it doesn't need to. The two compose cleanly. Reach for `<ViewTransition>` when you animate between committed UI states like route changes and shared-element morphs, and reach for `Presence`, `Swap`, and the frame loops for component-local lifecycle on stable React. A `phase` loop keeps ticking inside a view-transitioned subtree without conflict.
|
|
1226
|
-
|
|
1227
|
-
## Bundle size
|
|
1228
|
-
|
|
1229
|
-
Minimal footprint is a core promise (see [Why phase](#why-phase)). Every export is individually measured with [Size Limit](https://github.com/ai/size-limit) and budgeted in CI. Sizes reflect minified + brotli-compressed bytes.
|
|
1230
|
-
|
|
1231
|
-
> Regenerate with `pnpm size:readme`.
|
|
1232
|
-
|
|
1233
|
-
<!-- SIZE-TABLE:START -->
|
|
1234
|
-
|
|
1235
|
-
| Export | Size (min+brotli) |
|
|
1236
|
-
| ------------------------- | ----------------: |
|
|
1237
|
-
| **Core** | |
|
|
1238
|
-
| `createTicker` | 1.03 kB |
|
|
1239
|
-
| `createSight` | 1.05 kB |
|
|
1240
|
-
| `createLifecycle` | 1.55 kB |
|
|
1241
|
-
| `createLoop` | 2.92 kB |
|
|
1242
|
-
| `createScrollProgress` | 895 B |
|
|
1243
|
-
| `createRenderState` | 490 B |
|
|
1244
|
-
| `createDevicePixelRatio` | 544 B |
|
|
1245
|
-
| `createMutation` | 1.2 kB |
|
|
1246
|
-
| `createPointer` | 1.3 kB |
|
|
1247
|
-
| `createScroll` | 1.61 kB |
|
|
1248
|
-
| `createThrottle` | 660 B |
|
|
1249
|
-
| `createDebounce` | 559 B |
|
|
1250
|
-
| `whenIdle` | 409 B |
|
|
1251
|
-
| `prefersReducedMotion` | 101 B |
|
|
1252
|
-
| **Ease** | |
|
|
1253
|
-
| `ease (all)` | 210 B |
|
|
1254
|
-
| **React** | |
|
|
1255
|
-
| `useLoop` | 3.22 kB |
|
|
1256
|
-
| `useLifecycle` | 1.83 kB |
|
|
1257
|
-
| `useSight` | 1.36 kB |
|
|
1258
|
-
| `useCanvas` | 3.78 kB |
|
|
1259
|
-
| `useMutation` | 1.38 kB |
|
|
1260
|
-
| `usePointer` | 1.51 kB |
|
|
1261
|
-
| `useScroll` | 1.97 kB |
|
|
1262
|
-
| `useThrottledCallback` | 804 B |
|
|
1263
|
-
| `useDebouncedCallback` | 685 B |
|
|
1264
|
-
| `useTween` | 684 B |
|
|
1265
|
-
| `usePresence` | 591 B |
|
|
1266
|
-
| `useScrollProgress` | 1.03 kB |
|
|
1267
|
-
| `useSize` | 430 B |
|
|
1268
|
-
| `useContainerQuery` | 389 B |
|
|
1269
|
-
| `useMediaQuery` | 246 B |
|
|
1270
|
-
| `usePrefersReducedMotion` | 274 B |
|
|
1271
|
-
| `useDevicePixelRatio` | 230 B |
|
|
1272
|
-
| `useSyncedRef` | 22 B |
|
|
1273
|
-
| `useStableCallback` | 39 B |
|
|
1274
|
-
| `Presence` | 741 B |
|
|
1275
|
-
| `WhenVisible` | 1.61 kB |
|
|
1276
|
-
| `WhenIdle` | 596 B |
|
|
1277
|
-
| `Defer` | 85 B |
|
|
1278
|
-
| `useIdle` | 414 B |
|
|
1279
|
-
| `useWhenIdle` | 444 B |
|
|
1280
|
-
| `useRenderState` | 515 B |
|
|
1281
|
-
| `Swap` | 1.12 kB |
|
|
1282
|
-
|
|
1283
|
-
<!-- SIZE-TABLE:END -->
|
|
1284
|
-
|
|
1285
|
-
## Agent skill
|
|
1286
|
-
|
|
1287
|
-
`phase` ships with an [agent skill](skills/phase) that teaches AI coding agents to implement the API correctly, follow performant-animation best practices, and audit existing code to recommend the cheapest sufficient approach (CSS-only, minimal JS, `phase`, or a heavier library).
|
|
1288
|
-
|
|
1289
|
-
Install it three ways:
|
|
1290
|
-
|
|
1291
|
-
```bash
|
|
1292
|
-
# skills.sh
|
|
1293
|
-
npx skills add vercel-labs/phase --skill phase
|
|
1294
|
-
```
|
|
35
|
+
## Why phase
|
|
1295
36
|
|
|
1296
|
-
|
|
37
|
+
- **Pauses when unseen.** Off-screen or in a background tab, work stops and CPU drops to zero.
|
|
38
|
+
- **Respects reduced motion by default.** Accessibility is built in, not an opt-in.
|
|
39
|
+
- **Never forces a reflow.** No `getBoundingClientRect`, no layout thrash, anywhere in the package.
|
|
40
|
+
- **Zero re-renders from the frame loop.** Per-frame work writes to refs and the DOM, never React state.
|
|
41
|
+
- **Frame-locked shared clock.** Every animation on the page reads one clock, so nothing drifts out of sync.
|
|
42
|
+
- **Renders only what matters.** Skip painting off-screen content, mount non-critical UI when idle.
|
|
1297
43
|
|
|
1298
|
-
|
|
44
|
+
Read the [full documentation](https://github.com/vercel-labs/phase#readme), install the [phase agent skill](https://github.com/vercel-labs/phase/tree/main/skills/phase), review the [changelog](https://github.com/vercel-labs/phase/blob/main/CHANGELOG.md), or see the [MIT license](https://github.com/vercel-labs/phase/blob/main/packages/phase/LICENSE).
|