p5.js-style canvas sketches for React and Next.js — a headless hook plus a drop-in
<CanvasSketch /> component. DPR-aware resizing, a requestAnimationFrame
loop with dt/time/frame, normalized pointer/keyboard
input, and full SSR safety, all in one dependency-free core — plus an optional
<ThreeSketch /> engine, built the same way, for simulations and games with
Three.js.
Every hand-rolled canvas animation in React ends up rewriting the same plumbing: a
canvasRef, a devicePixelRatio-aware resize handler, a
requestAnimationFrame loop with manual dt bookkeeping, pointer
coordinates translated through getBoundingClientRect(), and cleanup on unmount.
next-sketch extracts that plumbing once so you can focus on setup/draw,
the way you would in p5.js — but as an idiomatic React
hook/component instead of a global-mode sketch.
Each card below is a standalone static page — a vanilla-JS port of the same
setup/draw pair you'd pass to <CanvasSketch /> or
<ThreeSketch />, all built on next-sketch's own engines end to end (no
p5.js or react-three-fiber underneath). Open "View source" on any of them to see the real
next-sketch (React) code side by side — simulations and a small game included.
The README's first example: a single circle orbiting the canvas center, driven by time.
60 particles bouncing off the edges, colored by speed with lerpColor and randRange.
Move the pointer to draw, click to burst a ring — demonstrates onPointerMove/onPointerDown input.
A rotating icosahedron rendered with Three.js, the same scene as the useThreeSketch README snippet.
500 particles drifting through a Perlin-noise vector field — <CanvasSketch /> plus createNoise2D, next-sketch's own engine end to end.
Drag to dodge the falling blocks — score, collisions, and a restart-on-click game loop, built on <CanvasSketch />'s pointer input.
Condensed reference — see the full README for every detail.
The headless engine. Returns a canvasRef to attach to your own <canvas>, plus imperative controls.
const { canvasRef, start, stop, reset, isRunning, running } = useCanvasSketch({
setup?: (info: SetupInfo) => void,
draw?: (info: DrawInfo) => void,
onPointerDown?: (info: PointerInfo) => void,
onPointerMove?: (info: PointerInfo) => void,
onPointerUp?: (info: PointerInfo) => void,
onKeyDown?: (event: KeyboardEvent) => void,
onKeyUp?: (event: KeyboardEvent) => void,
autoStart?: boolean, // default true
});
SetupInfo — { ctx, canvas, width, height } (CSS pixels, DPR-normalized).
DrawInfo adds { dt, time, frame }. PointerInfo is
{ x, y, event }, localized to the canvas in CSS pixels.
A thin <canvas> wrapper around the hook. Same options as props, plus className/style. Pass a ref to get a CanvasSketchHandle (start/stop/reset/isRunning).
Same engine, backed by Three.js instead of a 2D context. setup/draw get { scene, camera, renderer, canvas, width, height } (plus dt/time/frame in draw); renderer.render(scene, camera) happens for you. Requires three as a peer dependency — not bundled.
import { ThreeSketch } from 'next-sketch/three';
import * as THREE from 'three';
<ThreeSketch
style={{ width: '100%', height: 400 }}
setup={({ scene }) => {
scene.add(new THREE.AmbientLight(0xffffff, 0.6));
scene.add(new THREE.Mesh(
new THREE.IcosahedronGeometry(1.5, 1),
new THREE.MeshStandardMaterial({ color: '#6366f1' }),
));
}}
draw={({ scene, dt }) => { scene.children[1].rotation.y += dt; }}
/>
On unmount, every geometry/material in the scene graph is disposed and the WebGL context is force-lost, so it's actually freed.
A thin <canvas> wrapper around useThreeSketch, mirroring <CanvasSketch />: same ref-based handle, plus className/style.
A page rendering several live 3D sketches at once can hit the browser's per-process WebGL
context limit (Chrome: ~16, Safari: often lower); past it, browsers silently evict the
oldest context with no error. useThreeSketch guards against this by
default — each instance waits for a slot in a shared, module-level budget (default: 4
concurrent) before creating its renderer.
import { configureWebglBudget, getWebglBudget } from 'next-sketch';
configureWebglBudget(6); // raise/lower the app-wide cap
getWebglBudget(); // -> { active: 2, max: 6 }
Opt a specific sketch out with respectBudget: false (e.g. one full, interactive detail-page simulation, as opposed to a grid of decorative previews).
Tracks whether an element is inside the viewport via IntersectionObserver, so you can mount an expensive sketch only while it's actually visible.
const wrapperRef = useRef<HTMLDivElement>(null);
const inView = useInViewport(wrapperRef, { rootMargin: '200px', once: false });
With once: true it stays true forever after the first intersection — no restart cost on scroll. With once: false it flips back to false off-screen, letting a <ThreeSketch /> unmount and free its budget slot in a long gallery page.
Small helpers factored out of the same duplicated math every canvas sketch ends up writing — including a seeded 2D Perlin noise for flow fields and organic motion, the same role as p5.js's noise(), built from scratch.
import { clamp, randRange, lerpColor, createNoise2D } from 'next-sketch';
clamp(value, min, max); // -> number
randRange(min, max); // -> number
lerpColor([59,130,246], [239,68,68], t); // -> "rgb(r,g,b)"
const noise = createNoise2D(seed); // -> (x, y) => number, roughly in [-1, 1]
noise(x * 0.01, y * 0.01);
useCanvasSketch/CanvasSketch never touch window/document
outside of effects, so they're safe to import in a Server Component tree. If you still prefer
to opt a sketch fully out of SSR (e.g. it depends on window inside your own
draw callback), wrap it the usual way:
import dynamic from 'next/dynamic';
const Sketch = dynamic(() => import('./Sketch'), { ssr: false });