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:
| Prop | What it does |
|---|---|
library | The 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. |
render | Called every frame to draw the scene, in logical (CSS) coordinates: the canvas transform is pre-scaled by the device pixel ratio. |
loop | Set to false to render a single frame instead of an animation loop. The scene still re-renders when the canvas resizes. |
paused | Pauses the animation loop; time stops advancing while paused. |
device | Use 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, orfallbackwhen 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 onlyerrorFallbackset, the server renders nothing in place of the gated children, so the content pops in on hydration; pass afallbacktoo 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.useDeviceis 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,
onErrorfires when initialization fails or when the device is lost (a loss triggers automatic re-initialization). - On a canvas,
onErrorfires 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,
onReadyreceives theGPUDeviceonce it is ready, before the first frame. It is safe under React StrictMode. - On a canvas,
onGPUTimereceives 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 thetimestamp-queryfeature (<RedrawProvider options={{ timestampQuery: true }}>); when timestamps are unavailable, a one-time warning is logged instead. - On a canvas,
onStatsreceives, 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 thetimestamp-queryfeature the GPU fields are null but the callback still fires with the CPU time.
Without an onError handler, errors are logged to the console.