phase 0.0.6 → 0.0.8

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 CHANGED
@@ -34,6 +34,9 @@ Each guarantee is a [tested invariant](#guarantees), not an aspiration. Every ex
34
34
  - [createSight](#createsight)
35
35
  - [createLifecycle](#createlifecycle)
36
36
  - [createScrollProgress](#createscrollprogress)
37
+ - [createScroll](#createscroll)
38
+ - [createThrottle](#createthrottle)
39
+ - [createDebounce](#createdebounce)
37
40
  - [prefersReducedMotion](#prefersreducedmotion)
38
41
  - [Easing and math](#easing-and-math)
39
42
  - [Choosing a primitive](#choosing-a-primitive)
@@ -44,6 +47,9 @@ Each guarantee is a [tested invariant](#guarantees), not an aspiration. Every ex
44
47
  - [useTween](#usetween)
45
48
  - [usePresence](#usepresence)
46
49
  - [useScrollProgress](#usescrollprogress)
50
+ - [useScroll](#usescroll)
51
+ - [useThrottledCallback](#usethrottledcallback)
52
+ - [useDebouncedCallback](#usedebouncedcallback)
47
53
  - [Utility hooks](#utility-hooks)
48
54
  - [React components](#react-components)
49
55
  - [How animations work](#how-animations-work)
@@ -323,7 +329,7 @@ Pause priority is `reduced-motion` > `sight` > `manual`.
323
329
 
324
330
  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.
325
331
 
326
- > **Not a scroll-scrubbing engine.** This reports `intersectionRatio`, which plateaus for tall elements once they fill the viewport. For continuous scroll-driven animation, use `motion`'s `useScroll` or the native `ScrollTimeline` API.
332
+ > **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) use [`createScroll`](#createscroll); for CSS-declarative scroll-linked animation use the native `ScrollTimeline` API; for spring/gesture scroll use `motion`.
327
333
 
328
334
  ```ts
329
335
  import { createScrollProgress } from 'phase';
@@ -353,6 +359,112 @@ The `steps` option controls threshold granularity. Default `20` generates 21 eve
353
359
  | `root` | `Element \| Document \| null` | — | IO root element |
354
360
  | `rootMargin` | `string` | — | IO root margin |
355
361
 
362
+ ### createScroll
363
+
364
+ 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 resize or 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`.
365
+
366
+ > **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.
367
+
368
+ ```ts
369
+ import { createScroll } from 'phase';
370
+
371
+ const scroll = createScroll({
372
+ element: viewport,
373
+ onScroll: (s) => {
374
+ // thumb CSS needs `transform-origin: left` so scaleX anchors to the track start
375
+ thumb.style.transform = `translateX(${s.progressX * (1 - s.visibleX) * 100}%) scaleX(${s.visibleX})`;
376
+ prevButton.disabled = s.x <= 1;
377
+ nextButton.disabled = s.x >= s.maxX - 1;
378
+ },
379
+ });
380
+
381
+ // scroll.state.progressX === 0.5 (synchronous read)
382
+
383
+ // after mutating scrollable content:
384
+ scroll.measure();
385
+
386
+ // cleanup:
387
+ scroll.stop();
388
+ ```
389
+
390
+ `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`.
391
+
392
+ #### Scroll options
393
+
394
+ | Option | Type | Default | Description |
395
+ | --------------------- | ------------------------------ | --------- | -------------------------------------------------- |
396
+ | `element` | `Element` | required | Scroll container to track |
397
+ | `onScroll` | `(state: ScrollState) => void` | required | Called once per rAF frame with position + progress |
398
+ | `onPhaseChange` | `(phase, reason) => void` | — | Called on phase transitions |
399
+ | `visibility` | `'pause' \| 'ignore'` | `'pause'` | Pause tracking when off-screen, or ignore |
400
+ | `intersectionOptions` | `IntersectionObserverInit` | — | Forwarded to the visibility observer |
401
+ | `signal` | `AbortSignal` | — | Stops the tracker when aborted |
402
+
403
+ The options type is `CreateScrollOptions` (`ScrollOptions` is a `lib.dom` global and must not be shadowed).
404
+
405
+ ### createThrottle
406
+
407
+ 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.
408
+
409
+ > **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".
410
+
411
+ ```ts
412
+ import { createThrottle } from 'phase';
413
+
414
+ const throttle = createThrottle({
415
+ callback: (state) => socket.emit('cursor', state.x, state.y),
416
+ interval: 50,
417
+ });
418
+
419
+ const pointer = createPointer({ element, onPointer: throttle.call });
420
+
421
+ // throttle.flush() fires a pending trailing call now
422
+ // throttle.cancel() discards it and resets the window
423
+ // cleanup:
424
+ throttle.stop();
425
+ ```
426
+
427
+ 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.
428
+
429
+ #### Throttle options
430
+
431
+ | Option | Type | Default | Description |
432
+ | ---------- | ----------------------------------- | --------- | --------------------------------------------- |
433
+ | `callback` | `(value: T) => void` | required | Called with the latest value passed to `call` |
434
+ | `interval` | `number` | required | Minimum ms between invocations |
435
+ | `edge` | `'leading' \| 'trailing' \| 'both'` | `'both'` | Which edges fire |
436
+ | `hidden` | `'flush' \| 'drop'` | `'flush'` | Pending-call policy when the document hides |
437
+ | `signal` | `AbortSignal` | — | Stops the throttle when aborted |
438
+
439
+ ### createDebounce
440
+
441
+ 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.
442
+
443
+ ```ts
444
+ import { createDebounce } from 'phase';
445
+
446
+ const debounce = createDebounce({
447
+ callback: (size) => reallocateBuffers(size),
448
+ wait: 250,
449
+ });
450
+
451
+ debounce.call({ width, height });
452
+
453
+ // cleanup:
454
+ debounce.stop();
455
+ ```
456
+
457
+ Same surface as `createThrottle`: `flush()`, `cancel()`, a synchronous `pending` read, and terminal `stop()`.
458
+
459
+ #### Debounce options
460
+
461
+ | Option | Type | Default | Description |
462
+ | ---------- | -------------------- | --------- | --------------------------------------------- |
463
+ | `callback` | `(value: T) => void` | required | Called with the latest value passed to `call` |
464
+ | `wait` | `number` | required | Quiet period in ms; each call restarts it |
465
+ | `hidden` | `'flush' \| 'drop'` | `'flush'` | Pending-call policy when the document hides |
466
+ | `signal` | `AbortSignal` | — | Stops the debounce when aborted |
467
+
356
468
  ### prefersReducedMotion
357
469
 
358
470
  Returns `true` when reduced motion is enabled at the OS level. Use it to gate expensive setup or dynamic imports.
@@ -423,6 +535,8 @@ Easing, interpolation, and your value range are three separate concerns. `phase`
423
535
  | Pause non-`phase` work inside a `Defer` subtree | `useRenderState` |
424
536
  | Subscribe to scroll, size, or media values reactively | `useScrollProgress` / `useSize` / `useContainerQuery` / `useMediaQuery` |
425
537
  | Scroll/size/visibility without re-renders? | Same hooks with a callback (`onProgress` / `onResize` / `onVisibilityChange`), read via ref |
538
+ | Rate-limit event-driven work (sockets, workers) | `useThrottledCallback` |
539
+ | Run once after a burst settles (resize, typing) | `useDebouncedCallback` |
426
540
 
427
541
  **`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`.
428
542
 
@@ -576,7 +690,7 @@ return (
576
690
 
577
691
  ### useScrollProgress
578
692
 
579
- Element visibility ratio as a 0–1 value. Wraps `createScrollProgress` with React lifecycle management (see its [note on scope](#createscrollprogress) for the distinction between visibility ratio and scroll-scrubbing).
693
+ 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.
580
694
 
581
695
  ```tsx
582
696
  import { useScrollProgress } from 'phase/react';
@@ -593,6 +707,72 @@ function FadeIn({ children }) {
593
707
 
594
708
  Re-renders only at threshold crossings (~20 per full viewport traversal at default steps). `progress` is `0` before first observation.
595
709
 
710
+ ### useScroll
711
+
712
+ 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`.
713
+
714
+ ```tsx
715
+ import { useRef } from 'react';
716
+ import { useScroll } from 'phase/react';
717
+
718
+ function Carousel({ children }) {
719
+ // thumb uses `origin-left` so scaleX anchors to the track start
720
+ const thumbRef = useRef<HTMLDivElement>(null);
721
+ const { ref, measure } = useScroll<HTMLDivElement>({
722
+ onScroll: (s) => {
723
+ thumbRef.current?.style.setProperty(
724
+ 'transform',
725
+ `translateX(${s.progressX * (1 - s.visibleX) * 100}%) scaleX(${s.visibleX})`,
726
+ );
727
+ },
728
+ });
729
+
730
+ return (
731
+ <div ref={ref} className="overflow-x-auto">
732
+ {children}
733
+ </div>
734
+ );
735
+ }
736
+ ```
737
+
738
+ 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.
739
+
740
+ ### useThrottledCallback
741
+
742
+ 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`.
743
+
744
+ ```tsx
745
+ import { usePointer, useThrottledCallback } from 'phase/react';
746
+
747
+ function LiveCursor() {
748
+ const emit = useThrottledCallback(
749
+ (s: PointerState) => socket.emit('cursor', { x: s.x, y: s.y }),
750
+ { interval: 50 },
751
+ );
752
+ const { ref } = usePointer({ onPointer: emit });
753
+ return <div ref={ref} />;
754
+ }
755
+ ```
756
+
757
+ Unmount and option changes discard a pending trailing call. When the final value must land, flush in your own cleanup: `useEffect(() => () => emit.flush(), [emit])`.
758
+
759
+ ### useDebouncedCallback
760
+
761
+ Wraps `createDebounce` with React lifecycle management. Same shape as `useThrottledCallback`, but fires once `wait` ms pass without a new call.
762
+
763
+ ```tsx
764
+ import { useSize, useDebouncedCallback } from 'phase/react';
765
+
766
+ function SimulationCanvas() {
767
+ const realloc = useDebouncedCallback(
768
+ (size: Size) => reallocateBuffers(size),
769
+ { wait: 250 },
770
+ );
771
+ const { ref } = useSize({ onResize: realloc });
772
+ return <canvas ref={ref} />;
773
+ }
774
+ ```
775
+
596
776
  ### Utility hooks
597
777
 
598
778
  | Hook | Purpose |
@@ -898,44 +1078,50 @@ Minimal footprint is a core promise (see [Why phase](#why-phase)). Every export
898
1078
  | Export | Size (min+brotli) |
899
1079
  | ------------------------- | ----------------: |
900
1080
  | **Core** | |
901
- | `createTicker` | 837 B |
1081
+ | `createTicker` | 834 B |
902
1082
  | `createSight` | 963 B |
903
1083
  | `createLifecycle` | 1.47 kB |
904
1084
  | `createLoop` | 2.59 kB |
905
- | `createScrollProgress` | 857 B |
1085
+ | `createScrollProgress` | 878 B |
906
1086
  | `createRenderState` | 495 B |
907
1087
  | `createDevicePixelRatio` | 544 B |
908
1088
  | `createMutation` | 1.17 kB |
909
1089
  | `createPointer` | 1.26 kB |
1090
+ | `createScroll` | 1.45 kB |
1091
+ | `createThrottle` | 657 B |
1092
+ | `createDebounce` | 559 B |
910
1093
  | `whenIdle` | 409 B |
911
1094
  | `prefersReducedMotion` | 101 B |
912
1095
  | **Ease** | |
913
1096
  | `ease (all)` | 210 B |
914
1097
  | **React** | |
915
- | `useLoop` | 2.81 kB |
916
- | `useLifecycle` | 1.69 kB |
917
- | `useSight` | 1.18 kB |
1098
+ | `useLoop` | 2.82 kB |
1099
+ | `useLifecycle` | 1.68 kB |
1100
+ | `useSight` | 1.19 kB |
918
1101
  | `useCanvas` | 3.44 kB |
919
1102
  | `useMutation` | 1.36 kB |
920
1103
  | `usePointer` | 1.48 kB |
921
- | `useTween` | 617 B |
922
- | `usePresence` | 593 B |
1104
+ | `useScroll` | 1.72 kB |
1105
+ | `useThrottledCallback` | 797 B |
1106
+ | `useDebouncedCallback` | 688 B |
1107
+ | `useTween` | 619 B |
1108
+ | `usePresence` | 591 B |
923
1109
  | `useScrollProgress` | 993 B |
924
- | `useSize` | 384 B |
925
- | `useContainerQuery` | 356 B |
1110
+ | `useSize` | 378 B |
1111
+ | `useContainerQuery` | 384 B |
926
1112
  | `useMediaQuery` | 246 B |
927
1113
  | `usePrefersReducedMotion` | 272 B |
928
1114
  | `useDevicePixelRatio` | 231 B |
929
1115
  | `useSyncedRef` | 22 B |
930
1116
  | `useStableCallback` | 39 B |
931
- | `Presence` | 742 B |
1117
+ | `Presence` | 741 B |
932
1118
  | `WhenVisible` | 1.44 kB |
933
1119
  | `WhenIdle` | 593 B |
934
- | `Defer` | 85 B |
1120
+ | `Defer` | 86 B |
935
1121
  | `useIdle` | 435 B |
936
- | `useWhenIdle` | 449 B |
1122
+ | `useWhenIdle` | 445 B |
937
1123
  | `useRenderState` | 527 B |
938
- | `Swap` | 1.12 kB |
1124
+ | `Swap` | 1.13 kB |
939
1125
 
940
1126
  <!-- SIZE-TABLE:END -->
941
1127