Baktiyar Ablaikhan

Ink bloom

Source
After bencho.dev ↗
Author
Interaction after bencho.dev
Licence
Original code — interaction after bencho.dev

Loading…

Tap to drop ink that blooms across the paper, or drag to draw with it.

Code

"use client";

import { createNoise3D } from "simplex-noise";
import { useEffect, useRef } from "react";
import { fitCanvas, prefersReducedMotion, rng, Surface } from "../ui";

type Bloom = { x: number; y: number; r: number; max: number; age: number; seed: number; color: string };

const INKS = ["43,70,184", "21,21,21", "43,70,184", "92,110,190"];

export default function InkBloom() {
  const canvas = useRef<HTMLCanvasElement>(null);
  const queue = useRef<{ x: number; y: number; big: boolean }[]>([]);
  const last = useRef({ x: 0, y: 0, t: 0, down: false, touched: false });

  useEffect(() => {
    const cv = canvas.current;
    if (!cv) return;
    const noise = createNoise3D(rng(11));
    const rand = rng(99);
    let ctx: CanvasRenderingContext2D | null = null;
    let W = 0;
    let H = 0;
    let blooms: Bloom[] = [];
    let raf = 0;
    let idle = 0;
    let t = 0;
    const still = prefersReducedMotion();

    const size = () => {
      const r = cv.getBoundingClientRect();
      W = r.width;
      H = r.height;
      ctx = fitCanvas(cv, W, H);
    };
    size();
    const ro = new ResizeObserver(size);
    ro.observe(cv);

    const add = (x: number, y: number, big: boolean) => {
      const base = Math.min(W, H);
      blooms.push({
        x,
        y,
        r: 0,
        max: base * (big ? 0.16 + rand() * 0.1 : 0.05 + rand() * 0.04),
        age: 0,
        seed: rand() * 100,
        color: INKS[Math.floor(rand() * INKS.length)],
      });
      if (blooms.length > 90) blooms = blooms.slice(-90);
    };

    const shape = (b: Bloom, scale: number, wobble: number) => {
      if (!ctx) return;
      ctx.beginPath();
      const n = 56;
      for (let i = 0; i <= n; i++) {
        const a = (i / n) * Math.PI * 2;
        const k = 1 + noise(Math.cos(a) * 1.4, Math.sin(a) * 1.4, b.seed + t * 0.05) * wobble;
        const rr = b.r * scale * k;
        const px = b.x + Math.cos(a) * rr;
        const py = b.y + Math.sin(a) * rr;
        if (i) ctx.lineTo(px, py);
        else ctx.moveTo(px, py);
      }
      ctx.closePath();
    };

    const loop = () => {
      t += 1 / 60;
      for (const q of queue.current.splice(0)) add(q.x, q.y, q.big);
      // a gentle autoplay until someone touches the stage
      if (!last.current.touched) {
        idle += 1 / 60;
        if (idle > 1.6 || blooms.length === 0) {
          idle = 0;
          add(W * (0.25 + rand() * 0.5), H * (0.25 + rand() * 0.5), true);
        }
      }
      if (ctx) {
        ctx.clearRect(0, 0, W, H);
        for (const b of blooms) {
          b.age += 1 / 60;
          b.r += (b.max - b.r) * 0.035;
          const life = Math.max(0, 1 - Math.max(0, b.age - 4) / 3);
          // soft wet halo, the body, then a darker drying edge
          shape(b, 1.12, 0.22);
          ctx.fillStyle = `rgba(${b.color},${0.05 * life})`;
          ctx.fill();
          shape(b, 1, 0.16);
          ctx.fillStyle = `rgba(${b.color},${0.16 * life})`;
          ctx.fill();
          ctx.lineWidth = 1.2;
          ctx.strokeStyle = `rgba(${b.color},${0.28 * life})`;
          ctx.stroke();
          shape(b, 0.45, 0.3);
          ctx.fillStyle = `rgba(${b.color},${0.1 * life})`;
          ctx.fill();
        }
        blooms = blooms.filter((b) => b.age < 7);
      }
      if (!still) raf = requestAnimationFrame(loop);
    };
    loop();
    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
    };
  }, []);

  const at = (e: React.PointerEvent) => {
    const r = canvas.current!.getBoundingClientRect();
    return { x: e.clientX - r.left, y: e.clientY - r.top };
  };

  return (
    <Surface>
      <canvas
        ref={canvas}
        className="absolute inset-0 h-full w-full cursor-crosshair touch-none"
        onPointerDown={(e) => {
          const p = at(e);
          last.current = { ...p, t: performance.now(), down: true, touched: true };
          queue.current.push({ ...p, big: true });
        }}
        onPointerMove={(e) => {
          const l = last.current;
          if (!l.down) return;
          const p = at(e);
          if (Math.hypot(p.x - l.x, p.y - l.y) > 14) {
            queue.current.push({ ...p, big: false });
            l.x = p.x;
            l.y = p.y;
          }
        }}
        onPointerUp={() => (last.current.down = false)}
        onPointerLeave={() => (last.current.down = false)}
      />
      <p className="pointer-events-none absolute bottom-[6%] font-mono text-[0.7em] text-[#8a8984]">tap to drop ink · drag to draw</p>
    </Surface>
  );
}
Prompt

Build "Ink bloom", a canvas animation. What it does: Tap to drop ink that blooms across the paper, or drag to draw with it. Write it as a single self-contained React component in TypeScript that fills its parent (w-full h-full), styled with Tailwind CSS, using simplex-noise. 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://bencho.dev (Interaction after bencho.dev, Original code — interaction after bencho.dev).