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