Bristling Spiky Word
- Source
- arlan.me/vault ↗
- Author
- Arlan
- Licence
- MIT
Loading…
The word mango knocked out in cream from a yellow ground and bristling with hundreds of hand-drawn green-outlined spikes that breathe slowly and bend away from the pointer.
Code
"use client";
import { clamp, makeCanvas, mulberry32, useStage, type Scene } from "./stage";
/*
* "mango" knocked out in cream from a yellow ground, outlined in leaf green and
* bristling with ~1000 hand-drawn spikes of power-law length. Each spike is a
* wandering spine walked out and back, filled with the ground colour and
* outlined, so the tangle stays legible. Spikes breathe and bend away from the
* pointer on their own springs; open edges grow into the room they have.
*/
const DW = 1000;
const DH = 625;
const GROUND = "#ffd21a";
const CREAM = "#fff7e0";
const LEAF = "#3a8a2c";
const WORD = "mango";
const FONT = `900 210px "Arial Rounded MT Bold", "Nunito", "Arial Black", Arial, sans-serif`;
type Spike = {
x: number;
y: number;
base: number;
len: number;
slack: number;
path: Path2D;
phase: number;
bend: number;
vel: number;
grow: number;
stiff: number;
damp: number;
};
function build(): Spike[] {
const { c, ctx } = makeCanvas(DW, DH);
ctx.fillStyle = "#000";
ctx.font = FONT;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText(WORD, DW / 2, DH / 2 + 10);
const data = ctx.getImageData(0, 0, DW, DH).data;
void c;
const inside = (x: number, y: number) => {
const xi = x | 0;
const yi = y | 0;
if (xi < 0 || yi < 0 || xi >= DW || yi >= DH) return false;
return data[(yi * DW + xi) * 4 + 3] > 127;
};
const rnd = mulberry32(7);
const edges: [number, number][] = [];
for (let y = 1; y < DH - 1; y++) {
for (let x = 1; x < DW - 1; x++) {
if (!inside(x, y)) continue;
if (!inside(x - 1, y) || !inside(x + 1, y) || !inside(x, y - 1) || !inside(x, y + 1)) edges.push([x, y]);
}
}
// shuffle then thin with a spacing grid
for (let i = edges.length - 1; i > 0; i--) {
const j = (rnd() * (i + 1)) | 0;
[edges[i], edges[j]] = [edges[j], edges[i]];
}
const cell = 3.4;
const taken = new Set<number>();
const spikes: Spike[] = [];
for (const [x, y] of edges) {
const key = ((x / cell) | 0) * 4096 + ((y / cell) | 0);
if (taken.has(key)) continue;
taken.add(key);
if (rnd() < 0.14) continue; // bald patches
// outward normal from the local mass
let nx = 0;
let ny = 0;
for (let dy = -4; dy <= 4; dy++) {
for (let dx = -4; dx <= 4; dx++) {
if (inside(x + dx, y + dy)) {
nx -= dx;
ny -= dy;
}
}
}
const nl = Math.hypot(nx, ny);
if (nl < 1e-3) continue;
nx /= nl;
ny /= nl;
// raycast: how far could this spike reach before more letter?
let budget = 150;
for (let s = 4; s < 150; s += 2) {
if (inside(x + nx * s, y + ny * s)) {
budget = s - 3;
break;
}
}
const want = 7 + 118 * rnd() ** 2.6;
const len = Math.max(4, Math.min(want, budget * 0.85));
const slack = Math.max(0, Math.min(len * 1.1, budget - len));
// the spine wanders; walls taper with a power above 1
const segs = 7;
const spine: [number, number][] = [[0, 0]];
let a = 0;
let px = 0;
let py = 0;
for (let i = 1; i <= segs; i++) {
a += (rnd() - 0.5) * 0.42;
px += (Math.cos(a) * len) / segs;
py += (Math.sin(a) * len) / segs;
spine.push([px, py]);
}
const w0 = 3.4 + len * 0.11;
const out: [number, number][] = [];
const back: [number, number][] = [];
for (let i = 0; i <= segs; i++) {
const s = i / segs;
const half = w0 * (1 - s) ** 1.8;
const [ax, ay] = spine[Math.max(0, i - 1)];
const [bx, by] = spine[Math.min(segs, i + 1)];
let tx = bx - ax;
let ty = by - ay;
const tl = Math.hypot(tx, ty) || 1;
tx /= tl;
ty /= tl;
out.push([spine[i][0] - ty * half, spine[i][1] + tx * half]);
back.push([spine[i][0] + ty * half, spine[i][1] - tx * half]);
}
const path = new Path2D();
path.moveTo(out[0][0] - 2, out[0][1]);
for (const [qx, qy] of out) path.lineTo(qx, qy);
for (let i = back.length - 1; i >= 0; i--) path.lineTo(back[i][0], back[i][1]);
path.lineTo(back[0][0] - 2, back[0][1]);
path.closePath();
const lenK = len / 120;
spikes.push({
x,
y,
base: Math.atan2(ny, nx) + (rnd() - 0.5) * 0.5,
len,
slack,
path,
phase: rnd() * Math.PI * 2,
bend: 0,
vel: 0,
grow: 1,
stiff: 90 - 70 * lenK,
damp: 13 - 9 * lenK,
});
}
// long ones first so short stubble sits on top near the root
spikes.sort((A, B) => B.len - A.len);
return spikes;
}
function createScene(): Scene {
let spikes: Spike[] | null = null;
return {
frame({ ctx, w, h, dpr, t, dt, p }) {
if (!spikes) spikes = build();
ctx.fillStyle = GROUND;
ctx.fillRect(0, 0, w, h);
const S = Math.min(w / DW, h / DH) * 1.05;
const ox = (w - DW * S) / 2;
const oy = (h - DH * S) / 2;
const lx = (p.x - ox) / S;
const ly = (p.y - oy) / S;
const reach = 210;
const step = Math.min(dt, 1 / 30);
ctx.fillStyle = GROUND;
ctx.strokeStyle = LEAF;
ctx.lineJoin = "round";
ctx.lineWidth = 1.9;
for (const s of spikes) {
let target = 0;
let growT = 1;
if (p.inside) {
const dx = s.x - lx;
const dy = s.y - ly;
const d = Math.hypot(dx, dy);
if (d < reach) {
const f = (1 - d / reach) ** 2;
// rotate the spike away from the pointer
const away = Math.atan2(dy, dx);
let diff = away - s.base;
while (diff > Math.PI) diff -= Math.PI * 2;
while (diff < -Math.PI) diff += Math.PI * 2;
target = clamp(diff, -0.9, 0.9) * f * 0.9;
growT = 1 + (s.slack / s.len) * f;
}
}
// spring with stiffness and damping both driven by length
s.vel += ((target - s.bend) * s.stiff - s.vel * s.damp) * step;
s.bend += s.vel * step;
s.grow += (growT - s.grow) * (1 - Math.exp(-6 * step));
const breathe = 0.06 * Math.sin(t * 0.9 + s.phase) * Math.min(1, s.len / 40);
const ang = s.base + s.bend + breathe;
const c = Math.cos(ang);
const sn = Math.sin(ang);
const g = s.grow;
ctx.setTransform(c * g * S * dpr, sn * g * S * dpr, -sn * S * dpr, c * S * dpr, (ox + s.x * S) * dpr, (oy + s.y * S) * dpr);
ctx.fill(s.path);
ctx.stroke(s.path);
}
// the letters, knocked out in cream, edge stroked
ctx.setTransform(S * dpr, 0, 0, S * dpr, ox * dpr, oy * dpr);
ctx.font = FONT;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.lineWidth = 3;
ctx.strokeStyle = LEAF;
ctx.strokeText(WORD, DW / 2, DH / 2 + 10);
ctx.fillStyle = CREAM;
ctx.fillText(WORD, DW / 2, DH / 2 + 10);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
},
};
}
export default function MangoSpikes() {
const ref = useStage(createScene, 1);
return <div ref={ref} className="relative h-full w-full overflow-hidden bg-[#ffd21a]" />;
}
Prompt
Build "Bristling Spiky Word", a text animation. What it does: The word mango knocked out in cream from a yellow ground and bristling with hundreds of hand-drawn green-outlined spikes that breathe slowly and bend away from the pointer. 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).