Baktiyar Ablaikhan

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