p5.js API
prompts/pkg/llms/p5.md on every deploy, so it
can't drift from what the platform actually does. Read it from your terminal any
time with npx vibes-diy skills.
Instance mode, the pinned import, determinism, and the drawing surface for generative sketches
Any request for generative art, a flow field, a particle field, a screensaver, a
visualiser, a doodle pad, a drawing toy, a kaleidoscope, a plotter-style pattern
or "a sketch" is a p5.js app: one canvas created by a p5 instance, driven by
a setup/draw pair, mounted from a single React.useEffect(() => {…}, [])
and torn down with instance.remove() on unmount. p5 gives you a canvas, a
frame loop, Perlin noise and a seeded random generator for free.
The whole model of p5 is two functions: setup runs once, draw runs about
sixty times a second, forever. A still picture is a loop that draws the same
thing every frame; an animation is a loop where something changed.
Complete starter — the shell every p5 vibe begins from
This is a full, runnable App.jsx. Start here and grow it: the sketch is a
factory function that receives the host element, the instance is built once in
one useEffect([]), the canvas is sized from the host, and the cleanup removes
the instance so remounts do not leak a second draw loop.
App.jsx
jsximport React from "react";
import p5 from "https://esm.sh/[email protected]";
import { Button, Row, Stack } from "@vibes.diy/pouf";
const TAU = Math.PI * 2;
// Canvas pixels cannot follow a CSS variable, so resolve the injected theme
// tokens to concrete colors once per mount. Setting `color` on a throwaway
// element and reading it back returns a resolved `rgb(...)` string even when the
// token's value is a `color-mix()` or another `var()`, and the second argument
// is the fallback for an app with no theme injected.
function cssColor(host, token, fallback) {
const probe = document.createElement("span");
probe.style.display = "none";
probe.style.color = `var(${token}, ${fallback})`;
host.appendChild(probe);
const resolved = window.getComputedStyle(probe).color;
probe.remove();
return resolved || fallback;
}
// The sketch is a pure function of its parameters and its host element. Pass the
// host in explicitly — never read p5's private `_userNode` to find it.
function makeSketch({ params, host, stepsRef }) {
return (p) => {
let particles = [];
let palette = null;
let steps = 0;
let w = 0;
let h = 0;
let t = 0;
// Read the theme tokens into p5 colors. Called at setup AND whenever the
// page's CSS variables change under a live restyle.
const readPalette = () => {
const bg = p.color(cssColor(host, "--background", "#040a18"));
const ink = p.color(cssColor(host, "--accent", "#00e5ff"));
const fade = p.color(cssColor(host, "--background", "#040a18"));
fade.setAlpha(10);
ink.setAlpha(85);
palette = { bg, ink, fade };
};
// The step count lives inside this closure, and the Keep button lives in
// React — so publish it outward on every step. `p` exists ONLY here.
const publishSteps = () => {
if (stepsRef) stepsRef.current = steps;
};
const spawn = () => {
particles = [];
for (let i = 0; i < params.count; i++) {
const x = p.random(w);
const y = p.random(h);
particles.push({ x, y, px: x, py: y });
}
};
const advance = () => {
steps += 1;
t += params.speed * 0.0016;
for (const q of particles) {
const a = p.noise(q.x * params.grain, q.y * params.grain, t) * TAU * 2;
q.px = q.x;
q.py = q.y;
q.x += Math.cos(a) * params.speed;
q.y += Math.sin(a) * params.speed;
p.line(q.px, q.py, q.x, q.y);
if (q.x < -20 || q.x > w + 20 || q.y < -20 || q.y > h + 20) {
q.x = p.random(w);
q.y = p.random(h);
q.px = q.x;
q.py = q.y;
}
}
};
p.setup = () => {
w = Math.max(120, host.clientWidth || 320);
h = Math.max(120, host.clientHeight || 240);
const c = p.createCanvas(w, h);
if (c && c.elt) c.elt.style.display = "block";
p.pixelDensity(Math.min(window.devicePixelRatio || 1, 2));
p.randomSeed(params.seed);
p.noiseSeed(params.seed);
readPalette();
p.stroke(palette.ink);
p.strokeWeight(1);
p.background(palette.bg);
spawn();
publishSteps();
};
// ONE frame of the piece: the fade is part of the frame, not decoration
// around it, so the still render below replays this and not just advance().
const step = () => {
// Trails: dim what is already there instead of erasing it.
p.noStroke();
p.fill(palette.fade);
p.rect(0, 0, w, h);
p.stroke(palette.ink);
advance();
};
p.draw = () => {
step();
publishSteps();
};
// A live restyle swaps the page's CSS variables in place; the canvas is
// painted, so it has to be told. Called by the observer in App below.
p.retheme = () => {
if (palette === null) return;
readPalette();
p.stroke(palette.ink);
p.background(palette.bg);
};
p.mouseDragged = () => {
particles.push({ x: p.mouseX, y: p.mouseY, px: p.mouseX, py: p.mouseY });
};
p.touchMoved = () => {
particles.push({ x: p.mouseX, y: p.mouseY, px: p.mouseX, py: p.mouseY });
return false; // stop the browser scrolling the page under the finger
};
p.windowResized = () => {
w = Math.max(120, host.clientWidth);
h = Math.max(120, host.clientHeight);
p.resizeCanvas(w, h);
p.background(palette.bg);
spawn();
};
};
}
export default function App() {
const hostRef = React.useRef(null);
const stepsRef = React.useRef(0);
const [params, setParams] = React.useState({
count: 1200,
grain: 0.0022,
speed: 1.6,
seed: 1234,
});
const key = JSON.stringify(params);
React.useEffect(() => {
const node = hostRef.current;
if (!node) return undefined;
const instance = new p5(makeSketch({ params, host: node, stepsRef }), node);
// The Style tab recolors a running app by swapping the CSS variables with
// no remount, which every styled element follows and the canvas cannot.
// Watching the document's stylesheets is what makes the sketch follow too.
const observer = new MutationObserver(() => instance.retheme());
observer.observe(document.head, { childList: true, subtree: true, characterData: true });
return () => {
observer.disconnect();
instance.remove();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [key]);
return (
<div className="min-h-screen bg-background text-foreground font-sans p-6">
<Stack gap={3}>
<div ref={hostRef} className="w-full aspect-video overflow-hidden rounded-lg border border-border shadow-md touch-none" />
<Row>
<Button type="button" onClick={() => setParams((prev) => ({ ...prev, seed: Math.floor(Math.random() * 100000) }))}>
Reseed
</Button>
</Row>
</Stack>
</div>
);
}
Everything below grows that shell. The lifecycle above — one instance, one
useEffect, remove() on cleanup — stays the spine of every p5 vibe.
Instance mode is how p5 runs in a vibe
Every p5 tutorial on the internet opens with a global function setup(). That
works when the sketch owns the whole page. In a vibe React owns the page, and
the sketch is a component that mounts, unmounts, and mounts again every time an
input changes — so the sketch must be an instance.
Create the instance inside one React.useEffect and return () => instance.remove() from it.
Without the cleanup, every change to the sketch's inputs mounts a fresh instance
beside the old one and the old one keeps running: two draw loops, then four, a
leaked canvas per change, and a sketch that gets mysteriously slower and
blurrier every time a slider moves.
Assign every lifecycle hook on the instance — p.setup, p.draw,
p.windowResized, p.mouseDragged — rather than declaring them as globals.
The instance is the only handle the sketch has, so everything it draws with goes
through p: p.noise, p.random, p.line, p.fill.
Pass the host element into the sketch factory explicitly. new p5(sketch, host)
draws into that element instead of appending a canvas to the body, which is what
keeps the layout React's and the pixels p5's. p5 also stashes the node on the
instance, but that property is private — hand the node to your factory as an
argument instead of reading _userNode back off p.
jsxconst hostRef = React.useRef(null);
React.useEffect(() => {
const node = hostRef.current;
if (!node) return undefined;
const instance = new p5(makeSketch({ params, host: node }), node);
return () => instance.remove();
}, [key]);
return <div ref={hostRef} className="w-full h-full touch-none" />;
Because the effect re-runs on a changed key, a parameter change is a clean
swap: the old instance is removed, a new one is built, and the sketch stays a
pure function of its parameters.
Importing p5
Import p5 from a version-pinned esm.sh URL: import p5 from "https://esm.sh/[email protected]".
A bare "p5" resolves to the 2.x line, which pulls a much heavier module graph
(acorn, escodegen) into the sandbox for an API this doc never uses.
Pinning is a correctness property here, not caution. For generative art a
library bump can change what a piece looks like — a tweak to noise(), a
different default pixelDensity — and a saved piece is a handful of numbers
that assume the renderer they were saved on. Pin the version the way you would
date a print.
Sizing the canvas
Size from the host element, never from window.innerWidth: the sketch lives in
a box React laid out, and the box is rarely the whole window.
jsp.setup = () => {
w = Math.max(120, host.clientWidth || 320);
h = Math.max(120, host.clientHeight || 240);
p.createCanvas(w, h);
};
p.windowResized = () => {
w = Math.max(120, host.clientWidth);
h = Math.max(120, host.clientHeight);
p.resizeCanvas(w, h);
};
p.resizeCanvas clears the canvas, so a trails sketch repaints its background
and respawns its particles after a resize rather than resuming into an empty
frame.
Cap pixel density at 2 for a live canvas and set p.pixelDensity(1) for thumbnails.
On a retina screen the cost of a frame is fill rate, not JavaScript, and a
gallery of small cards at device density is the fastest way to make a page
stutter.
Colour comes from the injected theme tokens
The platform injects this app's colours as CSS variables — --background,
--surface, --primary, --secondary, --accent, --text-primary,
--text-secondary, --border — and the owner restyles the whole app by swapping
those values. Everything outside the canvas reads them through plain Tailwind
utilities (bg-background, text-foreground), but canvas pixels cannot follow a CSS
variable, so a sketch that inlines "#00e5ff" is the one part of a themed vibe
its owner can never restyle.
Resolve the theme tokens to concrete colours at setup, and use a literal only
as the fallback when no theme is injected. Reading the custom property
straight off the host with getComputedStyle(host).getPropertyValue("--accent")
is enough when the token holds a plain colour, but a token whose value is a
color-mix() or another var() comes back unresolved and p5 cannot parse it.
Letting the browser resolve it works for every value:
jsfunction cssColor(host, token, fallback) {
const probe = document.createElement("span");
probe.style.display = "none";
probe.style.color = `var(${token}, ${fallback})`;
host.appendChild(probe);
const resolved = window.getComputedStyle(probe).color;
probe.remove();
return resolved || fallback;
}
p.setup = () => {
p.createCanvas(w, h);
const ink = p.color(cssColor(host, "--accent", "#00e5ff"));
ink.setAlpha(85); // p5 owns the alpha; the token owns the hue
p.stroke(ink);
p.background(p.color(cssColor(host, "--background", "#040a18")));
};
Read the tokens inside setup, not at module scope, so a rebuilt instance
picks up the current theme. Keep alpha in p5's hands with setAlpha: appending
"55" to a token works only if the token happened to be a six-digit hex, and a
resolved rgb(...) string it silently corrupts.
Reading at setup is not enough on its own, because a restyle does not rebuild
the sketch. The Style tab recolors a running app by swapping the page's CSS
variables in place — it replaces the contents of a <style> element in the
document head, with no remount — so every styled element re-renders through the
cascade and the canvas, which is painted rather than styled, keeps the colours it
resolved at setup until something unrelated happens to rebuild it. Watch the
document's stylesheets and repaint:
jsx// in the sketch: re-read and repaint on demand
p.retheme = () => {
if (palette === null) return; // setup has not run yet
readPalette();
p.stroke(palette.ink);
p.background(palette.bg);
};
// in the component: same effect that built the instance, same cleanup
const instance = new p5(makeSketch({ params, host: node }), node);
const observer = new MutationObserver(() => instance.retheme());
observer.observe(document.head, { childList: true, subtree: true, characterData: true });
return () => {
observer.disconnect();
instance.remove();
};
characterData and subtree are both needed: a first push APPENDS the style
element, and every push after it rewrites the text of the one already there. The
observer belongs to the effect that built the instance, so it is disconnected in
the same cleanup that removes it — an observer outliving its sketch is the same
leak as a draw loop outliving its canvas.
Determinism — a picture is a handful of numbers
Call p.randomSeed(seed) and p.noiseSeed(seed) in setup so the sketch replays from its numbers instead of from its pixels.
Seed both generators and the simulation becomes reproducible: the same seed
gives the same starting positions, the same wind, the same drift.
This is the single most useful p5 idea for a vibe with a database. A saved piece is not a PNG — it is its parameters, its seed and how far the simulation had run, a few dozen bytes, and a fresh instance cooks that recipe again as many times as you like. So persist the numbers, not the pixels:
steps is as load-bearing as seed — an animated sketch looks different at step
100 and step 10,000 — and it is the number that is hardest to get at, because
p exists only inside the sketch factory and the Keep button lives in React.
Reaching for p.frameCount from a click handler is a ReferenceError. The
sketch has to publish the count outward; a ref is the smallest way:
jsx// in the component
const stepsRef = React.useRef(0);
const { database, useLiveQuery } = useFireproof("pieces");
React.useEffect(() => {
const instance = new p5(makeSketch({ params, host: node, stepsRef }), node);
return () => instance.remove();
}, [key]);
// in the sketch factory — `stepsRef` is an argument, so the closure can write it.
// ONE counter with ONE owner: `advance()` is the only thing that increments it,
// because it is the only thing that moves the simulation. Everything else reads.
let steps = 0;
const advance = () => {
steps += 1;
// …move every particle, draw its segment…
};
p.draw = () => {
step(); // calls advance() exactly once
if (stepsRef) stepsRef.current = steps;
};
// in the Keep handler — no `p` in sight
async function keep() {
await database.put({ ...params, type: "piece", steps: stepsRef.current, created: Date.now() });
}
const { docs } = useLiveQuery("created", { descending: true, limit: 12 });
Keep the counter to one owner. It counts calls to advance(), so incrementing it
anywhere else — in draw, in step, beside the publish — counts the same frame
twice, and a card then replays twice the frames that were on screen when Keep was
pressed, which is the exact error persisting steps exists to prevent.
A card then renders exactly doc.steps steps (see the still-render pattern
below), so the wall shows the piece at the moment it was kept rather than at
whatever frame the card happened to reach. Loading somebody else's piece hands
the reader a live sketch with their parameters in it, so nudging one number is a
variation rather than a copy.
Two honest limits, and say them in the app's own copy rather than promising more than it does:
- Hand-drawn input is not in the numbers. If a person can drag on the canvas
to push ink into it, what they drew is unbounded input that no seed replays. A
kept piece is a canonical render of the recipe, not a snapshot of the canvas at
the moment of keeping — the recipe is exact, the print is fresh. An app that
needs the literal pixels is asking for an image, so capture one
(
p.saveCanvas, or the canvas's owntoBlob) and store that instead. - The numbers fix the simulation, not the screen. A canvas of a different size or pixel density starts the particles in proportionally different places, the way the same negative prints differently at different sizes.
Math.random() is NOT covered by randomSeed — use p.random() everywhere
inside a sketch that means to be reproducible, and p.noise() rather than any
hand-rolled noise.
Still renders — a thumbnail is a loop you ran to completion
To draw a gallery card, run the simulation inside setup for the stored number
of steps and then stop the loop. Same sketch factory, one flag — and steps
comes off the saved piece (doc.steps), which is what makes the card the piece
its author kept:
js// ONE frame of the piece, called by both paths. The fade is part of a frame:
// a still loop that calls only `advance()` accumulates different stroke opacity
// than the live canvas did, so the card would not be the piece at identical
// seed, parameters, size and step count.
const step = () => {
p.noStroke();
p.fill(palette.fade);
p.rect(0, 0, w, h);
p.stroke(palette.ink);
advance();
};
p.setup = () => {
p.createCanvas(w, h);
p.pixelDensity(still ? 1 : Math.min(window.devicePixelRatio || 1, 2));
p.randomSeed(params.seed);
p.noiseSeed(params.seed);
readPalette();
p.background(palette.bg);
spawn();
if (still) {
for (let i = 0; i < steps; i++) step(); // the SAME step, not advance()
p.noLoop();
}
};
p.draw = () => {
if (still) return;
step();
};
Whatever a live frame does — the fade, the stroke colour, a per-frame drift — the
still path has to do too, or the card is a different picture from the one its
author kept. p.noLoop() stops draw from being scheduled; p.loop() starts it again, and
p.redraw() runs one frame on demand. A wall of stills is cheap because none
of them are animating.
Performance
A flow field draws thousands of segments a frame, so the per-item work is what decides whether it holds sixty frames a second.
Group by colour so stroke() changes a handful of times per frame instead of
once per particle. Bucket particles by palette band, sort by colour, or just
guard the call:
jslet cur = null;
for (const q of particles) {
if (q.c !== cur) {
cur = q.c;
p.stroke(cur);
}
p.line(q.px, q.py, q.x, q.y);
}
Rotate coordinates by hand instead of push/translate/rotate/pop per
item. Precompute the sine and cosine of each symmetry arm once, then transform
the two endpoints with arithmetic — the difference between one transform matrix
and eleven thousand of them.
jsconst arms = [];
for (let s = 0; s < symmetry; s++) {
const a = (TAU / symmetry) * s;
arms.push([Math.cos(a), Math.sin(a)]);
}
// per segment:
for (const [co, si] of arms) {
p.line(cx + ax * co - ay * si, cy + ax * si + ay * co, cx + bx * co - by * si, cy + bx * si + by * co);
}
Use a translucent rectangle for trails instead of background(). Painting a
nearly-transparent rect over the canvas each frame dims the history a few
percent rather than erasing it, which is most of what people mean when a sketch
"looks generative". The alpha is the knob: lower is smokier, higher snaps back
to dots.
jsp.noStroke();
p.fill(20, 20, 20, 12);
p.rect(0, 0, width, height);
Other levers: keep the particle array a fixed size and recycle dead particles
rather than allocating; keep React state out of the frame loop entirely (the
loop mutates plain objects the sketch closed over, and React only ever sets the
parameters); and for a field dense enough that 2D runs out of road, WEBGL mode
with p.createShader(vert, frag) evaluates it per pixel on the GPU.
Mouse and touch
p5 keeps p.mouseX / p.mouseY current and fills them from a touch too, so one
handler usually covers both surfaces.
jsp.mouseDragged = () => {
paintAt(p.mouseX, p.mouseY);
};
p.touchMoved = () => {
paintAt(p.mouseX, p.mouseY);
return false; // returning false prevents the browser's default scroll/zoom
};
Set touchAction: "none" on the host element so a drag paints instead of
scrolling the page on a phone. Returning false from touchMoved handles the
gesture p5 sees; the CSS handles the gesture the browser would have taken first.
API surface
Canvas and lifecycle
jsp.createCanvas(w, h); // or p.createCanvas(w, h, p.WEBGL)
p.resizeCanvas(w, h);
p.pixelDensity(1);
p.noLoop();
p.loop();
p.redraw();
p.frameRate(30);
p.frameCount; // frames since setup
p.deltaTime; // ms since the previous frame
Drawing
jsp.background("#040a18");
p.fill(255, 120, 0, 40); // r, g, b, alpha 0-255
p.noFill();
p.stroke("#00e5ff55"); // 8-digit hex carries alpha
p.noStroke();
p.strokeWeight(1.5);
p.line(x1, y1, x2, y2);
p.circle(x, y, d);
p.ellipse(x, y, w, h);
p.rect(x, y, w, h, radius);
p.triangle(x1, y1, x2, y2, x3, y3);
p.point(x, y);
p.beginShape();
p.vertex(x, y);
p.curveVertex(x, y);
p.endShape(p.CLOSE);
p.text("hello", x, y);
p.textSize(16);
p.textAlign(p.CENTER, p.CENTER);
Colour
jsp.colorMode(p.HSB, 360, 100, 100, 1);
const c = p.color(200, 80, 90);
p.lerpColor(a, b, 0.3);
Numbers, randomness, noise
jsp.random(max); // p.random(min, max), p.random(array)
p.randomSeed(1234);
p.noise(x, y, z); // 0..1 Perlin noise
p.noiseSeed(1234);
p.noiseDetail(octaves, falloff);
p.map(v, inMin, inMax, outMin, outMax);
p.constrain(v, min, max);
p.lerp(a, b, t);
p.dist(x1, y1, x2, y2);
p.TWO_PI;
p.sin(a);
p.cos(a);
Transforms
jsp.push();
p.translate(x, y);
p.rotate(angle);
p.scale(s);
p.pop();
Input
jsp.mouseX;
p.mouseY;
p.pmouseX;
p.pmouseY;
p.mouseIsPressed;
p.mousePressed = () => {};
p.mouseDragged = () => {};
p.mouseReleased = () => {};
p.mouseWheel = (event) => {};
p.touchStarted = () => {};
p.touchMoved = () => false;
p.touchEnded = () => {};
p.keyPressed = () => {};
p.key;
p.keyCode;
Pixels and export
jsp.loadPixels();
p.pixels; // flat RGBA array
p.updatePixels();
p.get(x, y); // one pixel
p.createGraphics(w, h); // an offscreen buffer to draw into
p.saveCanvas("piece", "png");
WEBGL
jsconst shader = p.createShader(vertSource, fragSource);
p.shader(shader);
shader.setUniform("u_time", p.millis() / 1000);
shader.setUniform("u_resolution", [w, h]);
p.rect(-w / 2, -h / 2, w, h); // a full-canvas quad for the fragment shader
p.box(50);
p.sphere(40);
p.orbitControl();
Common gotchas
A global function setup() never runs. In instance mode p5 only calls the
hooks it finds on the instance, so a sketch written the tutorial way mounts a
blank canvas and nothing reports a problem.
A missing instance.remove() is invisible until it is loud. The symptom is
a sketch that speeds up, brightens, or tears after a few parameter changes —
that is two or four draw loops painting the same canvas.
p.resizeCanvas clears the drawing. Repaint the background and reseed the
simulation from windowResized rather than assuming the previous frame survived.
Do not drive the sketch from React state per frame. A setState inside
draw re-renders the tree sixty times a second; let the loop mutate plain
objects and keep React for the controls.
p.random() is seeded; Math.random() is not. Mixing them makes a sketch
that looks reproducible until it isn't.
Colour with alpha is how trails and depth are made. An 8-digit hex string
("#00e5ff55") or a fourth argument to fill/stroke is usually the
difference between a diagram and a picture.
One instance per host element. Mounting a second p5 into the same node gives two canvases stacked in the same box.