Pixel Brush Swatches
- Source
- arlan.me/vault ↗
- Author
- Arlan
- Licence
- MIT
Loading…
The same spiral drawn three times on one sheet with pixel brushes — a pink combed ribbon, a dense blue spray and a loose drift of green squares.
Code
"use client";
import { makeCanvas, mulberry32, useStage, type Scene } from "./stage";
/*
* Three pixel brush swatches on one sheet: the same spiral drawn three times —
* pink as a combed ribbon, blue as a dense spray, green as a loose drift of
* squares. Everything snaps to one pixel grid.
*/
const SHEET = "#f6f3ec";
const PINK = "#ff4fa3";
const BLUE = "#2f6bff";
const GREEN = "#14b06a";
const DRAW_S = 3.4;
const HOLD_S = 1.6;
const FADE_S = 0.6;
const LOOP = DRAW_S + HOLD_S + FADE_S;
const TURNS = 2.5;
type Drift = { x: number; y: number; vx: number; vy: number; s: number; born: number };
function spiral(u: number, R: number): [number, number, number, number] {
// Archimedean spiral from the centre out; returns point and unit normal
const th = u * TURNS * Math.PI * 2;
const r = R * (0.08 + 0.92 * u);
const x = Math.cos(th) * r;
const y = Math.sin(th) * r;
const dr = R * 0.92;
const tx = dr * Math.cos(th) - r * Math.sin(th) * TURNS * Math.PI * 2;
const ty = dr * Math.sin(th) + r * Math.cos(th) * TURNS * Math.PI * 2;
const tl = Math.hypot(tx, ty) || 1;
return [x, y, -ty / tl, tx / tl];
}
function createScene(): Scene {
let layers: { c: HTMLCanvasElement; ctx: CanvasRenderingContext2D }[] = [];
let size = 0;
let px = 2;
let lastU = 0;
let lastCycle = -1;
let drift: Drift[] = [];
const rnd = mulberry32(11);
const comb = Array.from({ length: 11 }, () => rnd() > 0.22);
const reset = (sz: number) => {
size = Math.max(40, Math.floor(sz));
layers = [0, 1].map(() => makeCanvas(size, size));
for (const l of layers) l.ctx.clearRect(0, 0, size, size);
lastU = 0;
drift = [];
};
const snap = (v: number) => Math.round(v / px) * px;
const dab = (u0: number, u1: number, t: number) => {
const R = size * 0.43;
const c = size / 2;
const steps = Math.max(1, Math.ceil(((u1 - u0) * R * TURNS * Math.PI * 2 * 0.6) / px));
for (let i = 1; i <= steps; i++) {
const u = u0 + ((u1 - u0) * i) / steps;
const [x, y, nx, ny] = spiral(u, R);
const taper = Math.min(1, u / 0.06, (1 - u) / 0.06);
// combed ribbon: a row of bristles across the stroke, some missing
const pc = layers[0].ctx;
pc.fillStyle = PINK;
const half = Math.round(5 * Math.max(0.35, taper));
for (let j = -half; j <= half; j++) {
if (!comb[j + 5]) continue;
pc.fillRect(snap(c + x + nx * j * px), snap(c + y + ny * j * px), px, px);
}
// dense spray: gaussian scatter around the path
const bc = layers[1].ctx;
bc.fillStyle = BLUE;
for (let k = 0; k < 6; k++) {
const g1 = rnd() + rnd() + rnd() - 1.5;
const g2 = rnd() + rnd() + rnd() - 1.5;
bc.fillRect(snap(c + x + g1 * px * 4.5), snap(c + y + g2 * px * 4.5), px, px);
}
// loose drift: occasional squares that keep wandering
if (rnd() < 0.32) {
drift.push({
x: x + (rnd() - 0.5) * px * 6,
y: y + (rnd() - 0.5) * px * 6,
vx: (rnd() - 0.5) * px * 1.4,
vy: (rnd() - 0.5) * px * 1.4,
s: rnd() < 0.3 ? 2 : 1,
born: t,
});
}
}
};
return {
resize(w, h) {
px = Math.max(2, Math.round(Math.min(w, h) / 150));
reset(Math.min(w / 3.3, h * 0.72));
},
frame({ ctx, w, h, t, reduced }) {
ctx.fillStyle = SHEET;
ctx.fillRect(0, 0, w, h);
if (!layers.length) return;
const cycle = Math.floor(t / LOOP);
const local = t - cycle * LOOP;
if (cycle !== lastCycle) {
reset(size);
lastCycle = cycle;
if (reduced) dab(0, 1, t);
}
const u = Math.min(1, local / DRAW_S);
const eased = 1 - (1 - u) * (1 - u) * 0.35 - 0.65 * (1 - u);
if (eased > lastU) {
dab(lastU, eased, t);
lastU = eased;
}
const alpha = local > DRAW_S + HOLD_S ? 1 - (local - DRAW_S - HOLD_S) / FADE_S : 1;
const gap = (w - size * 3) / 4;
const top = (h - size) / 2 - h * 0.03;
ctx.imageSmoothingEnabled = false;
ctx.globalAlpha = Math.max(0, alpha);
ctx.drawImage(layers[0].c, gap, top, size, size);
ctx.drawImage(layers[1].c, gap * 2 + size, top, size, size);
// drift squares are live
const x3 = gap * 3 + size * 2 + size / 2;
const y3 = top + size / 2;
ctx.fillStyle = GREEN;
for (const d of drift) {
const age = t - d.born;
const k = 1 - Math.exp(-age * 0.8);
const dx = d.x + d.vx * k * 3 + Math.sin(t * 1.3 + d.born * 7) * px * 0.6;
const dy = d.y + d.vy * k * 3 + Math.cos(t * 1.1 + d.born * 5) * px * 0.6;
const s = px * (1 + d.s);
ctx.fillRect(snap(x3 + dx), snap(y3 + dy), s, s);
}
ctx.globalAlpha = 1;
// captions
ctx.fillStyle = "#151515";
ctx.globalAlpha = 0.55;
ctx.font = `500 ${Math.max(9, h * 0.035)}px ui-monospace, Menlo, Consolas, monospace`;
ctx.textAlign = "center";
ctx.textBaseline = "top";
const cap = top + size + h * 0.035;
ctx.fillText("comb", gap + size / 2, cap);
ctx.fillText("spray", gap * 2 + size * 1.5, cap);
ctx.fillText("drift", x3, cap);
ctx.globalAlpha = 1;
},
};
}
export default function PixelBrushes() {
const ref = useStage(createScene, 4.5);
return <div ref={ref} className="relative h-full w-full overflow-hidden bg-[#f6f3ec]" />;
}
Prompt
Build "Pixel Brush Swatches", a canvas animation. What it does: The same spiral drawn three times on one sheet with pixel brushes — a pink combed ribbon, a dense blue spray and a loose drift of green squares. Write it as a single self-contained React component in TypeScript that fills its parent (w-full h-full), styled with Tailwind CSS. Technique: an HTML canvas, a requestAnimationFrame loop, pointer tracking. It sits on a light background, in a 16:10 frame. Respect prefers-reduced-motion with a still state, pause work when the element is off screen, and keep it accessible. Original: https://www.arlan.me/vault (Arlan, MIT).