← All moods

Stitched Text

Turns text into an embroidered patch. Each letter is filled with satin stitches that run across the stroke at a slant and follow it around curves. Around it sits a raised satin border with stitches perpendicular to the edge. A dark crease separates fill from border, the fill puffs up from its edge, and every thread has its own shading and sheen. On load a needle sews it in at machine speed: the border first, then the fill, letter by letter.

React · Motion

Thread 1
Thread 2
Border
Background

StitchedText.tsx

"use client";

import { useEffect, useLayoutEffect, useRef, useState, type CSSProperties } from "react";
import { animate, useInView, useMotionValue, useReducedMotion } from "motion/react";

export interface StitchedTextProps {
  text: string;
  /** Fill thread color. Defaults to the CSS color. */
  color?: string;
  /** Fill thread colors cycled letter by letter (spaces skipped). Overrides `color`. */
  colors?: string[];
  /** Border (satin edge) thread color. */
  borderColor?: string;
  /** Border width, em. 0 = no border. */
  border?: number;
  /** Thread thickness, em. */
  thread?: number;
  /** Fill stitch slant, degrees from straight across the stroke. */
  slant?: number;
  /** Seconds to sew everything. 0 = appears finished. */
  duration?: number;
  delay?: number;
  /** Show the needle while sewing. */
  needle?: boolean;
  /** Soft drop shadow under the patch. */
  shadow?: boolean;
  trigger?: "load" | "inView";
  className?: string;
  style?: CSSProperties;
}

type RGB = [number, number, number];
interface Stitch {
  pts: Float32Array;
  border: boolean;
  /** Fill color index (per letter). */
  ink?: number;
}

const INF = 1e20;
const MAX_PIXELS = 2_400_000;
const LIGHT: [number, number] = [-0.6, -0.8]; // toward the light, y down

function parseColor(css: string): RGB {
  const ctx = document.createElement("canvas").getContext("2d")!;
  ctx.fillStyle = css;
  ctx.fillRect(0, 0, 1, 1);
  const d = ctx.getImageData(0, 0, 1, 1).data;
  return [d[0], d[1], d[2]];
}
const rgb = ([r, g, b]: RGB) => `rgb(${r | 0},${g | 0},${b | 0})`;
const scale = (c: RGB, k: number): RGB => [c[0] * k, c[1] * k, c[2] * k];
const mix = (a: RGB, b: RGB, t: number): RGB => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];
const smoothstep = (a: number, b: number, x: number) => {
  const t = Math.min(1, Math.max(0, (x - a) / (b - a)));
  return t * t * (3 - 2 * t);
};

// ---------- distance field (Felzenszwalb & Huttenlocher) ----------

function dt1(f: Float64Array, n: number, d: Float64Array, v: Int32Array, z: Float64Array) {
  let k = 0;
  v[0] = 0;
  z[0] = -INF;
  z[1] = INF;
  for (let q = 1; q < n; q++) {
    let s = (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);
    while (s <= z[k]) {
      k--;
      s = (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]);
    }
    k++;
    v[k] = q;
    z[k] = s;
    z[k + 1] = INF;
  }
  k = 0;
  for (let q = 0; q < n; q++) {
    while (z[k + 1] < q) k++;
    d[q] = (q - v[k]) * (q - v[k]) + f[v[k]];
  }
}

/** Euclidean distance from every pixel to the nearest `target` pixel. */
function edt(target: Uint8Array, w: number, h: number) {
  const n = Math.max(w, h);
  const f = new Float64Array(n);
  const d = new Float64Array(n);
  const z = new Float64Array(n + 1);
  const v = new Int32Array(n);
  const g = new Float64Array(w * h);
  for (let i = 0; i < w * h; i++) g[i] = target[i] ? 0 : INF;
  for (let x = 0; x < w; x++) {
    for (let y = 0; y < h; y++) f[y] = g[y * w + x];
    dt1(f, h, d, v, z);
    for (let y = 0; y < h; y++) g[y * w + x] = d[y];
  }
  const out = new Float32Array(w * h);
  for (let y = 0; y < h; y++) {
    for (let x = 0; x < w; x++) f[x] = g[y * w + x];
    dt1(f, w, d, v, z);
    for (let x = 0; x < w; x++) out[y * w + x] = Math.sqrt(d[x]);
  }
  return out;
}

/** Separable box blur, in place, `passes` times. */
function boxBlur(a: Float32Array, w: number, h: number, r: number, passes = 2) {
  const tmp = new Float32Array(Math.max(w, h));
  const run = (len: number, get: (i: number) => number, set: (i: number, v: number) => void) => {
    let sum = 0;
    for (let i = -r; i <= r; i++) sum += get(Math.min(len - 1, Math.max(0, i)));
    for (let i = 0; i < len; i++) {
      tmp[i] = sum / (2 * r + 1);
      sum += get(Math.min(len - 1, i + r + 1)) - get(Math.max(0, i - r));
    }
    for (let i = 0; i < len; i++) set(i, tmp[i]);
  };
  for (let p = 0; p < passes; p++) {
    for (let y = 0; y < h; y++) run(w, (i) => a[y * w + i], (i, v) => (a[y * w + i] = v));
    for (let x = 0; x < w; x++) run(h, (i) => a[i * w + x], (i, v) => (a[i * w + x] = v));
  }
}

// ---------- marching squares: closed contours of f = level ----------

function contours(f: Float32Array, w: number, h: number, level: number): number[][] {
  const pos = new Map<number, [number, number]>();
  const adj = new Map<number, number[]>();
  const val = (x: number, y: number) => f[y * w + x] - level;
  const hId = (x: number, y: number) => (y * w + x) * 2;
  const vId = (x: number, y: number) => (y * w + x) * 2 + 1;
  const point = (id: number) => {
    let p = pos.get(id);
    if (!p) {
      const cell = id >> 1;
      const x = cell % w;
      const y = (cell / w) | 0;
      const a = val(x, y);
      if (id & 1) {
        const b = val(x, y + 1);
        p = [x, y + a / (a - b)];
      } else {
        const b = val(x + 1, y);
        p = [x + a / (a - b), y];
      }
      pos.set(id, p);
    }
    return p;
  };
  const link = (a: number, b: number) => {
    point(a);
    point(b);
    (adj.get(a) ?? adj.set(a, []).get(a)!).push(b);
    (adj.get(b) ?? adj.set(b, []).get(b)!).push(a);
  };
  for (let y = 0; y < h - 1; y++) {
    for (let x = 0; x < w - 1; x++) {
      const a = val(x, y);
      const b = val(x + 1, y);
      const c = val(x + 1, y + 1);
      const d = val(x, y + 1);
      const idx = (a < 0 ? 8 : 0) | (b < 0 ? 4 : 0) | (c < 0 ? 2 : 0) | (d < 0 ? 1 : 0);
      if (idx === 0 || idx === 15) continue;
      const T = hId(x, y);
      const B = hId(x, y + 1);
      const L = vId(x, y);
      const R = vId(x + 1, y);
      const centre = (a + b + c + d) / 4 < 0;
      switch (idx) {
        case 1: case 14: link(L, B); break;
        case 2: case 13: link(B, R); break;
        case 3: case 12: link(L, R); break;
        case 4: case 11: link(T, R); break;
        case 6: case 9: link(T, B); break;
        case 7: case 8: link(L, T); break;
        case 5:
          if (centre) { link(L, T); link(B, R); } else { link(T, R); link(L, B); }
          break;
        case 10:
          if (centre) { link(T, R); link(L, B); } else { link(L, T); link(B, R); }
          break;
      }
    }
  }
  const seen = new Set<number>();
  const out: number[][] = [];
  for (const start of adj.keys()) {
    if (seen.has(start)) continue;
    const line: number[] = [];
    let prev = -1;
    let cur = start;
    while (cur !== -1 && !seen.has(cur)) {
      seen.add(cur);
      const p = pos.get(cur)!;
      line.push(p[0], p[1]);
      const next = (adj.get(cur) ?? []).find((n) => n !== prev && !seen.has(n));
      prev = cur;
      cur = next ?? -1;
    }
    if (line.length >= 8) out.push(line);
  }
  return out;
}

/** Evenly spaced points along a closed polyline. */
function resampleClosed(line: number[], step: number): number[] {
  const n = line.length / 2;
  const out: number[] = [];
  let carry = 0;
  for (let i = 0; i < n; i++) {
    const ax = line[i * 2];
    const ay = line[i * 2 + 1];
    const bx = line[((i + 1) % n) * 2];
    const by = line[((i + 1) % n) * 2 + 1];
    const len = Math.hypot(bx - ax, by - ay);
    let t = carry;
    while (t < len) {
      out.push(ax + ((bx - ax) * t) / len, ay + ((by - ay) * t) / len);
      t += step;
    }
    carry = t - len;
  }
  return out;
}

// ---------- building the stitches ----------

interface Built {
  w: number;
  h: number;
  /** Letter shape and letter + border shape, anti-aliased, for clean edges. */
  letterMask: HTMLCanvasElement;
  bandMask: HTMLCanvasElement;
  stitches: Stitch[];
  shade: HTMLCanvasElement;
  tw: number;
  spacing: number;
  fs: number;
}

function build(mask: Uint8ClampedArray, w: number, h: number, fs: number, o: { border: number; thread: number; slant: number }): Built {
  const N = w * h;
  const inside = new Uint8Array(N);
  const outside = new Uint8Array(N);
  for (let i = 0; i < N; i++) {
    const a = mask[i * 4 + 3] / 255;
    inside[i] = a >= 0.5 ? 1 : 0;
    outside[i] = 1 - inside[i];
  }
  const dOut = edt(inside, w, h);
  const dIn = edt(outside, w, h);
  const sdf = new Float32Array(N);
  for (let i = 0; i < N; i++) {
    const a = mask[i * 4 + 3] / 255;
    sdf[i] = a > 0.02 && a < 0.98 ? 0.5 - a : inside[i] ? -(dIn[i] - 0.5) : dOut[i] - 0.5;
  }

  const spacing = Math.max(1.6, o.thread * fs);
  const tw = spacing * 1.15;
  const B = o.border * fs;

  const at = (x: number, y: number) => {
    const xi = Math.min(w - 2, Math.max(0, Math.floor(x)));
    const yi = Math.min(h - 2, Math.max(0, Math.floor(y)));
    const fx = Math.min(1, Math.max(0, x - xi));
    const fy = Math.min(1, Math.max(0, y - yi));
    const i = yi * w + xi;
    return (sdf[i] * (1 - fx) + sdf[i + 1] * fx) * (1 - fy) + (sdf[i + w] * (1 - fx) + sdf[i + w + 1] * fx) * fy;
  };
  const grad = (x: number, y: number): [number, number] => {
    const gx = at(x + 1, y) - at(x - 1, y);
    const gy = at(x, y + 1) - at(x, y - 1);
    const l = Math.hypot(gx, gy) || 1;
    return [gx / l, gy / l];
  };

  // Stitch direction: across the stroke (the distance gradient), stored as a
  // doubled angle so opposite sides of a stroke agree, then smoothed.
  const vx = new Float32Array(N);
  const vy = new Float32Array(N);
  for (let y = 1; y < h - 1; y++) {
    for (let x = 1; x < w - 1; x++) {
      const i = y * w + x;
      if (sdf[i] > 1) continue;
      const gx = sdf[i + 1] - sdf[i - 1];
      const gy = sdf[i + w] - sdf[i - w];
      const l = gx * gx + gy * gy;
      if (l < 1e-6) continue;
      vx[i] = (gx * gx - gy * gy) / l;
      vy[i] = (2 * gx * gy) / l;
    }
  }
  const r = Math.max(2, Math.round(fs * 0.035));
  boxBlur(vx, w, h, r);
  boxBlur(vy, w, h, r);
  const sl = (o.slant * Math.PI) / 180;
  const dir = (x: number, y: number): [number, number] => {
    const i = Math.min(h - 1, Math.max(0, Math.round(y))) * w + Math.min(w - 1, Math.max(0, Math.round(x)));
    let a: number;
    if (Math.abs(vx[i]) + Math.abs(vy[i]) < 1e-4) {
      const [gx, gy] = grad(x, y);
      a = Math.atan2(gy, gx);
    } else a = Math.atan2(vy[i], vx[i]) / 2;
    a += sl;
    return [Math.cos(a), Math.sin(a)];
  };

  const byX = (a: number[], b: number[]) => {
    let ma = Infinity;
    let mb = Infinity;
    for (let i = 0; i < a.length; i += 2) ma = Math.min(ma, a[i]);
    for (let i = 0; i < b.length; i += 2) mb = Math.min(mb, b[i]);
    return ma - mb;
  };

  const stitches: Stitch[] = [];
  const stampInto = (grid: Uint8Array, x: number, y: number, rad: number) => {
    const r0 = Math.ceil(rad);
    const cx = Math.round(x);
    const cy = Math.round(y);
    for (let dy = -r0; dy <= r0; dy++) {
      for (let dx = -r0; dx <= r0; dx++) {
        if (dx * dx + dy * dy > rad * rad) continue;
        const px = cx + dx;
        const py = cy + dy;
        if (px >= 0 && py >= 0 && px < w && py < h) grid[py * w + px] = 1;
      }
    }
  };

  // Border: satin stitches straight across the band, perpendicular to the
  // letter edge. First pass shoots out from the letter edge and stops at the
  // band's outer edge, or at the ridge where it meets stitches from the other
  // side (tight counters, gaps between letters). Second pass comes in from the
  // outer edge to fill gaps where stitches fan apart around convex corners.
  if (B > 0.5) {
    const bcov = new Uint8Array(N);
    const bs = 0.1;
    const cs = Math.cos(bs);
    const sn = Math.sin(bs);
    const ray = (x0: number, y0: number, sign: 1 | -1): number[] | null => {
      let [gx, gy] = grad(x0, y0);
      const nx = (gx * cs - gy * sn) * sign;
      const ny = (gx * sn + gy * cs) * sign;
      let x = x0;
      let y = y0;
      let d = at(x, y);
      for (let k = 0; k < B * 2; k++) {
        const nxp = x + nx;
        const nyp = y + ny;
        const nd = at(nxp, nyp);
        // Outward: stop at the outer edge or when distance stops growing.
        if (sign > 0 && (nd > B + 0.6 || (d > tw && nd < d + 0.25))) break;
        // Inward: stop once under the letter, or at a ridge.
        if (sign < 0 && (nd < -tw * 0.8 || (d < B - tw && nd > d - 0.25))) break;
        x = nxp;
        y = nyp;
        d = nd;
      }
      [gx, gy] = [x - x0, y - y0];
      if (Math.hypot(gx, gy) < tw) return null;
      return [x0, y0, x, y];
    };
    const mark = (seg: number[]) => {
      const len = Math.hypot(seg[2] - seg[0], seg[3] - seg[1]);
      for (let t = 0; t <= len; t += 2) {
        const f = t / (len || 1);
        stampInto(bcov, seg[0] + (seg[2] - seg[0]) * f, seg[1] + (seg[3] - seg[1]) * f, spacing * 0.5);
      }
    };
    contours(sdf, w, h, -tw * 0.8).sort(byX).forEach((line) => {
      const pts = resampleClosed(line, spacing * 0.95);
      for (let i = 0; i < pts.length; i += 2) {
        const seg = ray(pts[i], pts[i + 1], 1);
        if (!seg) continue;
        mark(seg);
        stitches.push({ pts: new Float32Array(seg), border: true });
      }
    });
    contours(sdf, w, h, B).sort(byX).forEach((line) => {
      const pts = resampleClosed(line, spacing * 0.95);
      for (let i = 0; i < pts.length; i += 2) {
        const [gx, gy] = grad(pts[i], pts[i + 1]);
        const ix = Math.round(pts[i] - gx * 2);
        const iy = Math.round(pts[i + 1] - gy * 2);
        if (ix < 0 || iy < 0 || ix >= w || iy >= h || bcov[iy * w + ix]) continue;
        const seg = ray(pts[i] + gx * 0.6, pts[i + 1] + gy * 0.6, -1);
        if (!seg) continue;
        mark(seg);
        stitches.push({ pts: new Float32Array([seg[2], seg[3], seg[0], seg[1]]), border: true });
      }
    });
  }

  // Fill: satin threads traced from the outline along the smoothed direction
  // field until they leave the letter on the other side. Threads that would
  // start where another already lies are skipped, so they never pile up.
  const covered = new Uint8Array(N);
  const stamp = (x: number, y: number, rad: number) => stampInto(covered, x, y, rad);
  const maxStitch = fs * 0.32;
  const fill = contours(sdf, w, h, 0).sort(byX);
  let threadNo = 0;
  fill.forEach((line) => {
    const seeds = resampleClosed(line, spacing);
    for (let i = 0; i < seeds.length; i += 2) {
      const sx = seeds[i];
      const sy = seeds[i + 1];
      const [gx, gy] = grad(sx, sy);
      const ix = Math.round(sx - gx * 1.5);
      const iy = Math.round(sy - gy * 1.5);
      if (ix < 0 || iy < 0 || ix >= w || iy >= h || covered[iy * w + ix]) continue;
      let [px, py] = dir(sx, sy);
      if (px * -gx + py * -gy < 0) {
        px = -px;
        py = -py;
      }
      const pts: number[] = [sx + gx * 0.6, sy + gy * 0.6];
      let x = sx;
      let y = sy;
      const step = 1;
      for (let k = 0; k < fs * 3; k++) {
        let [dx, dy] = dir(x, y);
        if (dx * px + dy * py < 0) {
          dx = -dx;
          dy = -dy;
        }
        let [ex, ey] = dir(x + dx * step * 0.5, y + dy * step * 0.5);
        if (ex * dx + ey * dy < 0) {
          ex = -ex;
          ey = -ey;
        }
        x += ex * step;
        y += ey * step;
        px = ex;
        py = ey;
        pts.push(x, y);
        if (at(x, y) > 0.6) break;
      }
      if (pts.length < 8) continue;
      for (let k = 0; k < pts.length; k += 4) stamp(pts[k], pts[k + 1], spacing * 0.5);
      // Long threads are split into shorter, staggered stitches.
      let total = 0;
      for (let k = 2; k < pts.length; k += 2) total += Math.hypot(pts[k] - pts[k - 2], pts[k + 1] - pts[k - 1]);
      if (total <= maxStitch) {
        stitches.push({ pts: new Float32Array(pts), border: false });
      } else {
        const pieces = Math.ceil(total / maxStitch);
        const len = total / pieces;
        let off = ((threadNo * 0.618) % 1) * len;
        let acc = 0;
        let piece: number[] = [pts[0], pts[1]];
        for (let k = 2; k < pts.length; k += 2) {
          acc += Math.hypot(pts[k] - pts[k - 2], pts[k + 1] - pts[k - 1]);
          piece.push(pts[k], pts[k + 1]);
          if (acc >= off + len * 0.5 && acc >= len * 0.5 && k < pts.length - 4) {
            stitches.push({ pts: new Float32Array(piece), border: false });
            piece = [pts[k], pts[k + 1]];
            acc = 0;
            off = 0;
          }
        }
        if (piece.length >= 4) stitches.push({ pts: new Float32Array(piece), border: false });
      }
      threadNo++;
    }
  });

  // Puffy lighting: fill rises from its edge; the border is a rounded tube;
  // dark creases where fill meets border and at the outer edge.
  const shade = document.createElement("canvas");
  shade.width = w;
  shade.height = h;
  const sctx = shade.getContext("2d")!;
  const img = sctx.createImageData(w, h);
  const puff = fs * 0.05;
  const hasBorder = B > 0.5;
  for (let y = 1; y < h - 1; y++) {
    for (let x = 1; x < w - 1; x++) {
      const i = y * w + x;
      const d = sdf[i];
      if (d > (hasBorder ? B : 0) + 1) continue;
      let gx = sdf[i + 1] - sdf[i - 1];
      let gy = sdf[i + w] - sdf[i - w];
      const l = Math.hypot(gx, gy) || 1;
      gx /= l;
      gy /= l;
      const lit = gx * LIGHT[0] + gy * LIGHT[1];
      let s: number;
      if (d < 0 || !hasBorder) {
        const edge = 1 - smoothstep(0, puff, -d);
        s = edge * lit * 0.45 - edge * 0.16;
      } else {
        const u = Math.min(1, d / B);
        s = -Math.cos(Math.PI * u) * lit * 0.4 - 0.1 * (1 - Math.sin(Math.PI * u));
        s -= 0.3 * Math.exp(-((d - B) * (d - B)) / (tw * tw * 0.6));
      }
      if (hasBorder) s -= 0.42 * Math.exp(-(d * d) / (tw * tw * 0.5));
      const k = i * 4;
      const a = Math.min(0.6, Math.abs(s));
      const c = s > 0 ? 255 : 0;
      img.data[k] = c;
      img.data[k + 1] = c;
      img.data[k + 2] = c;
      img.data[k + 3] = a * 255;
    }
  }
  sctx.putImageData(img, 0, 0);

  const maskOf = (level: number) => {
    const c = document.createElement("canvas");
    c.width = w;
    c.height = h;
    const mc = c.getContext("2d")!;
    const im = mc.createImageData(w, h);
    for (let i = 0; i < N; i++) im.data[i * 4 + 3] = Math.min(1, Math.max(0, level + 0.5 - sdf[i])) * 255;
    mc.putImageData(im, 0, 0);
    return c;
  };
  return { w, h, letterMask: maskOf(0), bandMask: maskOf(hasBorder ? B : 0), stitches, shade, tw, spacing, fs };
}

// ---------- drawing ----------

interface Palette {
  under: string;
  dark: string;
  base: string;
  light: string;
}
function palette(c: RGB, border: boolean): Palette {
  const white: RGB = [255, 255, 255];
  return {
    under: rgb(scale(c, border ? 0.74 : 0.55)),
    dark: rgb(scale(c, border ? 0.72 : 0.52)),
    base: rgb(c),
    light: rgb(mix(c, white, border ? 0.9 : 0.5)),
  };
}

function path(ctx: CanvasRenderingContext2D, p: Float32Array, from = 0, to = p.length / 2) {
  ctx.beginPath();
  ctx.moveTo(p[from * 2], p[from * 2 + 1]);
  for (let i = from + 1; i < to; i++) ctx.lineTo(p[i * 2], p[i * 2 + 1]);
}

function drawStitch(ctx: CanvasRenderingContext2D, s: Stitch, b: Built, fillPals: Palette[], borderPal: Palette) {
  const pal = s.border ? borderPal : fillPals[(s.ink ?? 0) % fillPals.length];
  const p = s.pts;
  const n = p.length / 2;
  const tw = b.tw;
  // Underlay closes the gaps between neighbouring threads.
  ctx.lineCap = "butt";
  ctx.lineJoin = "round";
  ctx.strokeStyle = pal.under;
  ctx.lineWidth = b.spacing * 1.08;
  path(ctx, p);
  ctx.stroke();
  // Thread: dark rim, body, then a highlight along its middle stretch,
  // nudged toward the light, so each thread reads as a little cylinder.
  ctx.lineCap = "round";
  ctx.strokeStyle = pal.dark;
  ctx.lineWidth = tw;
  path(ctx, p);
  ctx.stroke();
  ctx.strokeStyle = pal.base;
  ctx.lineWidth = tw * 0.72;
  path(ctx, p);
  ctx.stroke();
  const dx = p[(n - 1) * 2] - p[0];
  const dy = p[(n - 1) * 2 + 1] - p[1];
  const l = Math.hypot(dx, dy) || 1;
  let ox = -dy / l;
  let oy = dx / l;
  if (ox * LIGHT[0] + oy * LIGHT[1] < 0) {
    ox = -ox;
    oy = -oy;
  }
  const a = Math.floor(n * 0.18);
  const z = Math.max(a + 2, Math.ceil(n * 0.82));
  ctx.save();
  ctx.translate(ox * tw * 0.14, oy * tw * 0.14);
  ctx.strokeStyle = pal.light;
  ctx.lineWidth = tw * 0.24;
  ctx.globalAlpha = 0.9;
  path(ctx, p, a, Math.min(n, z));
  ctx.stroke();
  ctx.restore();
}

// ---------- component ----------

export function StitchedText({
  text,
  color,
  colors,
  borderColor = "#f4f1e8",
  border = 0.07,
  thread = 0.013,
  slant = 28,
  duration = 3.2,
  delay = 0.2,
  needle = true,
  shadow = true,
  trigger = "load",
  className,
  style,
}: StitchedTextProps) {
  const reduceMotion = useReducedMotion();
  const rootRef = useRef<HTMLSpanElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const progress = useMotionValue(0);
  const inView = useInView(rootRef, { once: true, amount: 0.3 });
  const started = trigger === "load" || inView;
  const [failed, setFailed] = useState(false);

  // Room around the text for the border and the shadow, em.
  const pad = border + 0.14;

  useEffect(() => {
    if (!started) return;
    if (reduceMotion || duration <= 0) {
      progress.set(1);
      return;
    }
    // A machine sews at a constant speed.
    const controls = animate(progress, 1, { duration, delay, ease: "linear" });
    return () => controls.stop();
  }, [started, reduceMotion, duration, delay, progress]);

  useLayoutEffect(() => {
    const root = rootRef.current;
    const canvas = canvasRef.current;
    if (!root || !canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) {
      setFailed(true);
      return;
    }
    // Border and fill are sewn on separate layers, each clipped to its shape.
    const layer = document.createElement("canvas");
    const lctx = layer.getContext("2d")!;
    const borderLayer = document.createElement("canvas");
    const bctx = borderLayer.getContext("2d")!;
    const fillLayer = document.createElement("canvas");
    const fctx = fillLayer.getContext("2d")!;
    let built: Built | null = null;
    let fillPals: Palette[];
    let borderPal: Palette;
    let drawn = 0;
    let shown = -1;
    let px = 1; // device px per css px

    const rebuild = () => {
      const cs = getComputedStyle(root);
      const fs = parseFloat(cs.fontSize) || 16;
      const rect = root.getBoundingClientRect();
      const cssW = rect.width + pad * 2 * fs;
      const cssH = rect.height + pad * 2 * fs;
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      px = Math.min(dpr, Math.sqrt(MAX_PIXELS / Math.max(1, cssW * cssH)));
      const w = Math.max(4, Math.round(cssW * px));
      const h = Math.max(4, Math.round(cssH * px));
      canvas.width = w;
      canvas.height = h;
      for (const c of [layer, borderLayer, fillLayer]) {
        c.width = w;
        c.height = h;
      }

      const m = document.createElement("canvas");
      m.width = w;
      m.height = h;
      const mctx = m.getContext("2d", { willReadFrequently: true })!;
      mctx.font = `${cs.fontStyle} ${cs.fontWeight} ${fs * px}px ${cs.fontFamily}`;
      if ("letterSpacing" in mctx && cs.letterSpacing !== "normal") {
        (mctx as CanvasRenderingContext2D & { letterSpacing: string }).letterSpacing = `${parseFloat(cs.letterSpacing) * px}px`;
      }
      mctx.textAlign = "center";
      mctx.textBaseline = "middle";
      mctx.fillStyle = "#000";
      mctx.fillText(text, w / 2, (pad * fs + rect.height / 2) * px);
      const data = mctx.getImageData(0, 0, w, h).data;

      built = build(data, w, h, fs * px, { border, thread, slant });
      const inks = colors?.length ? colors : [color ?? cs.color];
      fillPals = inks.map((c) => palette(parseColor(c), false));
      if (inks.length > 1) {
        // Which letter each fill stitch belongs to, from the text's advance widths.
        const total = mctx.measureText(text).width;
        const x0 = w / 2 - total / 2;
        const edges: number[] = [];
        const inkOf: number[] = [];
        let n = 0;
        [...text].forEach((ch, i, arr) => {
          edges.push(x0 + mctx.measureText(arr.slice(0, i + 1).join("")).width);
          inkOf.push(ch.trim() ? n++ : -1);
        });
        for (const st of built.stitches) {
          if (st.border) continue;
          const p = st.pts;
          const mx = (p[0] + p[p.length - 2]) / 2;
          let i = edges.findIndex((e) => mx < e);
          if (i < 0) i = edges.length - 1;
          while (i > 0 && inkOf[i] < 0) i--;
          st.ink = Math.max(0, inkOf[i]);
        }
      }
      borderPal = palette(parseColor(borderColor), true);
      drawn = 0;
      shown = -1;
    };

    const draw = () => {
      if (!built) return;
      const total = built.stitches.length;
      const target = Math.min(total, Math.round(progress.get() * total));
      if (target === shown) return;
      const { w, h } = built;
      let newBorder = false;
      let newFill = false;
      for (; drawn < target; drawn++) {
        const st = built.stitches[drawn];
        drawStitch(st.border ? bctx : fctx, st, built, fillPals, borderPal);
        if (st.border) newBorder = true;
        else newFill = true;
      }
      const clip = (c: CanvasRenderingContext2D, m: HTMLCanvasElement) => {
        c.globalCompositeOperation = "destination-in";
        c.drawImage(m, 0, 0);
        c.globalCompositeOperation = "source-over";
      };
      if (newBorder) clip(bctx, built.bandMask);
      if (newFill) clip(fctx, built.letterMask);
      shown = target;
      lctx.clearRect(0, 0, w, h);
      lctx.drawImage(borderLayer, 0, 0);
      lctx.drawImage(fillLayer, 0, 0);
      ctx.globalCompositeOperation = "source-over";
      ctx.clearRect(0, 0, w, h);
      ctx.drawImage(layer, 0, 0);
      ctx.globalCompositeOperation = "source-atop";
      ctx.drawImage(built.shade, 0, 0);
      if (shadow) {
        ctx.globalCompositeOperation = "destination-over";
        ctx.shadowColor = "rgba(0,0,0,0.32)";
        ctx.shadowBlur = built.fs * 0.06;
        ctx.shadowOffsetX = built.fs * 0.012;
        ctx.shadowOffsetY = built.fs * 0.03;
        ctx.drawImage(layer, 0, 0);
        ctx.shadowColor = "transparent";
      }
      ctx.globalCompositeOperation = "source-over";
      // The needle rides on the last stitch while sewing.
      if (needle && target > 0 && target < total) {
        const p = built.stitches[target - 1].pts;
        const x = p[p.length - 2];
        const y = p[p.length - 1];
        const L = built.fs * 0.5;
        const nw = Math.max(1.5, built.tw * 0.7);
        ctx.lineCap = "round";
        ctx.strokeStyle = "rgba(0,0,0,0.35)";
        ctx.lineWidth = nw * 1.6;
        ctx.beginPath();
        ctx.moveTo(x + 2, y + 2);
        ctx.lineTo(x + L * 0.35 + 2, y - L + 2);
        ctx.stroke();
        const g = ctx.createLinearGradient(x - nw, 0, x + nw, 0);
        g.addColorStop(0, "#8c9097");
        g.addColorStop(0.5, "#f4f6f8");
        g.addColorStop(1, "#6d7178");
        ctx.strokeStyle = g;
        ctx.lineWidth = nw;
        ctx.beginPath();
        ctx.moveTo(x, y);
        ctx.lineTo(x + L * 0.35, y - L);
        ctx.stroke();
      }
    };

    rebuild();
    draw();
    let timer = 0;
    const onResize = () => {
      clearTimeout(timer);
      timer = window.setTimeout(() => {
        rebuild();
        draw();
      }, 120);
    };
    const observer = new ResizeObserver(onResize);
    observer.observe(root);
    document.fonts?.addEventListener("loadingdone", onResize);
    const unsub = progress.on("change", draw);

    return () => {
      clearTimeout(timer);
      unsub();
      observer.disconnect();
      document.fonts?.removeEventListener("loadingdone", onResize);
    };
  }, [text, color, colors?.join(), borderColor, border, thread, slant, needle, shadow, pad, progress]);

  return (
    <span
      ref={rootRef}
      role="img"
      aria-label={text}
      className={className}
      style={{ position: "relative", display: "inline-block", lineHeight: 1, whiteSpace: "pre", ...style }}
    >
      {/* Invisible copy of the text sizes the box; the canvas draws it. */}
      <span aria-hidden style={{ color: failed ? undefined : "transparent" }}>
        {text}
      </span>
      {!failed && (
        <canvas
          ref={canvasRef}
          aria-hidden
          style={{
            position: "absolute",
            left: `${-pad}em`,
            top: `${-pad}em`,
            width: `calc(100% + ${pad * 2}em)`,
            height: `calc(100% + ${pad * 2}em)`,
            pointerEvents: "none",
          }}
        />
      )}
    </span>
  );
}