next-sketch

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.

npm install next-sketch

npm version npm downloads CI license

Why

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.

API

Condensed reference — see the full README for every detail.

useCanvasSketch(options)

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.

<CanvasSketch />

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

useThreeSketch(options)

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.

<ThreeSketch />

A thin <canvas> wrapper around useThreeSketch, mirroring <CanvasSketch />: same ref-based handle, plus className/style.

WebGL context budget

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

useInViewport(ref, options)

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.

Utilities

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

Next.js / SSR

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 });