world-cup formtype below ↓

Type a word and it re-tiles as you go, every tile a World Cup Trionda ball. A slow wave rolls across and lights them as it passes, and your cursor pulls a little glow along with it.

It's just canvas underneath: one engine file, a thin React wrapper, and a small config. I pull the letters off a hidden text canvas and oversample them so the edges don't go jaggy, and the tiles size themselves to the font so a long word still fits the frame.

// Framework-agnostic canvas engine.
//
// Renders a SOURCE — a text wordmark OR an image — as a dense grid of square
// tiles. A slow morphing colour wave wanders across and lights tiles up; the
// pointer brushes a soft morphing light blob with occasional sparks.
//
// Two colour modes:
//   • brand-hue   — tiles rest as a muted grey and glow in a single brand hue.
//                   This is the original wordmark look; it's the only mode for text.
//   • from-image  — each tile samples its colour from the source image, so the
//                   wave + pointer read as light sweeping across a mosaic. Great
//                   for photos / logos (e.g. the World Cup Trionda ball).
//
// Mount: `const field = new TileField(hostEl, opts); field.start()`.
// Drive resize from a ResizeObserver on the host; call `field.destroy()` to tear down.

const TAU = Math.PI * 2;

export type TextSource = {
  kind: "text";
  word: string;
  /** returns a full CSS font shorthand for a given pixel size */
  font?: (px: number) => string;
  /** letter-spacing as a fraction of font size (Apple-ish tight tracking is negative) */
  letterTrack?: number;
  /** 0..1 — how much of the width the wordmark should fill */
  fillFrac?: number;
  /**
   * Sample each tile's colour from this image, mapped to cover the wordmark's
   * bounds. The text decides which tiles exist; the image decides their colour —
   * so a typed word reads "in world-cup form" (e.g. the Trionda ball's colours).
   * Same-origin (or CORS) required — it reads pixels via getImageData.
   */
  colorImage?: string;
  /**
   * Draw this image *as* every tile — each "pixel" of the wordmark is a little
   * copy of the picture (e.g. the World Cup Trionda ball). The wave and pointer swell
   * and light them. Takes precedence over `colorImage`.
   */
  tileImage?: string;
};

export type ImageSource = {
  kind: "image";
  /** any same-origin image URL (cross-origin needs CORS for pixel sampling) */
  src: string;
  /** true → mosaic of the image's own colours; false → brand-hue silhouette */
  colorFromImage?: boolean;
  /** 0..1 — fraction of min(width,height) the image should occupy */
  footprint?: number;
  /** 0..1 — opacity cutoff deciding whether a sampled cell becomes a tile */
  alphaThreshold?: number;
};

export type TileFieldSource = TextSource | ImageSource;

export type TileFieldOptions = {
  source?: TileFieldSource;
  /** hue (deg) used in brand-hue mode */
  brandHue?: number;
  /** tiles per screen width, roughly — smaller = chunkier tiles */
  density?: number;
  /**
   * 0..1 resting brightness in colour mode. The default (REST_A) is a dim
   * "dark LED panel" the light reveals; raise it so a typed wordmark stays
   * legible at rest with the wave + pointer only adding shimmer.
   */
  restBrightness?: number;
};

const DEFAULT_SOURCE: ImageSource = {
  kind: "image",
  src: "/tile-field/trionda.png",
  colorFromImage: true,
  footprint: 0.72,
  alphaThreshold: 0.45,
};

const REST = "oklch(0.64 0.006 263)"; // resting grey (brand-hue mode)
const GLOW_LCH = "0.42 0.07 263"; // pointer glow (brand-hue mode)
const REST_A = 0.2; // resting brightness (from-image mode)
const MAXSZ = 0.96; // max tile size as a fraction of a cell
const SPEED = 0.02; // colour-wave time step per frame

// sprite mode (each tile is a little copy of an image, e.g. the ball)
const SPRITE_PX = 256; // offscreen sprite resolution (source ball baked once)
const SPRITE_REST = 0.82; // ball size (fraction of cell) at rest — reads as a field
const SPRITE_MAX = 1.55; // ball size on a wave crest / under the pointer (overlaps)

// per-cell supersampling: average an SS×SS grid of alpha samples into a
// coverage value (0..1) instead of one hard center sample — smooths the
// letter edges and stops them shimmering as the word re-rasterises.
const SS = 4;

// text mode: tile size as a fraction of the font size. Tying the ball to the
// letter (not the viewport) keeps spacing/resolution constant as the word
// lengthens — long words get smaller balls instead of cramming big ones.
const TEXT_CELL_FRAC = 0.06;

const HUE_STEPS = 24; // brand-hue mode: hue buckets for batched fills
const ALPHA_STEPS = 6; // brand-hue mode: alpha buckets
const BR_STEPS = 10; // from-image mode: brightness buckets
const QLEV = 6; // from-image mode: colour quantisation levels per channel

const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
const smoothstep = (x: number, e0: number, e1: number) => {
  const t = clamp((x - e0) / (e1 - e0), 0, 1);
  return t * t * (3 - 2 * t);
};
function hash(x: number, y: number) {
  const r = Math.sin(x * 127.1 + y * 311.7) * 43758.5453;
  return r - Math.floor(r);
}

export class TileField {
  private host: HTMLElement;
  private canvas: HTMLCanvasElement;
  private ctx: CanvasRenderingContext2D;
  private reduced: boolean;

  private source: TileFieldSource;
  private brandHue: number;
  private density: number;
  private colorMode: boolean; // true = tiles take colours from an image
  private spriteMode: boolean; // true = each tile is a copy of an image
  private sprite: HTMLCanvasElement | null = null; // pre-rendered ball sprite
  private glow: HTMLCanvasElement | null = null; // pre-rendered round light
  private restA: number; // resting brightness in colour mode
  private destroyed = false; // guards a deferred image load from resurrecting rAF

  private dpr = Math.min(2, window.devicePixelRatio || 1);
  private viewW = 0;
  private viewH = 0;
  private cell = 10;
  private time = 0;

  private n = 0;
  private px = new Float32Array(0);
  private py = new Float32Array(0);
  private lit = new Float32Array(0);
  private seed = new Float32Array(0);
  private cov = new Float32Array(0); // per-cell coverage 0..1 — soft edge AA
  private spark = new Uint8Array(0);
  private hueSeed = new Float32Array(0); // brand-hue mode
  private pal = new Uint16Array(0); // from-image mode: palette index
  private palList: string[] = []; // from-image mode: "r,g,b"

  private hasPointer = false;
  private rawX = 0;
  private rawY = 0;
  private lightX = 0;
  private lightY = 0;
  private lightSpeed = 0;

  private raf = 0;

  private img: HTMLImageElement | null = null;
  private ready = false;

  private stepL = new Float32Array(HUE_STEPS);
  private stepC = new Float32Array(HUE_STEPS);

  constructor(host: HTMLElement, opts: TileFieldOptions = {}) {
    this.host = host;
    this.source = opts.source ?? DEFAULT_SOURCE;
    this.brandHue = opts.brandHue ?? 263;
    this.density = opts.density ?? 440;
    this.restA = opts.restBrightness ?? REST_A;
    this.spriteMode = this.source.kind === "text" && !!this.source.tileImage;
    this.colorMode = this.spriteMode
      ? false
      : this.source.kind === "image"
        ? this.source.colorFromImage !== false
        : !!this.source.colorImage;

    this.canvas = document.createElement("canvas");
    this.canvas.style.cssText =
      "position:absolute;inset:0;display:block;width:100%;height:100%;pointer-events:none";
    const ctx = this.canvas.getContext("2d");
    if (!ctx) throw new Error("TileField: 2D canvas context unavailable");
    this.ctx = ctx;
    this.host.appendChild(this.canvas);

    this.reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    window.addEventListener("pointermove", this.onMove, { passive: true });
    window.addEventListener("pointerleave", this.onLeave);

    // An image supplies tile colours for image sources and for text sources
    // that opt into `colorImage`. Silhouette mode (colorFromImage: false) still
    // needs the image for its alpha shape, so it loads too.
    const colorImgUrl =
      this.source.kind === "image"
        ? this.source.src
        : this.source.tileImage ?? this.source.colorImage ?? null;

    if (colorImgUrl) {
      const img = new Image();
      const boot = () => {
        if (this.destroyed) return; // unmounted while the image was loading
        this.ready = true;
        this.resize();
        this.start();
      };
      img.onload = () => {
        this.img = img;
        if (this.spriteMode) this.buildSprite();
        boot();
      };
      img.onerror = () => {
        // The image failed (404 / offline / CORS). Sprite and colour modes need
        // its pixels, so fall back to a plain brand-hue wordmark rather than a
        // blank canvas — text sources still have their letters to draw.
        if (this.source.kind === "text") {
          this.spriteMode = false;
          this.colorMode = false;
        }
        boot();
      };
      img.src = colorImgUrl;
    } else {
      this.ready = true;
      this.resize();
    }
  }

  resize() {
    if (!this.ready) return;
    const rect = this.host.getBoundingClientRect();
    this.viewW = rect.width;
    this.viewH = rect.height;
    this.dpr = Math.min(2, window.devicePixelRatio || 1);
    this.cell = Math.max(2, Math.round(this.viewW / this.density));
    const { viewW, viewH, ctx, canvas } = this;
    if (viewW < 1 || viewH < 1) return;

    canvas.style.width = Math.round(viewW) + "px";
    canvas.style.height = Math.round(viewH) + "px";
    canvas.width = Math.ceil(viewW * this.dpr);
    canvas.height = Math.ceil(viewH * this.dpr);
    ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0);
    ctx.imageSmoothingEnabled = false;

    // paint the source onto an offscreen canvas, then sample its pixels
    const sc = document.createElement("canvas");
    sc.width = Math.max(1, Math.floor(viewW));
    sc.height = Math.max(1, Math.floor(viewH));
    const s = sc.getContext("2d");
    if (!s) return;

    let alphaCut = 0.5;
    if (this.source.kind === "text") {
      const src = this.source;
      const track = src.letterTrack ?? -0.02;
      const fillFrac = src.fillFrac ?? 0.92;
      const fontOf =
        src.font ?? ((px: number) => `600 ${px}px ui-sans-serif, system-ui, Arial, sans-serif`);
      const sls = s as CanvasRenderingContext2D & { letterSpacing?: string };
      s.fillStyle = "#000";
      s.textAlign = "center";
      s.textBaseline = "middle";
      let fs = viewH * 0.5;
      sls.letterSpacing = `${track * fs}px`;
      s.font = fontOf(fs);
      const measured = s.measureText(src.word).width || 1;
      fs *= (viewW * fillFrac) / measured;
      fs = Math.min(fs, viewH * 0.62); // don't let short words clip vertically
      sls.letterSpacing = `${track * fs}px`;
      s.font = fontOf(fs);
      s.fillText(src.word, viewW / 2, viewH / 2);
      // ball size follows the letter size, capped by density — a longer word
      // (smaller letters) gets proportionally smaller balls, so the spacing
      // stays open instead of cramming fixed-size balls together.
      this.cell = Math.max(3, Math.min(this.cell, Math.round(fs * TEXT_CELL_FRAC)));
    } else if (this.img) {
      const src = this.source as ImageSource;
      alphaCut = src.alphaThreshold ?? 0.45;
      s.imageSmoothingEnabled = true;
      const iw = this.img.naturalWidth;
      const ih = this.img.naturalHeight;
      const fill = Math.min(viewW, viewH) * (src.footprint ?? 0.72);
      const scale = fill / Math.max(iw, ih);
      const dw = iw * scale;
      const dh = ih * scale;
      s.drawImage(this.img, (viewW - dw) / 2, (viewH - dh) / 2, dw, dh);
    }

    const data = s.getImageData(0, 0, sc.width, sc.height).data;
    const cell = this.cell; // may have been re-derived from the font size above
    const cols = Math.ceil(viewW / cell);
    const rows = Math.ceil(viewH / cell);
    const xs: number[] = [];
    const ys: number[] = [];
    const sp: number[] = [];
    const sd: number[] = [];
    const hs: number[] = [];
    const pl: number[] = [];
    const cv: number[] = [];
    this.palList = [];

    // pass 1 — tile positions from the source's alpha, plus the drawn bounds.
    // Instead of one hard center sample, average an SS×SS grid of samples into a
    // coverage value: interior cells read ~1, edge cells read a fraction. Sprite
    // mode keeps the faint edge cells and fades/shrinks them for a clean edge;
    // other modes keep their original threshold on the (now smoother) coverage.
    const keepCut = this.spriteMode ? 0.06 : alphaCut;
    const step = cell / SS;
    let minX = Infinity;
    let minY = Infinity;
    let maxX = -Infinity;
    let maxY = -Infinity;
    for (let r = 0; r < rows; r++) {
      for (let c = 0; c < cols; c++) {
        const lx = Math.floor(c * cell + cell / 2);
        const ly = Math.floor(r * cell + cell / 2);
        if (lx >= sc.width || ly >= sc.height) continue;
        let sum = 0;
        let cnt = 0;
        for (let sj = 0; sj < SS; sj++) {
          const sy = Math.floor(r * cell + (sj + 0.5) * step);
          if (sy >= sc.height) continue;
          for (let si = 0; si < SS; si++) {
            const sx = Math.floor(c * cell + (si + 0.5) * step);
            if (sx >= sc.width) continue;
            sum += (data[(sy * sc.width + sx) * 4 + 3] ?? 0) / 255;
            cnt++;
          }
        }
        const coverage = cnt ? sum / cnt : 0;
        if (coverage <= keepCut) continue;
        xs.push(lx);
        ys.push(ly);
        cv.push(coverage);
        sp.push(hash(lx + 7, ly - 3) > 0.86 ? 1 : 0);
        sd.push(hash(lx * 1.3, ly * 0.7));
        if (lx < minX) minX = lx;
        if (ly < minY) minY = ly;
        if (lx > maxX) maxX = lx;
        if (ly > maxY) maxY = ly;
      }
    }

    // pass 2 — colours. Image sources sample themselves; a text source with a
    // colorImage samples that image drawn to *cover the wordmark's bounds*, so
    // the ball's full colour range spreads across the letters.
    if (this.colorMode) {
      let colorData = data;
      let colorW = sc.width;
      if (this.source.kind === "text" && this.img && xs.length) {
        const cc = document.createElement("canvas");
        cc.width = sc.width;
        cc.height = sc.height;
        const cctx = cc.getContext("2d");
        if (cctx) {
          cctx.imageSmoothingEnabled = true;
          const bw = Math.max(1, maxX - minX);
          const bh = Math.max(1, maxY - minY);
          const iw = this.img.naturalWidth;
          const ih = this.img.naturalHeight;
          const scale = Math.max(bw / iw, bh / ih) * 1.05; // cover + a little
          const dw = iw * scale;
          const dh = ih * scale;
          const cx = (minX + maxX) / 2;
          const cy = (minY + maxY) / 2;
          cctx.drawImage(this.img, cx - dw / 2, cy - dh / 2, dw, dh);
          colorData = cctx.getImageData(0, 0, cc.width, cc.height).data;
          colorW = cc.width;
        }
      }
      const q = 255 / (QLEV - 1);
      const palMap = new Map<number, number>();
      for (let i = 0; i < xs.length; i++) {
        const idx = (ys[i] * colorW + xs[i]) * 4;
        const ca = (colorData[idx + 3] ?? 0) / 255;
        let rr: number;
        let gg: number;
        let bb: number;
        if (ca < 0.35) {
          // transparent (e.g. a ball corner) — warm off-white so the letter
          // stays bright rather than dropping to black
          rr = 236;
          gg = 234;
          bb = 228;
        } else {
          rr = Math.round(Math.round((colorData[idx] ?? 0) / q) * q);
          gg = Math.round(Math.round((colorData[idx + 1] ?? 0) / q) * q);
          bb = Math.round(Math.round((colorData[idx + 2] ?? 0) / q) * q);
        }
        const key = (rr << 16) | (gg << 8) | bb;
        let pi = palMap.get(key);
        if (pi === undefined) {
          pi = this.palList.length;
          palMap.set(key, pi);
          this.palList.push(`${rr},${gg},${bb}`);
        }
        pl.push(pi);
      }
    } else {
      for (let i = 0; i < xs.length; i++) {
        hs.push(hash(xs[i] * 0.7 + 11, ys[i] * 1.9 - 5));
      }
    }

    this.n = xs.length;
    this.px = new Float32Array(xs);
    this.py = new Float32Array(ys);
    this.cov = new Float32Array(cv);
    this.spark = new Uint8Array(sp);
    this.seed = new Float32Array(sd);
    this.hueSeed = new Float32Array(hs);
    this.pal = new Uint16Array(pl);
    this.lit = new Float32Array(this.n);

    if (this.reduced) this.renderStatic();
  }

  private distSegSq(
    qx: number,
    qy: number,
    ax: number,
    ay: number,
    bx: number,
    by: number,
  ) {
    const dx = bx - ax;
    const dy = by - ay;
    if (dx === 0 && dy === 0) {
      const ex = qx - ax;
      const ey = qy - ay;
      return ex * ex + ey * ey;
    }
    const t = clamp(((qx - ax) * dx + (qy - ay) * dy) / (dx * dx + dy * dy), 0, 1);
    const ex = qx - (ax + dx * t);
    const ey = qy - (ay + dy * t);
    return ex * ex + ey * ey;
  }

  private frame = (t: number) => {
    if (this.spriteMode) this.frameSprite(t);
    else if (this.colorMode) this.frameColor(t);
    else this.frameBrand(t);
    this.raf = requestAnimationFrame(this.frame);
  };

  /** Pre-render the tile image (the ball) into a small, smooth, cached sprite. */
  private buildSprite() {
    if (!this.img) return;
    const s = document.createElement("canvas");
    s.width = SPRITE_PX;
    s.height = SPRITE_PX;
    const sctx = s.getContext("2d");
    if (!sctx) return;
    sctx.imageSmoothingEnabled = true;
    const iw = this.img.naturalWidth || 1;
    const ih = this.img.naturalHeight || 1;
    const scale = SPRITE_PX / Math.max(iw, ih); // contain, keep aspect
    const dw = iw * scale;
    const dh = ih * scale;
    sctx.drawImage(this.img, (SPRITE_PX - dw) / 2, (SPRITE_PX - dh) / 2, dw, dh);
    this.sprite = s;

    // a soft, ROUND white light — drawn additively over lit balls so the
    // highlight matches the ball instead of a square poking past its edge
    const GP = 64;
    const g = document.createElement("canvas");
    g.width = GP;
    g.height = GP;
    const gctx = g.getContext("2d");
    if (gctx) {
      const grad = gctx.createRadialGradient(GP / 2, GP / 2, 0, GP / 2, GP / 2, GP / 2);
      grad.addColorStop(0, "rgba(255,255,255,1)");
      grad.addColorStop(0.45, "rgba(255,255,255,0.32)");
      grad.addColorStop(1, "rgba(255,255,255,0)");
      gctx.fillStyle = grad;
      gctx.fillRect(0, 0, GP, GP);
      this.glow = g;
    }
  }

  /** Sprite mode — every tile is a little copy of the ball; the wave + pointer
   *  swell them and add a soft light with the odd spark. */
  private frameSprite(t: number) {
    const ctx = this.ctx;
    const { viewW, viewH, cell, n, px, py, cov, spark, lit, seed } = this;
    ctx.clearRect(0, 0, viewW, viewH);
    this.time += SPEED;
    const time = this.time;
    const T = time;
    const L = this.tickLight();
    const sprite = this.sprite;
    if (!sprite) return; // still loading

    ctx.imageSmoothingEnabled = true; // keep the little balls round

    const litList: number[] = [];
    for (let i = 0; i < n; i++) {
      const x = px[i];
      const y = py[i];
      let target = 0;
      if (L.moving && x >= L.minX && x <= L.maxX && y >= L.minY && y <= L.maxY) {
        const dSq = this.distSegSq(x, y, L.prevX, L.prevY, L.lightX, L.lightY);
        if (dSq <= L.influenceSq) {
          const ang = Math.atan2(y - L.lightY, x - L.lightX);
          const wobble =
            1 +
            0.3 * Math.sin(3 * ang + time * 1.6) +
            0.16 * Math.sin(5 * ang - time * 1.1 + 1.3);
          const f = clamp(1 - Math.sqrt(dSq) / (L.reach * wobble), 0, 1);
          target = f * f * (3 - 2 * f);
        }
      }
      lit[i] += (target - lit[i]) * (target > lit[i] ? 0.24 : 0.02);

      const wave = smoothstep(this.waveAt(x, y, T), 0.1, 1.6);
      const energy = Math.max(wave, lit[i]);
      const breathe = 0.5 + 0.5 * Math.sin(seed[i] * TAU + time * 1.3);
      // coverage → soft edge: interior cells (cov ≈ 1) draw a full ball; the
      // fractional edge cells draw a smaller, fainter ball, anti-aliasing the
      // wordmark's silhouette instead of a hard staircase.
      const edge = smoothstep(cov[i], 0.08, 0.72);
      const sz =
        cell *
        (SPRITE_REST + 0.06 * breathe + (SPRITE_MAX - SPRITE_REST) * energy) *
        (0.52 + 0.48 * edge);
      const h = sz / 2;

      ctx.globalAlpha = (0.7 + 0.3 * energy) * (0.32 + 0.68 * edge);
      ctx.drawImage(sprite, x - h, y - h, sz, sz);

      if (lit[i] > 0.03) litList.push(i);
    }
    ctx.globalAlpha = 1;

    // additive ROUND light + sparks where the pointer passed — a radial glow
    // sprite, so the highlight is a circle that matches the ball rather than a
    // square whose corners poke past it. Scaled by coverage so faint edge balls
    // don't pick up a full glow.
    const glow = this.glow;
    if (glow) {
      ctx.globalCompositeOperation = "lighter";
      for (const i of litList) {
        const amt = lit[i];
        const x = px[i];
        const y = py[i];
        const edge = smoothstep(cov[i], 0.08, 0.72);
        if (spark[i]) {
          const ph = seed[i];
          const tt = 0.00025 * t;
          const amp = (0.45 + ph) * cell * 0.3;
          const jx = x + Math.sin(0.05 * x + 1.3 * tt + ph * TAU) * amp;
          const jy = y + Math.cos(0.04 * y - 0.9 * tt + ph * TAU) * amp;
          const gs = cell * (0.5 + 0.4 * amt);
          ctx.globalAlpha = Math.min(1, 0.9 * amt * edge);
          ctx.drawImage(glow, jx - gs / 2, jy - gs / 2, gs, gs);
        } else {
          const gs = cell * (0.85 + 0.5 * amt);
          ctx.globalAlpha = Math.min(1, 0.22 * amt * edge);
          ctx.drawImage(glow, x - gs / 2, y - gs / 2, gs, gs);
        }
      }
      ctx.globalAlpha = 1;
      ctx.globalCompositeOperation = "source-over";
    }
  }

  /** shared per-frame pointer-light bookkeeping */
  private tickLight() {
    const prevX = this.lightX;
    const prevY = this.lightY;
    if (this.hasPointer) {
      this.lightX += (this.rawX - this.lightX) * 0.5;
      this.lightY += (this.rawY - this.lightY) * 0.5;
    }
    this.lightSpeed =
      0.9 * this.lightSpeed + 0.1 * Math.hypot(this.lightX - prevX, this.lightY - prevY);
    const reach = clamp(
      this.viewH * 0.2 + this.lightSpeed * 0.9,
      this.viewH * 0.16,
      this.viewH * 0.42,
    );
    const influence = 1.5 * reach;
    return {
      prevX,
      prevY,
      lightX: this.lightX,
      lightY: this.lightY,
      moving: this.hasPointer,
      reach,
      influence,
      influenceSq: influence * influence,
      minX: Math.min(prevX, this.lightX) - influence,
      maxX: Math.max(prevX, this.lightX) + influence,
      minY: Math.min(prevY, this.lightY) - influence,
      maxY: Math.max(prevY, this.lightY) + influence,
    };
  }

  private waveAt(x: number, y: number, T: number) {
    const u = x / Math.max(this.viewW, 1);
    const v = y / Math.max(this.viewH, 1);
    return (
      Math.sin((u * 1.6 + 0.4 * Math.sin(T * 0.3)) * TAU + T * 0.8) +
      0.7 * Math.sin((v * 2.1 - u * 0.9) * TAU - T * 0.6 + 1.7) +
      0.5 * Math.sin((u * 3.3 + v * 2.7) * TAU + T * 0.4 + 4.2) +
      0.4 * Math.cos((v * 1.3 - 1.1 * Math.sin(T * 0.2)) * TAU - T * 0.5)
    );
  }

  private frameBrand(t: number) {
    const ctx = this.ctx;
    const { viewW, viewH, cell, n, px, py, hueSeed, spark, lit, seed } = this;
    ctx.clearRect(0, 0, viewW, viewH);
    this.time += SPEED;
    const time = this.time;
    const T = time;
    const L = this.tickLight();

    const grayP = new Path2D();
    const litList: number[] = [];
    const buckets: Path2D[][] = Array.from({ length: HUE_STEPS }, () =>
      Array.from({ length: ALPHA_STEPS }, () => new Path2D()),
    );
    const hueOfStep = new Float32Array(HUE_STEPS);

    for (let i = 0; i < n; i++) {
      const x = px[i];
      const y = py[i];
      let target = 0;
      if (L.moving && x >= L.minX && x <= L.maxX && y >= L.minY && y <= L.maxY) {
        const dSq = this.distSegSq(x, y, L.prevX, L.prevY, L.lightX, L.lightY);
        if (dSq <= L.influenceSq) {
          const ang = Math.atan2(y - L.lightY, x - L.lightX);
          const wobble =
            1 +
            0.3 * Math.sin(3 * ang + time * 1.6) +
            0.16 * Math.sin(5 * ang - time * 1.1 + 1.3);
          const f = clamp(1 - Math.sqrt(dSq) / (L.reach * wobble), 0, 1);
          target = f * f * (3 - 2 * f);
        }
      }
      lit[i] += (target - lit[i]) * (target > lit[i] ? 0.24 : 0.02);

      const flow = this.waveAt(x, y, T);
      let colorAmt = smoothstep(flow, 0.1, 1.6);
      colorAmt = Math.max(colorAmt, lit[i]);

      const breathe = 0.5 + 0.5 * Math.sin(seed[i] * TAU + time * 1.3);
      const base = 0.22 + 0.1 * breathe;
      const sz = cell * (base + (MAXSZ - base) * colorAmt);
      const h = sz / 2;
      grayP.rect(x - h, y - h, sz, sz);

      if (colorAmt > 0.04) {
        const drift = (flow - 0.8) * 16;
        const hue = this.brandHue + drift + (hueSeed[i] - 0.5) * 8;
        const hStep = ((Math.round((hue / 360) * HUE_STEPS) % HUE_STEPS) + HUE_STEPS) % HUE_STEPS;
        hueOfStep[hStep] = hue;
        const as = Math.min(ALPHA_STEPS - 1, Math.floor(colorAmt * ALPHA_STEPS));
        buckets[hStep][as].rect(x - h, y - h, sz, sz);
        this.stepL[hStep] = 0.5;
        this.stepC[hStep] = 0.085;
      }
      if (lit[i] > 0.02) litList.push(i);
    }

    ctx.fillStyle = REST;
    ctx.fill(grayP);

    for (let hi = 0; hi < HUE_STEPS; hi++) {
      const hue = hueOfStep[hi];
      for (let as = 0; as < ALPHA_STEPS; as++) {
        ctx.globalAlpha = (as + 1) / ALPHA_STEPS;
        ctx.fillStyle = `oklch(${this.stepL[hi]} ${this.stepC[hi]} ${hue})`;
        ctx.fill(buckets[hi][as]);
      }
    }
    ctx.globalAlpha = 1;

    for (const i of litList) {
      const amt = lit[i];
      const x = px[i];
      const y = py[i];
      const gsz = cell * (0.7 + (MAXSZ - 0.7) * amt);
      const gh = gsz / 2;
      if (spark[i]) {
        const ph = seed[i];
        const tt = 0.00025 * t;
        const amp = (0.45 + ph) * cell * 0.28;
        const jx = x + Math.sin(0.05 * x + 1.3 * tt + ph * TAU) * amp;
        const jy = y + Math.cos(0.04 * y - 0.9 * tt + ph * TAU) * amp;
        ctx.fillStyle = `rgba(70,100,180,${(0.1 * amt).toFixed(3)})`;
        ctx.fillRect(jx - gh * 1.5, jy - gh * 1.5, gsz * 1.5, gsz * 1.5);
        ctx.fillStyle = `rgba(70,100,180,${(0.55 * amt).toFixed(3)})`;
        ctx.fillRect(jx - gh, jy - gh, gsz, gsz);
      } else {
        ctx.fillStyle = `oklch(${GLOW_LCH} / ${(0.85 * amt).toFixed(3)})`;
        ctx.fillRect(x - gh, y - gh, gsz, gsz);
      }
    }
  }

  private frameColor(t: number) {
    const ctx = this.ctx;
    const { viewW, viewH, cell, n, px, py, pal, spark, lit, seed } = this;
    ctx.clearRect(0, 0, viewW, viewH);
    this.time += SPEED;
    const time = this.time;
    const T = time;
    const L = this.tickLight();

    const P = this.palList.length;
    const buckets: Array<Array<Path2D | undefined>> = Array.from({ length: P }, () =>
      new Array<Path2D | undefined>(BR_STEPS),
    );
    const litList: number[] = [];

    for (let i = 0; i < n; i++) {
      const x = px[i];
      const y = py[i];
      let target = 0;
      if (L.moving && x >= L.minX && x <= L.maxX && y >= L.minY && y <= L.maxY) {
        const dSq = this.distSegSq(x, y, L.prevX, L.prevY, L.lightX, L.lightY);
        if (dSq <= L.influenceSq) {
          const ang = Math.atan2(y - L.lightY, x - L.lightX);
          const wobble =
            1 +
            0.3 * Math.sin(3 * ang + time * 1.6) +
            0.16 * Math.sin(5 * ang - time * 1.1 + 1.3);
          const f = clamp(1 - Math.sqrt(dSq) / (L.reach * wobble), 0, 1);
          target = f * f * (3 - 2 * f);
        }
      }
      lit[i] += (target - lit[i]) * (target > lit[i] ? 0.24 : 0.02);

      const wave = smoothstep(this.waveAt(x, y, T), 0.1, 1.6);
      const energy = Math.max(wave, lit[i]);

      const breathe = 0.5 + 0.5 * Math.sin(seed[i] * TAU + time * 1.3);
      const sz = cell * (0.34 + 0.06 * breathe + (MAXSZ - 0.4) * energy);
      const h = sz / 2;

      const brightness = this.restA + (1 - this.restA) * energy;
      let bs = Math.floor(brightness * BR_STEPS);
      if (bs >= BR_STEPS) bs = BR_STEPS - 1;
      const row = buckets[pal[i]];
      let p = row[bs];
      if (!p) {
        p = new Path2D();
        row[bs] = p;
      }
      p.rect(x - h, y - h, sz, sz);

      if (lit[i] > 0.03) litList.push(i);
    }

    for (let pi = 0; pi < P; pi++) {
      const row = buckets[pi];
      const rgb = this.palList[pi];
      for (let bs = 0; bs < BR_STEPS; bs++) {
        const p = row[bs];
        if (!p) continue;
        ctx.globalAlpha = (bs + 1) / BR_STEPS;
        ctx.fillStyle = `rgb(${rgb})`;
        ctx.fill(p);
      }
    }
    ctx.globalAlpha = 1;

    // additive white shimmer + sparks where the pointer lit tiles
    ctx.globalCompositeOperation = "lighter";
    for (const i of litList) {
      const amt = lit[i];
      const x = px[i];
      const y = py[i];
      const gsz = cell * (0.6 + (MAXSZ - 0.6) * amt);
      const gh = gsz / 2;
      if (spark[i]) {
        const ph = seed[i];
        const tt = 0.00025 * t;
        const amp = (0.45 + ph) * cell * 0.3;
        const jx = x + Math.sin(0.05 * x + 1.3 * tt + ph * TAU) * amp;
        const jy = y + Math.cos(0.04 * y - 0.9 * tt + ph * TAU) * amp;
        ctx.fillStyle = `rgba(255,255,255,${(0.9 * amt).toFixed(3)})`;
        ctx.fillRect(jx - gh, jy - gh, gsz, gsz);
      } else {
        ctx.fillStyle = `rgba(255,255,255,${(0.22 * amt).toFixed(3)})`;
        ctx.fillRect(x - gh, y - gh, gsz, gsz);
      }
    }
    ctx.globalCompositeOperation = "source-over";
  }

  renderStatic() {
    const ctx = this.ctx;
    ctx.clearRect(0, 0, this.viewW, this.viewH);
    if (this.spriteMode && this.sprite) {
      ctx.imageSmoothingEnabled = true;
      for (let i = 0; i < this.n; i++) {
        const edge = smoothstep(this.cov[i], 0.08, 0.72);
        const s = this.cell * SPRITE_REST * (0.52 + 0.48 * edge);
        const hh = s / 2;
        ctx.globalAlpha = 0.32 + 0.68 * edge;
        ctx.drawImage(this.sprite, this.px[i] - hh, this.py[i] - hh, s, s);
      }
      ctx.globalAlpha = 1;
      return;
    }
    if (this.colorMode) {
      ctx.globalAlpha = Math.max(0.55, this.restA);
      for (let i = 0; i < this.n; i++) {
        const sz = this.cell * 0.5;
        const h = sz / 2;
        ctx.fillStyle = `rgb(${this.palList[this.pal[i]]})`;
        ctx.fillRect(this.px[i] - h, this.py[i] - h, sz, sz);
      }
      ctx.globalAlpha = 1;
    } else {
      const grayP = new Path2D();
      for (let i = 0; i < this.n; i++) {
        const sz = this.cell * 0.5;
        const h = sz / 2;
        grayP.rect(this.px[i] - h, this.py[i] - h, sz, sz);
      }
      ctx.fillStyle = REST;
      ctx.fill(grayP);
    }
  }

  /**
   * Live-update the wordmark (text sources only) — re-samples the letters into
   * tiles, re-derives ball colours, and keeps the animation running. Cheap
   * enough to call on every keystroke for short words.
   */
  setText(word: string) {
    if (this.source.kind !== "text") return;
    if (this.source.word === word) return;
    this.source = { ...this.source, word };
    this.resize();
    if (this.reduced) this.renderStatic();
  }

  private onMove = (e: PointerEvent) => {
    const rect = this.canvas.getBoundingClientRect();
    const x = e.clientX - rect.left;
    const y = e.clientY - rect.top;
    if (x >= -40 && y >= -40 && x <= rect.width + 40 && y <= rect.height + 40) {
      if (!this.hasPointer) {
        this.hasPointer = true;
        this.lightX = x;
        this.lightY = y;
        this.lightSpeed = 0;
      }
      this.rawX = x;
      this.rawY = y;
    } else {
      this.hasPointer = false;
    }
  };

  private onLeave = () => {
    this.hasPointer = false;
  };

  start() {
    if (this.destroyed) return;
    if (this.reduced) {
      this.renderStatic();
      return;
    }
    if (!this.raf) this.raf = requestAnimationFrame(this.frame);
  }

  stop() {
    if (this.raf) cancelAnimationFrame(this.raf);
    this.raf = 0;
  }

  destroy() {
    this.destroyed = true;
    this.stop();
    window.removeEventListener("pointermove", this.onMove);
    window.removeEventListener("pointerleave", this.onLeave);
    this.canvas.remove();
  }
}
No dependencies, drop the three files into any React project (Next, Vite, Astro) and add a square, transparent ball PNG. Or copy the prompt: it carries the brief and the full source, enough for a model to rebuild this in one go.

I draw your word onto an offscreen canvas, then slice it into a grid. Every filled cell gets a tiny copy of the World Cup Trionda ball, baked once into a sprite so stamping thousands of them stays cheap. Type anything and it comes out spelled in footballs.

A grid like this wants to staircase on every curve. So instead of one yes/no sample per cell, I take sixteen and average them: cells deep inside a letter come back full, cells clipping an edge come back partial. The partial ones shrink and fade, and the jaggies disappear. (Trick borrowed from Alex Harri’s ASCII-rendering write-up.)

Four sine waves drift across the field on their own clocks, swelling the balls wherever they crest. Nothing lines up, so it never repeats, the wordmark just keeps shifting. Your cursor drags a patch of light that reaches further the faster you move, and throws off the odd spark.

Jul 14, 2026Canvas · Wordmark · Motion · World Cup 26