# Particle Headline Assembling Into Letters

A shadcn/ui block from the [@weinc](https://we.inc/components?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/weinc/particle-headline-assembling-into-letters-canvas).

- Page, with a live preview: https://shadcn.exchange/r/weinc/particle-headline-assembling-into-letters-canvas
- Screenshot: https://shadcn.exchange/shot/weinc/particle-headline-assembling-into-letters-canvas/light.png
- Registry item: https://we.inc/r/particle-headline-assembling-into-letters-canvas.json
- Listed: 2026-10-28, source last changed 2026-10-28

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/weinc/particle-headline-assembling-into-letters-canvas.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `framer-motion`

## Files

### `components/we-inc/particle-headline-assembling-into-letters-canvas.tsx`

```tsx
import { useEffect, useRef } from "react";
import { useReducedMotion } from "framer-motion";

const WORD = "Lantern";
const COLORS = ["#efdcbc", "#d9a35b", "#f7ecd8"]; // sand, amber, pale cream
const ASSEMBLE_MS = 1500; // how long the dust takes to settle into letters
const CYCLE_MS = 11000; // full loop: assemble, rest, drift apart
const MAX_DOTS = 4200; // upper limit on particles, keeps each frame cheap

export default function ParticleHeadlineAssemblingIntoLetters() {
  const box = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const reduce = useReducedMotion();

  useEffect(() => {
    const canvas = canvasRef.current;
    const host = box.current;
    if (!canvas || !host) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    let w = 0;
    let h = 0;
    let n = 0;
    let tx = new Float32Array(0);
    let ty = new Float32Array(0);
    let sx = new Float32Array(0);
    let sy = new Float32Array(0);
    let ox = new Float32Array(0);
    let oy = new Float32Array(0);
    let delay = new Float32Array(0);
    let tone = new Uint8Array(0);
    let size = new Float32Array(0);
    let frame = 0;
    let visible = true;
    let start = performance.now();
    const pointer = { x: -1e4, y: -1e4 };

    const build = () => {
      const r = host.getBoundingClientRect();
      w = Math.max(1, r.width);
      h = Math.max(1, r.height);
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      canvas.width = Math.round(w * dpr);
      canvas.height = Math.round(h * dpr);
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

      const off = document.createElement("canvas");
      off.width = Math.ceil(w);
      off.height = Math.ceil(h);
      const o = off.getContext("2d", { willReadFrequently: true });
      if (!o) return;
      let fs = h * 0.78;
      o.font = `700 ${fs}px ui-serif, Georgia, "Times New Roman", serif`;
      const tw = o.measureText(WORD).width;
      if (tw > w * 0.92) fs *= (w * 0.92) / tw;
      o.font = `700 ${fs}px ui-serif, Georgia, "Times New Roman", serif`;
      o.textAlign = "center";
      o.textBaseline = "middle";
      o.fillStyle = "#000";
      o.fillText(WORD, w / 2, h * 0.53);
      const data = o.getImageData(0, 0, off.width, off.height).data;
      const gap = w < 640 ? 2.5 : 3.5;
      const xs: number[] = [];
      const ys: number[] = [];
      for (let y = 0; y < off.height; y += gap) {
        for (let x = 0; x < off.width; x += gap) {
          if (data[(Math.floor(y) * off.width + Math.floor(x)) * 4 + 3] > 140) {
            xs.push(x + (Math.random() - 0.5) * 0.8);
            ys.push(y + (Math.random() - 0.5) * 0.8);
          }
        }
      }
      const stride = Math.max(1, Math.ceil(xs.length / MAX_DOTS));
      tx = Float32Array.from(xs.filter((_, i) => i % stride === 0));
      ty = Float32Array.from(ys.filter((_, i) => i % stride === 0));
      n = tx.length;
      sx = new Float32Array(n);
      sy = new Float32Array(n);
      ox = new Float32Array(n);
      oy = new Float32Array(n);
      delay = new Float32Array(n);
      tone = new Uint8Array(n);
      size = new Float32Array(n);
      for (let i = 0; i < n; i += 1) {
        const a = Math.random() * Math.PI * 2;
        const d = 0.35 + Math.random() * 0.75;
        sx[i] = w / 2 + Math.cos(a) * w * 0.55 * d;
        sy[i] = h / 2 + Math.sin(a) * h * 0.9 * d;
        delay[i] = (tx[i] / w) * 380 + Math.random() * 260;
        const roll = Math.random();
        tone[i] = roll < 0.62 ? 0 : roll < 0.9 ? 1 : 2;
        size[i] = gap * (0.62 + Math.random() * 0.4);
      }
    };

    const ease = (p: number) => 1 - Math.pow(1 - p, 3);

    const draw = (now: number, still: boolean) => {
      const t = still ? ASSEMBLE_MS + 2000 : (now - start) % CYCLE_MS;
      ctx.clearRect(0, 0, w, h);
      const out = CYCLE_MS - 1400;
      for (let c = 0; c < COLORS.length; c += 1) {
        ctx.fillStyle = COLORS[c];
        for (let i = 0; i < n; i += 1) {
          if (tone[i] !== c) continue;
          let p = Math.min(1, Math.max(0, (t - delay[i]) / (ASSEMBLE_MS - 600)));
          if (t > out) p = 1 - Math.min(1, (t - out) / 1300) * 0.85;
          const e = ease(p);
          let x = sx[i] + (tx[i] - sx[i]) * e;
          let y = sy[i] + (ty[i] - sy[i]) * e;
          if (!still) {
            x += Math.sin(now * 0.0011 + i) * 0.45;
            y += Math.cos(now * 0.0013 + i * 1.7) * 0.45;
            const dx = x - pointer.x;
            const dy = y - pointer.y;
            const d2 = dx * dx + dy * dy;
            if (d2 < 3600) {
              const f = (1 - d2 / 3600) * 2.2;
              const d = Math.sqrt(d2) || 1;
              ox[i] += (dx / d) * f;
              oy[i] += (dy / d) * f;
            }
            ox[i] *= 0.9;
            oy[i] *= 0.9;
            x += ox[i];
            y += oy[i];
          }
          ctx.globalAlpha = 0.35 + 0.65 * e;
          ctx.fillRect(x, y, size[i], size[i]);
        }
      }
      ctx.globalAlpha = 1;
    };

    const loop = (now: number) => {
      draw(now, false);
      frame = requestAnimationFrame(loop);
    };
    const play = () => {
      if (!frame && visible && !reduce) frame = requestAnimationFrame(loop);
    };
    const stop = () => {
      cancelAnimationFrame(frame);
      frame = 0;
    };

    build();
    if (reduce) draw(0, true);
    else play();

    const ro = new ResizeObserver(() => {
      build();
      if (reduce) draw(0, true);
    });
    ro.observe(host);
    const io = new IntersectionObserver(([entry]) => {
      visible = entry.isIntersecting;
      if (visible) play();
      else stop();
    });
    io.observe(host);
    const move = (e: PointerEvent) => {
      const r = host.getBoundingClientRect();
      pointer.x = e.clientX - r.left;
      pointer.y = e.clientY - r.top;
    };
    const leave = () => {
      pointer.x = -1e4;
      pointer.y = -1e4;
    };
    host.addEventListener("pointermove", move);
    host.addEventListener("pointerleave", leave);
    start = performance.now();

    return () => {
      stop();
      ro.disconnect();
      io.disconnect();
      host.removeEventListener("pointermove", move);
      host.removeEventListener("pointerleave", leave);
    };
  }, [reduce]);

  return (
    <section className="relative overflow-hidden bg-[#15110e] px-5 py-16 sm:px-10 sm:py-20">
      <div aria-hidden="true" className="pointer-events-none absolute inset-0 bg-[radial-gradient(55%_50%_at_50%_42%,rgba(217,163,91,0.16),transparent_70%)]" />
      <div className="relative mx-auto max-w-5xl text-center">
        <p className="text-xs font-semibold uppercase tracking-[0.32em] text-[#c9b08a]">Small-batch coffee roasters</p>
        <h1 className="sr-only">{WORD}</h1>
        <div ref={box} className="relative mx-auto mt-4 h-[150px] w-full touch-pan-y sm:h-[240px] lg:h-[280px]">
          <canvas ref={canvasRef} aria-hidden="true" className="absolute inset-0 h-full w-full" />
        </div>
        <p className="mx-auto mt-4 max-w-lg text-base leading-relaxed text-[#d8c6aa] sm:text-lg">
          Beans roasted every Tuesday in a converted boathouse, shipped the same afternoon so they reach you within the week.
        </p>
        <a
          href="#"
          className="mt-8 inline-flex h-12 items-center rounded-full bg-[#efdcbc] px-7 text-sm font-semibold text-[#15110e] transition hover:bg-[#f7ecd8] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#d9a35b] focus-visible:ring-offset-2 focus-visible:ring-offset-[#15110e]"
        >
          Choose this week&apos;s roast
        </a>
      </div>
    </section>
  );
}
```
