Baktiyar Ablaikhan

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