Skip to main content
Technical preview

Redraw is currently in technical preview, available to wcandillon.dev subscribers. API is unstable.

React

react-redraw provides React bindings for Redraw, built around two pieces:

  • <RedrawProvider> owns the WebGPU device, shared by everything below it.
  • <RedrawCanvas> binds a canvas element to that device and drives the render loop.

Every canvas and hook in this package needs a <RedrawProvider> above it (or an explicit device via the device prop) and throws a clear error otherwise. Wrap your app, or the part of it that draws, once:

import { RedrawCanvas, RedrawProvider } from "react-redraw";

import { library, render } from "./hello-world";

export function App() {
return (
<RedrawProvider>
<Hello />
</RedrawProvider>
);
}

function Hello() {
return (
<RedrawCanvas
style={{ width: "100%", aspectRatio: "4 / 3" }}
library={library}
render={render}
/>
);
}

No matter how many canvases live under the provider, the page holds a single GPU device.

The provider accepts its device-request options through the options prop, for example GPU timestamp queries or an existing device:

<RedrawProvider options={{ timestampQuery: true }}>...</RedrawProvider>
// or interop with an existing WebGPU setup; the device stays yours
<RedrawProvider options={{ device }}>...</RedrawProvider>

Nesting

Nesting <RedrawProvider> is idempotent: a nested provider reuses the enclosing device instead of creating a second one. If you do want an isolated device, e.g. with distinct options such as timestampQuery, use <LocalRedrawProvider>.

<RedrawProvider>
<RedrawCanvas library={library} render={render} />
<LocalRedrawProvider options={{ timestampQuery: true }}>
{/* its own device, isolated from the provider above */}
<RedrawCanvas library={library} render={render} />
</LocalRedrawProvider>
</RedrawProvider>

RedrawCanvas

<RedrawCanvas> renders a <canvas> element, builds the Library from the library prop, and calls render every frame with a fresh recorder and a FrameInfo:

<RedrawCanvas
library={{ functions: [PathGradient, HelloStroke], maxPerTile: 64 }}
render={(canvas, { width, height, time }) => {
// record the scene; the component calls canvas.render() for you
}}
/>

The props:

PropWhat it does
libraryThe canvas's drawing vocabulary (custom functions) plus Library options (tileSize, maxPerTile, textureSlots, ...). Omit it to draw with the std vocabulary only. Read once on mount; remount with a key to change it.
renderCalled every frame to draw the scene, in logical (CSS) coordinates: the canvas transform is pre-scaled by the device pixel ratio.
loopSet to false to render a single frame instead of an animation loop. The scene still re-renders when the canvas resizes.
pausedPauses the animation loop; time stops advancing while paused.
deviceUse an existing GPUDevice, overriding a surrounding provider.

FrameInfo carries width and height (CSS pixels), time (milliseconds since mount, excluding paused time), frame, and dpr.

The component handles the WebGPU plumbing: it configures the swapchain (rendering straight into it when the platform's preferred format is storage-bindable, otherwise through an offscreen copy), rebinds the canvas when the element resizes, and stops the loop when the device is lost.

Loading and error states

The provider initializes asynchronously. By default children render immediately: each canvas mounts (reserving its layout) and starts drawing once the device is ready. To show loading and unsupported states instead, pass fallback and errorFallback:

<RedrawProvider
fallback={<Spinner />}
errorFallback={(error) => <p>WebGPU is not available: {error.message}</p>}
>
<RedrawCanvas library={library} render={render} />
</RedrawProvider>

If the device is ever lost, the provider notifies onError, shows fallback again, and re-initializes with a fresh device; canvases resume automatically.

useDevice

Canvases are not the only consumers of the device: useDevice() hands you the GPUDevice directly for imperative work on the same device, for example loading textures or building a Library by hand:

import { makeTextureFromImage } from "redraw";
import { useDevice } from "react-redraw";

function Photo({ blob }: { blob: Blob }) {
const device = useDevice();
// Same device as the canvases on the page
// ... makeTextureFromImage(device, blob), library.makeCanvas(...), etc.
}

The hook suspends while the provider initializes (and rethrows init failures), so components using it work with your own <Suspense> and error boundaries anywhere below the provider. It is client-only: see Server-side rendering for how to gate it in an SSR app.

Bring your own device

You can also manage a device yourself and pass it explicitly, which overrides any surrounding provider:

const adapter = await navigator.gpu.requestAdapter();
const device = await adapter.requestDevice();

<RedrawCanvas device={device} library={library} render={render} />;

Ownership is simple: whoever creates the device owns it. A canvas only ever destroys its own resources; the provider destroys the device it created on unmount; a device you pass in stays yours.

Server-side rendering

The bindings are safe to render on the server (Next.js, React Router streaming SSR, and similar):

  • <RedrawProvider> initializes WebGPU only after mounting in the browser. On the server it renders its children directly, or fallback when you passed one, without suspending. Hydration is seamless: the client first renders the same fallback, then swaps in the children once the device is ready. With only errorFallback set, the server renders nothing in place of the gated children, so the content pops in on hydration; pass a fallback too when the subtree should reserve its layout.
  • <RedrawCanvas> renders a plain <canvas> server-side (reserving its layout) and starts drawing once the device is ready in the browser.
  • useDevice is client-only. The device can never exist on the server, so instead of suspending forever (which would hold a streaming response open until the framework aborts it), the hook throws a descriptive error during server rendering.

To use useDevice in a component that takes part in SSR, render it only after a client mount, the standard gating pattern:

function Thumbnail() {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
if (!mounted) {
return <Placeholder />; // emitted into the SSR payload
}
return (
<Suspense fallback={<Placeholder />}>
<ThumbnailContent /> {/* calls useDevice() */}
</Suspense>
);
}

Alternatively, the provider's fallback prop gives you the same gating for an entire subtree without any wiring.

Lifecycle callbacks

  • On the provider, onError fires when initialization fails or when the device is lost (a loss triggers automatic re-initialization).
  • On a canvas, onError fires when the device is lost or when rendering a frame throws; the animation loop stops first, so a dying device never leaves a frozen canvas without a signal.
  • On a canvas, onReady receives the GPUDevice once it is ready, before the first frame. It is safe under React StrictMode.
  • On a canvas, onGPUTime receives the GPU time of the most recently completed render in nanoseconds after each frame (the value lags the current frame by a few frames). Setting it enables Library timestamps automatically, but the device must support the timestamp-query feature (<RedrawProvider options={{ timestampQuery: true }}>); when timestamps are unavailable, a one-time warning is logged instead.
  • On a canvas, onStats receives, after each frame, the frame's CPU time in milliseconds plus the per-pass GPU times (the binning pass and the draw pass, in nanoseconds). Without the timestamp-query feature the GPU fields are null but the callback still fires with the CPU time.

Without an onError handler, errors are logged to the console.