Code — how this page is built

Under the hood

The actual source of the components on this page, read straight from the repo. Copy it, read it, or open it on GitHub.


TextParticlesGameview on GitHub ↗

Renders the word offscreen, samples filled pixels as particle targets, and springs each particle toward its target every frame; clicking adds scatter velocity.

'use client';

import * as React from 'react';

const W = 600;
const H = 240;

interface P {
  x: number;
  y: number;
  tx: number;
  ty: number;
  vx: number;
  vy: number;
}

export function TextParticlesGame() {
  const canvasRef = React.useRef<HTMLCanvasElement>(null);
  const parts = React.useRef<P[]>([]);
  const [text, setText] = React.useState('hello');

  // Recompute targets when the text changes.
  React.useEffect(() => {
    const off = document.createElement('canvas');
    off.width = W;
    off.height = H;
    const octx = off.getContext('2d');
    if (!octx) return;
    octx.fillStyle = '#000';
    octx.textAlign = 'center';
    octx.textBaseline = 'middle';
    const size = Math.min(160, (W * 1.4) / Math.max(text.length, 1));
    octx.font = `700 ${size}px "Helvetica Neue", Arial, sans-serif`;
    octx.fillText(text || ' ', W / 2, H / 2);
    const data = octx.getImageData(0, 0, W, H).data;
    const targets: { x: number; y: number }[] = [];
    const step = 5;
    for (let y = 0; y < H; y += step)
      for (let x = 0; x < W; x += step) if (data[(y * W + x) * 4 + 3] > 128) targets.push({ x, y });

    const pool = parts.current;
    parts.current = targets.map((t, i) => {
      const ex = pool[i];
      return {
        x: ex ? ex.x : Math.random() * W,
        y: ex ? ex.y : Math.random() * H,
        tx: t.x,
        ty: t.y,
        vx: 0,
        vy: 0,
      };
    });
  }, [text]);

  React.useEffect(() => {
    const ctx = canvasRef.current?.getContext('2d');
    if (!ctx) return;
    let raf = 0;
    const tick = () => {
      ctx.clearRect(0, 0, W, H);
      ctx.fillStyle = '#14110b';
      for (const p of parts.current) {
        p.vx = (p.vx + (p.tx - p.x) * 0.02) * 0.86;
        p.vy = (p.vy + (p.ty - p.y) * 0.02) * 0.86;
        p.x += p.vx;
        p.y += p.vy;
        ctx.fillRect(p.x, p.y, 2.4, 2.4);
      }
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => cancelAnimationFrame(raf);
  }, []);

  const scatter = () => {
    for (const p of parts.current) {
      p.vx += (Math.random() - 0.5) * 40;
      p.vy += (Math.random() - 0.5) * 40;
    }
  };

  return (
    <div className="flex w-full max-w-[640px] flex-col items-center gap-5">
      <canvas
        ref={canvasRef}
        width={W}
        height={H}
        onClick={scatter}
        className="w-full max-w-full cursor-pointer rounded-md border border-foreground/15"
      />
      <input
        value={text}
        maxLength={14}
        onChange={(e) => setText(e.target.value)}
        spellCheck={false}
        placeholder="type a word…"
        className="w-full max-w-xs border-b border-foreground/30 bg-transparent px-1 py-2 text-center font-mono text-sm outline-none placeholder:opacity-40"
      />
      <div className="font-mono text-[11px] uppercase tracking-[0.12em] opacity-45">
        click the canvas to scatter — it reforms
      </div>
    </div>
  );
}
↖ kaspirius