Skip to content

Repository files navigation

phase

▲ phase

Status: Alpha. APIs are evolving rapidly. Expect breaking changes.

Phase is a browser runtime performance toolkit for detecting and controlling avoidable browser work in animation, rendering, and loading.

Start with the scan tool:

npx phase scan --diff origin/main    # gate a PR on new findings
npx phase scan src components        # scan files or directories
npx phase explain setstate-in-raf    # explain a finding and its fix

The scanner is deterministic; its findings are candidates that need review, not confirmed defects. --fail-on turns severity tiers into a CI gate, and a committed baseline keeps pre-existing findings from failing new PRs.

The toolkit

Part What it does Requires the libraries?
Agent skill Audits browser runtime performance, checks each candidate in context, and recommends the cheapest safe fix No
phase CLI + GitHub Action Runs the same deterministic scanner in terminals and CI No
Runtime libraries (@usephase/*) Lifecycle-aware primitives for when code needs to run, pause, render, or wait Yes

One scanner powers all three distributions: the skill, the CLI, and the Action. The libraries are one possible recommendation from an audit, not a prerequisite for one: CSS, a browser API, a framework feature, or no change may be the correct result.

Migrating from phase <0.6.0

phase versions below 0.6.0 on npm were the runtime library. Starting at 0.6.0, phase is the scan tool and the library ships as scoped packages. Update imports and dependencies:

Before After
phase @usephase/core
phase/react @usephase/react
phase/ease @usephase/core/ease

Replace the phase entry in package.json with @usephase/core (and @usephase/react if you use the hooks or components). Versions of phase pinned below 0.6.0 keep working; caret ranges on 0.x never auto-upgrade across the flip.

Why the runtime libraries

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:

  • Pauses when unseen. Off-screen or in a background tab, work stops and CPU drops to zero.
  • Respects reduced motion by default. Accessibility is built in, not an opt-in.
  • Batches layout reads. Element-relative pointer tracking reads one rect per dirty frame; scroll geometry is read on attachment or explicit measurement and coalesced after resize signals; other dimensions and visibility come from observers.
  • Zero re-renders from the frame loop. Per-frame work writes to refs and the DOM, never React state.
  • Frame-locked shared clock. Tickers using the same clock protocol read one timestamp, so they do not drift out of sync.
  • Renders only what matters. Skip painting off-screen content, mount non-critical UI when idle.

Each guarantee is a tested invariant, not an aspiration. Every export stays sub-kilobyte to a few kilobytes.

Table of contents

Install

pnpm add @usephase/core @usephase/react

Getting started

import { useLoop } from '@usephase/react';

function Orbit({ radius }) {
  const speed = 1; // radians per second
  const { ref } = useLoop({
    onTick: (frame) => {
      const angle = (frame.elapsed / 1000) * speed;
      ref.current.style.transform = `translate(${Math.cos(angle) * radius}px, ${Math.sin(angle) * radius}px)`;
    },
  });

  return <div ref={ref} className="dot" />;
}

Four lines of animation code. Behind them, performance-critical plumbing:

  • Pauses when invisible. Scrolled off-screen or background tab? Zero CPU consumed.
  • Respects reduced motion. Accessibility is the default, not an opt-in.
  • Resumes without teleporting. Elapsed time freezes during pause, picks up where it left off.
  • Clean teardown. Unmount the component and walk away. Nothing leaks.

Philosophy

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.

const { phase, phaseReason } = useLoop({ onTick: draw });

// phase: 'paused'  phaseReason: 'sight'           → off-screen
// phase: 'paused'  phaseReason: 'reduced-motion'  → user disabled motion
// phase: 'running' phaseReason: 'resumed'         → came back into view

One string replaces if (running && visible && !paused && !prefersReducedMotion && mounted).

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.

Safe behavior is automatic. Visibility awareness, reduced motion, observer cleanup, and limits on animation-time jumps after delayed frames are defaults, not opt-ins. Bypassing reduced motion requires an explicit reducedMotion: 'ignore' in the diff.

Scope

phase composes signals (visibility, focus, reduced motion, frame budget) into a coherent lifecycle with a reason for every state transition.

Handles: lifecycle state, timing, visibility, scroll visibility-ratio, reduced motion, observer pooling, quality signals, frame loops.

Does not handle: spring physics, gesture systems, declarative keyframe orchestration. Reach for a dedicated library (e.g. motion) when you need those.

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.

Admission criteria

Every export must pass all four:

  1. Wraps a browser API that is easy to misuse and causes measurable perf regressions without careful handling.
  2. Manages a lifecycle (browser: visibility-pausing, reduced-motion, observer pooling; render: preventing re-renders, stable identities; CSS: containment state).
  3. Makes the safe path shorter than the raw path. The primitive is less code and less error-prone than the browser API directly.
  4. Stays individually lean. Every export is measured and budgeted in .size-limit.json. CI rejects regressions. Every byte must justify itself.

If a gap fails any criterion, phase closes it in the skill (audit rules, recipes, scanner signals) rather than shipping code.

Export taxonomy

Category What it covers Examples
Timing Frame clocks and animation loops createLoop, useLoop, useCanvas, useTween
Observation Reactive wrappers around browser observers useSight, useSize, useScrollProgress, useMediaQuery
Lifecycle Activation signals composed from IO+MQL+rIC useLifecycle, useIdle, useWhenIdle
Composition Mount/unmount orchestration with transitions Presence, Swap, WhenVisible, WhenIdle, Defer
Math Pure easing and interpolation functions lerp, clamp, easeOutCubic
Utility React ref/callback patterns for phase users useSyncedRef, useStableCallback

Entry points

Import Contents
@usephase/core Framework-agnostic timing, observation, lifecycle, scheduling, and errors
@usephase/core/ease Easing functions and math utilities only
@usephase/react React hooks and components

Each entry point is independently tree-shakeable. Importing @usephase/core/ease in a server component pulls zero browser APIs.

Core API

createLoop

The main primitive. Composes a ticker, visibility observer, and reduced-motion listener into a lifecycle-aware animation loop.

import { createLoop } from '@usephase/core';

const loop = createLoop({
  target: el,
  onTick: (frame) => {
    // frame.time    — browser requestAnimationFrame timestamp
    // frame.delta   — milliseconds to advance this frame
    // frame.elapsed — sum of all delivered deltas
    // frame.frame   — delivered frame count
  },
});

loop.start();
// loop.phase       === 'running'
// loop.phaseReason === 'started'

Loop phases

Phase Meaning Possible reasons
idle Created but not started initial
running Actively ticking started, resumed
paused Temporarily stopped, will resume sight, reduced-motion, degraded
stopped Permanently disposed manual, disposed

Quality signals

phase and quality are orthogonal. A loop can be running + degraded (still animating, but at reduced fidelity to preserve resources).

Quality Meaning What changes
full Normal operation Configured FPS, full DPR
degraded Resources constrained FPS capped to 30, DPR drops to 1x

Two signals trigger degradation:

Trigger qualityReason When Recovery
Window blur 'unfocused' User switches to another window Recovers on window focus
Frame budget 'frame-budget' 3+ consecutive frames exceed the 16.6ms budget Does not auto-recover

Read loop.quality and loop.qualityReason to adapt rendering (fewer particles, lower-fidelity shaders, skip non-essential visual passes).

The degraded option

Controls the loop's response when quality degrades. Same three-value pattern as reducedMotion.

Value Behavior Use case
'throttle' Cap FPS (default 30, configurable via degradedFps) Most animations. Still runs, only slower
'pause' Pause the loop entirely Heavy canvas/WebGL. If it can't run well, don't run
'ignore' Keep running at full quality Critical UI that must never degrade
createLoop({
  target: el,
  onTick: draw,
  degraded: 'throttle', // default
  degradedFps: 20, // only accepted when degraded is 'throttle'
});

Loop options

Option Type Default Description
target Element | Document required Element to observe for visibility, or document for the page
onTick (frame: FrameState) => void required Called each frame while running
fps number Cap frames per second
reducedMotion 'pause' | 'complete' | 'ignore' 'pause' Behavior when user prefers reduced motion
degraded 'throttle' | 'pause' | 'ignore' 'throttle' Behavior when quality degrades
degradedFps number 30 FPS cap in degraded throttle mode
onPhaseChange (phase, reason) => void Called on every phase transition

createTicker

The low-level requestAnimationFrame clock underneath createLoop. Use it when you need a frame loop without visibility management (background processing, audio sync, non-visual timing).

import { createTicker } from '@usephase/core';

const ticker = createTicker({
  onTick: (frame) => {
    /* runs every frame */
  },
  fps: 30,
});
ticker.start();

Ticker instances within one JavaScript global, such as a page or worker, share one browser requestAnimationFrame loop and timestamp when they use the same clock protocol. This includes separately bundled copies of the same compatible phase release.

Within one clock protocol, pointer, scroll, mutation, and throttle callbacks queued before frame dispatch begins flush before every ticker callback in that frame. A callback first queued during input or tick dispatch runs in the next frame. Additional work coalesces into an eligible callback that has not run yet; once it has run, new work waits for the next frame. An input callback error does not prevent other input or ticker callbacks from running; the first error is rethrown after both stages complete. A ticker callback error retains precedence and aborts the remaining ticker callbacks.

frame.delta is how many milliseconds an animation should advance on each callback. After a delayed callback, it is at most 40ms without an FPS limit, or one configured FPS interval plus 40ms with a limit. frame.elapsed increases by exactly the same delta.

The first callback after start() or resume() uses 16.67ms without an FPS limit, or one configured interval with a limit. frame.time always reports the browser's unmodified requestAnimationFrame timestamp so non-phase animation code can use the same source time.

Ticker phases

Phase Meaning Transitions
idle Created, not started running via start()
running Actively ticking paused via pause()
paused Suspended, resumable running via resume()
stopped Terminal, cannot restart via stop() from any state

createSight

Answers one question: is this element visible right now? Combines document.visibilitychange, pageshow (bfcache restore), and IntersectionObserver into a single phase.

import { createSight } from '@usephase/core';

const sight = createSight({
  target: el,
  onPhaseChange: (phase, reason) => {
    // phase:  'visible' | 'hidden' | 'unknown'
    // reason: 'initial' | 'viewport' | 'document' | 'bfcache' | 'all-hidden'
  },
});

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).

createLifecycle

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.

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 instead.

import { createLifecycle } from '@usephase/core';

const lifecycle = createLifecycle({
  target: canvas,
  onPhaseChange: (phase, reason) => {
    // phase:  'idle' | 'active' | 'paused' | 'stopped'
    // reason: 'started' | 'resumed' | 'sight' | 'reduced-motion' | 'manual' | 'disposed' | 'initial'
    if (phase === 'active') renderer.start();
    else renderer.stop(); // your loop, your teardown
  },
});

// Manual pause (e.g. a panel opened over the hero):
lifecycle.pause();
lifecycle.resume();

// cleanup:
lifecycle.stop();

createLoop adds timing and quality controls to createLifecycle. Frame scheduling, FrameState reuse, FPS limits, and bounded time advances apply only when phase runs the loop. Observer pooling, visibility handling, and reduced-motion handling also apply to consumer-owned loops.

Lifecycle phases

Phase Meaning Possible reasons
idle Created but not started initial
active Should be animating started, resumed
paused Off-screen, reduced motion, etc. sight, reduced-motion, manual
stopped Permanently disposed disposed

Pause priority is reduced-motion > sight > manual.

createScrollProgress

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.

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; for CSS-declarative scroll-linked animation use the native ScrollTimeline API; for spring/gesture scroll use motion.

import { createScrollProgress } from '@usephase/core';

const progress = createScrollProgress({
  target: el,
  onProgress: (ratio) => {
    el.style.opacity = String(ratio);
  },
});

// progress.ratio === 0.65 (synchronous read)

// cleanup:
progress.stop();

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.

ScrollProgress options

Option Type Default Description
target Element | Document required Element to observe, or document for the page
onProgress (ratio: number) => void required Called at each threshold crossing
steps number 20 Number of evenly-spaced thresholds
root Element | Document | null IO root element
rootMargin string IO root margin

createScroll

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.

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; for CSS-declarative scroll-linked animation, use the native ScrollTimeline API.

import { createScroll } from '@usephase/core';

const scroll = createScroll({
  target: viewport,
  onScroll: (s) => {
    // thumb CSS needs `transform-origin: left` so scaleX anchors to the track start
    thumb.style.transform = `translateX(${s.progressX * (1 - s.visibleX) * 100}%) scaleX(${s.visibleX})`;
    prevButton.disabled = s.x <= 1;
    nextButton.disabled = s.x >= s.maxX - 1;
  },
});

// scroll.state.progressX === 0.5 (synchronous read)

// after mutating scrollable content:
scroll.measure();

// cleanup:
scroll.stop();

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.

Scroll options

Option Type Default Description
target Element | Document required Scroll container, or document for the page
onScroll (state: ScrollState) => void required Called once per rAF frame with position + progress
onPhaseChange (phase, reason) => void Called on phase transitions
visibility 'pause' | 'ignore' 'pause' Pause tracking when off-screen, or ignore
intersectionOptions IntersectionObserverInit Forwarded to the visibility observer
signal AbortSignal Stops the tracker when aborted

The options type is CreateScrollOptions (ScrollOptions is a lib.dom global and must not be shadowed).

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.

createThrottle

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.

Event-driven, not a loop. This fires on the trigger and idles otherwise. To cap a continuous render loop, use fps on createLoop. To think in rates, interval: 1000 / 20 reads as "at most 20 per second".

import { createThrottle } from '@usephase/core';

const throttle = createThrottle({
  callback: (state) => socket.emit('cursor', state.x, state.y),
  interval: 50,
});

const pointer = createPointer({ element, onPointer: throttle.call });

// throttle.flush()  fires a pending trailing call now
// throttle.cancel() discards it and resets the window
// cleanup:
throttle.stop();

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.

Throttle options

Option Type Default Description
callback (value: T) => void required Called with the latest value passed to call
interval number required Minimum ms between invocations
edge 'leading' | 'trailing' | 'both' 'both' Which edges fire
hidden 'flush' | 'drop' 'flush' Pending-call policy when the document hides
signal AbortSignal Stops the throttle when aborted

createDebounce

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.

import { createDebounce } from '@usephase/core';

const debounce = createDebounce({
  callback: (size) => reallocateBuffers(size),
  wait: 250,
});

debounce.call({ width, height });

// cleanup:
debounce.stop();

Same surface as createThrottle: flush(), cancel(), a synchronous pending read, and terminal stop().

Debounce options

Option Type Default Description
callback (value: T) => void required Called with the latest value passed to call
wait number required Quiet period in ms; each call restarts it
hidden 'flush' | 'drop' 'flush' Pending-call policy when the document hides
signal AbortSignal Stops the debounce when aborted

createRenderState

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.

import { createRenderState } from '@usephase/core';

const renderState = createRenderState({
  target: el,
  onPhaseChange: (phase) => {
    if (phase === 'skipped') clock.pause();
    else clock.resume();
  },
});

renderState.stop();

It listens to contentvisibilityautostatechange, the browser's actual paint decision, without changing layout.

createDevicePixelRatio

Tracks devicePixelRatio changes through a shared media-query subscription. Use it for framework-free canvas, WebGL, or worker renderers that own their buffer sizing.

import { createDevicePixelRatio } from '@usephase/core';

const dpr = createDevicePixelRatio({
  onChange: (value) => renderer.setPixelRatio(Math.min(value, 2)),
});

// dpr.dpr is always current
dpr.stop();

useCanvas handles DPR automatically; use this primitive only when you own the renderer.

createMutation

A lifecycle-aware MutationObserver: records are coalesced into one callback per animation frame, observation pauses off-screen by default, and teardown is explicit.

import { createMutation } from '@usephase/core';

const mutation = createMutation({
  target: list,
  mutation: { childList: true },
  onMutations: (records) => syncItems(records),
});

mutation.stop();

Reserve it for structural or narrow attribute changes. For dimensions, use ResizeObserver-backed useSize; reading layout inside onMutations still forces a reflow.

createPointer

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.

import { createPointer } from '@usephase/core';

const pointer = createPointer({
  target: surface,
  onPointer: (state) => {
    cursor.style.transform = `translate(${state.x}px, ${state.y}px)`;
  },
});

// pointer.state is always current
pointer.stop();

Use CSS :hover for hover state and a gesture library for drag physics. This primitive is for continuous element-relative coordinates.

whenIdle

Runs one callback when the browser is idle. Where requestIdleCallback is unavailable (Safari), it falls back to a near-immediate task instead of waiting for an idle period. The returned function cancels pending work.

import { whenIdle } from '@usephase/core';

const cancel = whenIdle(() => warmCache(), { timeout: 2000 });
cancel();

In React, use useWhenIdle for effects, useIdle for a boolean, or WhenIdle to mount a subtree.

prefersReducedMotion

Returns true when reduced motion is enabled at the OS level. Use it to gate expensive setup or dynamic imports.

import { prefersReducedMotion } from '@usephase/core';

if (!prefersReducedMotion()) {
  const { startParticleSystem } = await import('./particles');
  startParticleSystem(canvas);
}

All hooks and primitives consult this signal automatically. You only need it directly for conditional imports or setup logic.

Easing and math

Pure functions with no browser APIs, side effects, or React. Safe in server components, build scripts, and tests.

import { lerp, clamp01, easeOutCubic, remap } from '@usephase/core/ease';

Easing functions

Function Character
easeOutCubic Fast start, smooth deceleration
easeOutQuart Sharper deceleration
easeOutBack Overshoots target, snaps back
easeInOutCubic Symmetric S-curve
linear No easing (identity)

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.

Math utilities

Function Description Example
clamp(value, min, max) Constrain to range clamp(150, 0, 100)100
clamp01(value) Constrain to 0–1 clamp01(-0.5)0
lerp(start, end, t) Linear interpolation lerp(0, 100, 0.5)50
inverseLerp(start, end, value) Where is value in range? (0–1) inverseLerp(0, 100, 75)0.75
remap(options) Map from one range to another Input range → output range

The pattern

const progress = clamp01(elapsed / duration); // normalize time to 0–1
const eased = easeOutCubic(progress); // reshape the curve
const value = lerp(startPos, endPos, eased); // map to your range

Easing, interpolation, and your value range are three separate concerns. phase keeps them separate so you can mix and match.

Choosing a primitive

Need Use
Check on-screen visibility useSight (visibility only)
Run a frame loop via phase useLoop (DOM) / useCanvas (canvas)
Pause/resume your own loop (WebGL, three.js, Web Worker) useLifecycle (active/paused signal)
Animate a single value in render output useTween
Animate mount/unmount transitions Presence / Swap / WhenVisible
Skip painting off-screen content (keep in DOM) Defer
Defer non-critical UI until the browser is idle WhenIdle / useIdle
Run a side effect or prefetch when idle useWhenIdle
Pause non-phase work inside a Defer subtree useRenderState
React to DOM mutations without synchronous callback storms useMutation
Track element-relative pointer position without per-event layout reads usePointer
Track DPR for a renderer you own useDevicePixelRatio
Check reduced motion for non-phase work usePrefersReducedMotion
Subscribe to scroll, size, or media values reactively useScrollProgress / useSize / useContainerQuery / useMediaQuery
Scroll/size/visibility without re-renders? Same hooks with a callback (onProgress / onResize / onVisibilityChange), read via ref
Rate-limit event-driven work (sockets, workers) useThrottledCallback
Run once after a burst settles (resize, typing) useDebouncedCallback

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.

React hooks

useLoop

The primary React hook. Wraps createLoop with React lifecycle management.

import { useLoop } from '@usephase/react';

const { ref, phase, phaseReason } = useLoop({
  onTick: (frame) => {
    ref.current.style.transform = `translateX(${frame.elapsed * 0.1}px)`;
  },
});
return <div ref={ref} />;

Attach the returned ref to the element you want to animate. To bring your own, pass ref in the options.

Your onTick callback always sees the latest props, state, and refs without restarting the loop (stored internally via useSyncedRef).

Never write state that changes on every frame inside onTick. React may re-render on every tick. Write repeated values to refs or the DOM. A one-time state update is allowed only if the callback first blocks repeats and then sets enabled to false.

useLifecycle

The activation signal for a loop you own. Wraps 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.

import { useLifecycle } from '@usephase/react';

function Hero() {
  const { ref, isActive } = useLifecycle();

  useEffect(() => {
    if (!isActive) return; // off-screen / reduced motion / paused
    let raf = requestAnimationFrame(function render() {
      renderer.render();
      raf = requestAnimationFrame(render);
    });
    return () => cancelAnimationFrame(raf);
  }, [isActive]);

  return <canvas ref={ref} />;
}
Option Type Default Description
ref RefObject returned Bring your own, or attach the returned ref
reducedMotion 'pause' | 'ignore' 'pause' Whether reduced motion pauses the lifecycle
paused boolean false Manual pause (e.g. a panel opened over the animation)
enabled boolean true When false, tears down and reports idle
intersectionOptions IntersectionObserverInit Forwarded to the underlying observer

Returns { ref, phase, phaseReason, isActive }. See Choosing a primitive for useSight vs useLifecycle.

useCanvas

Everything useLoop provides, plus DPR-aware buffer sizing, ResizeObserver coalescing, and GPU context loss recovery.

import { useRef } from 'react';
import { useCanvas } from '@usephase/react';

const containerRef = useRef(null);
const canvasRef = useRef(null);

const { phase } = useCanvas({
  containerRef,
  canvasRef,
  draw: (ctx, frame, size) => {
    ctx.clearRect(0, 0, size.width, size.height);
    // ctx is already scaled for devicePixelRatio — draw in CSS pixels
  },
});

return (
  <div ref={containerRef}>
    <canvas ref={canvasRef} />
  </div>
);

useCanvas coordinates two elements (a sizing container and the canvas), so you pass both refs in.

Concern How useCanvas handles it
DPR (retina) Uses devicePixelContentBoxSize for exact physical pixels when available, falls back to width * dpr. Listens for DPR changes.
Resize Shared ResizeObserver. Canvas resized on container change. No getBoundingClientRect.
Context loss Listens for contextlost/contextrestored. Pauses on loss, recovers on restore.
Quality When degraded, DPR drops to 1x automatically (halves GPU pixel count).

Both hooks accept the same quality controls as createLoop: degraded and degradedFps. For heavy GPU work, consider degraded: 'pause'.

useTween

Animates a number from A to B over a duration. Calls setState per frame (appropriate when the animated value is used in render output).

import { useTween } from '@usephase/react';

const opacity = useTween({ to: isVisible ? 1 : 0, duration: 300 });

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.

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.

usePresence

The hook behind <Presence>. Use directly when you need full control over mount/unmount lifecycle.

import { usePresence } from '@usephase/react';

const { phase, ref, mounted, enter } = usePresence({ show: isOpen });
if (!mounted) return null;
return (
  <div
    ref={ref}
    data-phase={phase}
    data-enter={enter === 'animate' ? 'animate' : undefined}
    className="transition-opacity data-[enter=animate]:starting:opacity-0 data-[phase=exiting]:opacity-0"
  />
);

Presence phases

idleenteredexitingexited

Phase Meaning mounted
idle Not shown (initial or after reveal exit) false
entered Visible and active true
exiting Exit animation in progress true
exited Exit complete, ready for unmount false

Options

Option Type Default Description
show boolean required Visibility toggle
mode 'mount' | 'reveal' 'mount' Unmount after exit or stay in DOM
enter 'animate' | 'instant' 'animate' First-mount behavior
exitDuration number 5000 Safety timeout for exit (ms)
reducedMotion 'respect' | 'ignore' 'respect' Reduced motion preference handling

useScrollProgress

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 instead. See the note on scope for the full distinction.

import { useScrollProgress } from '@usephase/react';

function FadeIn({ children }) {
  const { ref, progress } = useScrollProgress();
  return (
    <div ref={ref} style={{ opacity: progress }}>
      {children}
    </div>
  );
}

Re-renders only at threshold crossings (~20 per full viewport traversal at default steps). progress is 0 before first observation.

useScroll

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.

import { useRef } from 'react';
import { useScroll } from '@usephase/react';

function Carousel({ children }) {
  // thumb uses `origin-left` so scaleX anchors to the track start
  const thumbRef = useRef<HTMLDivElement>(null);
  const { ref, measure } = useScroll<HTMLDivElement>({
    onScroll: (s) => {
      thumbRef.current?.style.setProperty(
        'transform',
        `translateX(${s.progressX * (1 - s.visibleX) * 100}%) scaleX(${s.visibleX})`,
      );
    },
  });

  return (
    <div ref={ref} className="overflow-x-auto">
      {children}
    </div>
  );
}

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.

useThrottledCallback

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.

import { usePointer, useThrottledCallback } from '@usephase/react';

function LiveCursor() {
  const emit = useThrottledCallback(
    (s: PointerState) => socket.emit('cursor', { x: s.x, y: s.y }),
    { interval: 50 },
  );
  const { ref } = usePointer({ onPointer: emit });
  return <div ref={ref} />;
}

Unmount and option changes discard a pending trailing call. When the final value must land, flush in your own cleanup: useEffect(() => () => emit.flush(), [emit]).

useDebouncedCallback

Wraps createDebounce with React lifecycle management. Same shape as useThrottledCallback, but fires once wait ms pass without a new call.

import { useSize, useDebouncedCallback } from '@usephase/react';

function SimulationCanvas() {
  const realloc = useDebouncedCallback(
    (size: Size) => reallocateBuffers(size),
    { wait: 250 },
  );
  const { ref } = useSize({ onResize: realloc });
  return <canvas ref={ref} />;
}

useMutation

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.

import { useMutation } from '@usephase/react';

const { ref, phase } = useMutation({
  mutation: { childList: true },
  onMutations: (records) => syncItems(records),
});

return <ul ref={ref} data-observer-phase={phase} />;

Observation pauses off-screen by default. Set visibility: 'ignore' only for document-level coordination that must continue in the background.

usePointer

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.

import { usePointer } from '@usephase/react';

const { ref } = usePointer({
  onPointer: ({ x, y, active }) => {
    cursorRef.current?.style.setProperty(
      'transform',
      `translate(${x}px, ${y}px)`,
    );
    cursorRef.current?.toggleAttribute('data-active', active);
  },
});

return <div ref={ref}>{children}</div>;

Use it for custom cursors, canvas interaction, and tooltips—not simple hover or drag gestures.

Observation and utility hooks

Hook Purpose
useSight Element visibility as a phase. Pass onVisibilityChange for zero-re-render mode
useSize Element dimensions via shared ResizeObserver. Pass onResize for render-free updates
useContainerQuery Breakpoint matching against element width
useScrollProgress Element visibility ratio (0–1). Pass onProgress for zero-re-render mode
useMediaQuery CSS media query subscription (shared MQL pool)
usePrefersReducedMotion Reactive reduced-motion preference for non-phase animation
useDevicePixelRatio Reactive DPR for renderers outside useCanvas
useSyncedRef Ref always in sync with latest value
useStableCallback Stable-identity function that calls latest closure

useSight, useSize, and useScrollProgress each support a transient mode: pass a callback (onVisibilityChange, onResize, onProgress) and observer updates do not re-render. 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. useSize may render once when its ref attaches to a different element so it can move the subscription.

React components

How animations work

One CSS pattern covers enter and exit across Presence, WhenVisible, and Swap:

className =
  'transition-opacity data-[enter=animate]:starting:opacity-0 data-[phase=exiting]:opacity-0';

No motion-reduce: class needed because reduced motion is handled automatically.

Enter: CSS @starting-style animates the element natively when data-enter="animate" is present. Zero JS during the animation.

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.

Reduced motion: phase suppresses data-enter="animate" and skips the exit animation (instant unmount). No consumer effort.

Presence

Renders a div that manages its own mount/unmount lifecycle, stamping data-phase for exit and data-enter="animate" for enter.

import { Presence } from '@usephase/react';

<Presence
  show={isOpen}
  className="transition-opacity data-[enter=animate]:starting:opacity-0 data-[phase=exiting]:opacity-0"
>
  Modal content
</Presence>;
Prop Type Default Description
show boolean required Visibility toggle
mode 'mount' | 'reveal' 'mount' Unmount after exit or stay in DOM
enter 'animate' | 'instant' 'animate' First-mount animation behavior
exitDuration number 5000 Safety timeout for exit (ms)
reducedMotion 'respect' | 'ignore' 'respect' Reduced motion handling

Two modes:

Mode Behavior Use case
'mount' Added to DOM on show, removed after exit completes Modals, toasts, menus
'reveal' Always in DOM, visibility toggled via phase Scroll reveals, SEO content, IO re-entry

WhenVisible

Mounts children when the element enters the viewport. One-shot (once triggered, stays mounted). Uses the pooled IntersectionObserver via useSight.

import { WhenVisible } from '@usephase/react';

<WhenVisible
  rootMargin="200px"
  className="transition-opacity data-[enter=animate]:starting:opacity-0"
>
  <HeavyInteractiveChart />
</WhenVisible>;

Common pattern for viewport-gated lazy loading:

const HeavyChart = lazy(() => import('./heavy-chart'));

<WhenVisible
  rootMargin="200px"
  className="transition-opacity data-[enter=animate]:starting:opacity-0"
>
  <Suspense fallback={<Skeleton />}>
    <HeavyChart />
  </Suspense>
</WhenVisible>;
Prop Type Default Description
rootMargin string '200px' IO rootMargin (preload headroom)
threshold number | number[] IO threshold
root Element | null IO root element
fallback ReactNode Shown while awaiting intersection

Reduced motion is automatic: data-enter="animate" is not stamped when reduced motion is preferred.

Swap

Coordinated exit-then-enter transitions. The old state fully exits before the new state enters (no overlap, no z-index issues).

import { Swap } from '@usephase/react';

<Swap active={success ? 'success' : 'form'}>
  <Swap.State
    id="form"
    className="transition-all data-[phase=exiting]:opacity-0"
  >
    <Form />
  </Swap.State>
  <Swap.State
    id="success"
    className="transition-all data-[enter=animate]:starting:opacity-0 data-[phase=exiting]:opacity-0"
  >
    <SuccessMessage />
  </Swap.State>
</Swap>;

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.

Rendering

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:

Helper Defers In DOM? In SSR HTML? Reach for it when
Defer browser render (style/layout/paint) yes yes content must stay crawlable but need not paint yet
WhenIdle React mount until idle no no non-critical UI that shouldn't block first paint
WhenVisible React mount until near viewport no no viewport-gated lazy loading / reveals

Defer

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.

import { Defer } from '@usephase/react';

<Defer estimatedHeight="600px" className="my-section">
  <ArticleSection />
</Defer>;

// Use `as` for semantic elements (no wrapper div needed)
<ul>
  {items.map((item) => (
    <Defer as="li" key={item.id} estimatedHeight="80px">
      <ItemContent item={item} />
    </Defer>
  ))}
</ul>;
Prop Type Default Description
as ElementType 'div' HTML element to render ('li', 'tr', 'section', etc.)
estimatedHeight string '1000px' Reserved size before first paint (any CSS length)
...rest Omit<HTMLAttributes<HTMLElement>, 'style'> Standard HTML attributes except style (use className)

contain-intrinsic-size: auto <estimatedHeight> uses the estimate as the subtree's layout placeholder while content is skipped, and the browser remembers the real size after first paint. An inaccurate estimate can change document size and scroll position when the content first renders, so keep it close to the final height. 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.

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.

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.

WhenIdle

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).

import { WhenIdle } from '@usephase/react';

<WhenIdle
  fallback={<Skeleton />}
  className="transition-opacity data-[enter=animate]:starting:opacity-0"
>
  <SecondaryPanel />
</WhenIdle>;
Prop Type Default Description
timeout number Max ms to wait before mounting anyway
fallback ReactNode Shown until the browser is idle

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.

useIdle

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.

import { useIdle } from '@usephase/react';

const idle = useIdle({ timeout: 2000 });
return idle ? <SecondaryPanel /> : <Skeleton />;

Like WhenIdle, idle-gated content is absent from server HTML and should be non-critical.

useWhenIdle

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.

import { lazy, Suspense, useState } from 'react';
import { useWhenIdle } from '@usephase/react';

const openPanel = () => import('./chat-panel');
const ChatPanel = lazy(openPanel);

function Chat() {
  const [open, setOpen] = useState(false);
  useWhenIdle(() => void openPanel()); // prefetch the chunk during idle

  return open ? (
    <Suspense fallback={<Skeleton />}>
      <ChatPanel />
    </Suspense>
  ) : (
    <button onClick={() => setOpen(true)}>Open</button>
  );
}

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.

useRenderState

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.

import { useRef, useEffect } from 'react';
import { Defer, useRenderState } from '@usephase/react';

function Chart() {
  const ref = useRef<HTMLDivElement>(null);
  const phase = useRenderState(ref); // 'rendered' | 'skipped'

  useEffect(() => {
    if (phase === 'skipped') clock.pause();
    else clock.resume();
  }, [phase]);

  return (
    <Defer ref={ref}>
      <RawCanvasThing />
    </Defer>
  );
}

useRenderState only listens and reports. It has no layout effect of its own. You rarely need it for phase loops, which already self-pause off-screen.

Guarantees

These are the performance invariants behind Why the runtime libraries. They are tested in CI, not aspirations.

Zero per-frame allocations

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.

Strong pause

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.

Controlled layout reads

ResizeObserver signals element dimension changes and IntersectionObserver reports visibility. Two primitives own controlled synchronous reads: createPointer reads at most one getBoundingClientRect() in the input stage of each dirty frame; createScroll reads scroll geometry synchronously on attachment and explicit measure(), then coalesces resize-driven reads into the input stage. Frame-loop callbacks perform no synchronous layout reads.

Zero React re-renders from the frame loop

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).

Frame-locked shared clock

Ticker instances within one JavaScript global and clock protocol share one browser requestAnimationFrame loop and timestamp. See createTicker for the duplicate-copy behavior.

Frame timing

See createTicker for how delta, elapsed, and time behave after delayed frames and pauses.

Errors

Every error includes a machine-readable code and an actionable message.

import { PhaseError, isPhaseError } from '@usephase/core';
Code Trigger
server_context Calling a browser-only primitive during SSR
no_target Passing a null or undefined target to a primitive
conflicting_target Passing both ref and target to a hook
invalid_duration useTween duration is zero, negative, or NaN
ticker_stopped Calling start/resume on a stopped ticker
missing_context <Swap.State> used outside <Swap>

Relationship to View Transitions

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.

Bundle size

Minimal footprint is a core promise (see Why the runtime libraries). Every export is individually measured with Size Limit and budgeted in CI. Sizes are minified and brotli-compressed. Core rows include the core code pulled in by each export. React rows measure only the binding and exclude React and @usephase/core.

Regenerate with pnpm size:readme.

Export Size (min+brotli)
Core
createTicker 1.24 kB
createSight 1.08 kB
createLifecycle 1.59 kB
createLoop 3.12 kB
createScrollProgress 934 B
createRenderState 490 B
createDevicePixelRatio 544 B
createMutation 1.54 kB
createPointer 1.64 kB
createScroll 2.07 kB
createThrottle 983 B
createDebounce 558 B
whenIdle 409 B
prefersReducedMotion 101 B
PhaseError 98 B
isPhaseError 103 B
Ease
ease (all) 210 B
React
useLoop 421 B
useLifecycle 366 B
useSight 377 B
useCanvas 788 B
useMutation 301 B
usePointer 337 B
useScroll 440 B
useThrottledCallback 205 B
useDebouncedCallback 202 B
useTween 452 B
usePresence 534 B
useScrollProgress 216 B
useSize 365 B
useContainerQuery 239 B
useMediaQuery 61 B
usePrefersReducedMotion 92 B
useDevicePixelRatio 58 B
useSyncedRef 22 B
useStableCallback 39 B
Presence 698 B
WhenVisible 578 B
WhenIdle 197 B
Defer 85 B
useIdle 66 B
useWhenIdle 121 B
useRenderState 92 B
Swap 951 B

Agent skill

phase ships with an agent skill 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).

Install it three ways:

# skills.sh
npx skills add vercel-labs/phase --skill phase

Or copy skills/phase/ into your project's .agents/skills/phase/ and reference its SKILL.md from your AGENTS.md, or download skills/phase/dist/phase-skill.zip and unzip it into your skills directory.

The audit scanner ships with the skill (no separate install needed). Ask your agent to audit your animation code and it runs scripts/scan.mjs for you, or run it standalone with node <skill-dir>/scripts/scan.mjs <target-dir>. See the skill README for details.

Repository layout

Path Purpose
packages/core Published framework-agnostic runtime
packages/react Published React binding
packages/testing Private shared test helpers
packages/cli Command-line scanner package
packages/codemod Application migration codemod package
packages/skill Scanner source, evals, and maintainer tooling
packages/examples Shared React examples
skills/phase Installable agent skill and generated artifacts
docs/adr/ Architecture decision records

About

Lifecycle-aware UI performance layer for the web

Resources

Stars

51 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages