Baktiyar Ablaikhan

Embroidered Word Patches

Source
arlan.me/vault ↗
Author
Arlan
Licence
MIT

Loading…

Embroidered made / with / love patches stitched onto dark fabric; hovering sends a glint across the threads and pressing pushes them into the cloth.

Code

"use client";

import { approach, makeCanvas, mulberry32, useStage, type Scene } from "./stage";

/*
 * Word-shaped embroidered patches — made / with / love — stitched onto dark
 * fabric. Each patch is the word's silhouette dilated into cloth, ringed by a
 * white merrowed border, with dark satin lettering on top. Hover sends a glint
 * across the threads; a press pushes the patches into the cloth.
 */

const FABRIC = "#2a2233";
const INK = "#1c1824";
const PATCHES = [
  { word: "made", color: "#5ea8ff", x: 0.47, y: 0.31, rot: -0.04 },
  { word: "with", color: "#f2c94c", x: 0.56, y: 0.53, rot: 0.03 },
  { word: "love", color: "#f5a9d8", x: 0.46, y: 0.75, rot: -0.02 },
];

type Built = {
  fabric: HTMLCanvasElement;
  patches: HTMLCanvasElement[];
  threads: HTMLCanvasElement;
  glint: { c: HTMLCanvasElement; ctx: CanvasRenderingContext2D };
  r: number;
};

function stampRing(ctx: CanvasRenderingContext2D, draw: (dx: number, dy: number) => void, radius: number) {
  const n = Math.max(12, Math.ceil(radius * 2.2));
  for (let i = 0; i < n; i++) {
    const a = (i / n) * Math.PI * 2;
    draw(Math.cos(a) * radius, Math.sin(a) * radius);
  }
  for (let rr = radius * 0.5; rr > 0; rr -= radius * 0.5) {
    for (let i = 0; i < 8; i++) draw(Math.cos(i) * rr, Math.sin(i) * rr);
  }
  draw(0, 0);
}

function satin(ctx: CanvasRenderingContext2D, W: number, H: number, angle: number, gap: number, alpha: number) {
  ctx.save();
  ctx.translate(W / 2, H / 2);
  ctx.rotate(angle);
  const L = Math.hypot(W, H);
  for (let x = -L; x < L; x += gap) {
    ctx.fillStyle = `rgba(255,255,255,${alpha})`;
    ctx.fillRect(x, -L, gap * 0.45, 2 * L);
    ctx.fillStyle = `rgba(0,0,0,${alpha * 1.3})`;
    ctx.fillRect(x + gap * 0.5, -L, gap * 0.35, 2 * L);
  }
  ctx.restore();
}

function build(w: number, h: number, r: number): Built {
  const W = Math.round(w * r);
  const H = Math.round(h * r);
  const rnd = mulberry32(5);

  // fabric: twill weave + speckle
  const fab = makeCanvas(W, H);
  fab.ctx.fillStyle = FABRIC;
  fab.ctx.fillRect(0, 0, W, H);
  const step = Math.max(2, Math.round(3 * r));
  for (let x = 0; x < W; x += step) {
    fab.ctx.fillStyle = (x / step) % 2 ? "rgba(255,255,255,0.035)" : "rgba(0,0,0,0.12)";
    fab.ctx.fillRect(x, 0, step / 2, H);
  }
  for (let y = 0; y < H; y += step * 2) {
    fab.ctx.fillStyle = "rgba(0,0,0,0.08)";
    fab.ctx.fillRect(0, y, W, 1);
  }
  for (let i = 0; i < (W * H) / 60; i++) {
    fab.ctx.fillStyle = rnd() > 0.5 ? "rgba(255,255,255,0.05)" : "rgba(0,0,0,0.12)";
    fab.ctx.fillRect(rnd() * W, rnd() * H, 1, 1 + rnd() * 2);
  }

  const size = Math.min(H * 0.27, W * 0.19);
  const font = `900 ${size}px "Arial Rounded MT Bold", "Nunito", "Arial Black", Arial, sans-serif`;
  const threads = makeCanvas(W, H);
  const patches = PATCHES.map((pd) => {
    const P = makeCanvas(W, H);
    const c = P.ctx;
    c.font = font;
    c.textAlign = "center";
    c.textBaseline = "middle";
    c.translate(pd.x * W, pd.y * H);
    c.rotate(pd.rot);
    const word = (dx: number, dy: number) => c.fillText(pd.word, dx, dy);
    // merrowed border (white), then cloth
    c.fillStyle = "#f4f1ea";
    stampRing(c, word, size * 0.17);
    c.fillStyle = pd.color;
    stampRing(c, word, size * 0.11);
    // satin texture on the cloth + border
    c.setTransform(1, 0, 0, 1, 0, 0);
    c.globalCompositeOperation = "source-atop";
    satin(c, W, H, 0.9 + pd.rot, Math.max(2, 2.2 * r), 0.07);
    c.globalCompositeOperation = "source-over";
    // satin lettering
    const L = makeCanvas(W, H);
    L.ctx.font = font;
    L.ctx.textAlign = "center";
    L.ctx.textBaseline = "middle";
    L.ctx.translate(pd.x * W, pd.y * H);
    L.ctx.rotate(pd.rot);
    L.ctx.fillStyle = INK;
    L.ctx.fillText(pd.word, 0, 0);
    L.ctx.setTransform(1, 0, 0, 1, 0, 0);
    L.ctx.globalCompositeOperation = "source-atop";
    satin(L.ctx, W, H, -0.55, Math.max(2, 1.8 * r), 0.09);
    c.drawImage(L.c, 0, 0);
    // puff: a soft top-left highlight and bottom-right shade over the whole patch
    c.globalCompositeOperation = "source-atop";
    const puff = c.createLinearGradient(0, pd.y * H - size * 0.7, 0, pd.y * H + size * 0.7);
    puff.addColorStop(0, "rgba(255,255,255,0.16)");
    puff.addColorStop(0.5, "rgba(255,255,255,0)");
    puff.addColorStop(1, "rgba(0,0,0,0.22)");
    c.fillStyle = puff;
    c.fillRect(0, 0, W, H);
    c.globalCompositeOperation = "source-over";
    // thread map for the glint: same silhouette, striped
    const tc = threads.ctx;
    tc.save();
    tc.globalCompositeOperation = "destination-out";
    tc.drawImage(P.c, 0, 0);
    tc.restore();
    const T = makeCanvas(W, H);
    T.ctx.drawImage(P.c, 0, 0);
    T.ctx.globalCompositeOperation = "source-in";
    T.ctx.fillStyle = "#fff";
    T.ctx.fillRect(0, 0, W, H);
    T.ctx.globalCompositeOperation = "destination-out";
    satin(T.ctx, W, H, 0.9 + pd.rot, Math.max(2, 2.2 * r), 0.55);
    tc.drawImage(T.c, 0, 0);
    return P.c;
  });
  return { fabric: fab.c, patches, threads: threads.c, glint: makeCanvas(W, H), r };
}

function createScene(): Scene {
  let b: Built | null = null;
  let press = 0;
  let pressV = 0;
  let gx = 0.5;
  let hover = 0;

  return {
    resize(w, h, dpr) {
      b = build(w, h, Math.min(1.5, dpr));
    },
    cursor: () => "pointer",
    frame({ ctx, w, h, t, dt, p }) {
      if (!b) return;
      // press spring
      const target = p.down && p.inside ? 1 : 0;
      pressV += ((target - press) * 220 - pressV * 18) * Math.min(dt, 1 / 30);
      press += pressV * Math.min(dt, 1 / 30);
      hover = approach(hover, p.inside ? 1 : 0, 5, dt);
      // the glint follows the pointer when hovering, otherwise sweeps every few seconds
      const idle = ((t % 5.5) / 5.5) * 1.8 - 0.4;
      gx = approach(gx, p.inside ? p.x / w : idle, p.inside ? 7 : 30, dt);

      ctx.drawImage(b.fabric, 0, 0, w, h);
      const s = 1 - press * 0.018;
      for (const pc of b.patches) {
        ctx.save();
        ctx.translate(w / 2, h / 2);
        ctx.scale(s, s);
        ctx.translate(-w / 2, -h / 2);
        ctx.shadowColor = `rgba(0,0,0,${0.55 - press * 0.3})`;
        ctx.shadowBlur = 8 - press * 6;
        ctx.shadowOffsetX = 2 - press * 1.5;
        ctx.shadowOffsetY = 4 - press * 3;
        ctx.drawImage(pc, 0, 0, w, h);
        ctx.restore();
      }
      if (press > 0.02) {
        ctx.fillStyle = `rgba(20,14,28,${press * 0.18})`;
        ctx.fillRect(0, 0, w, h);
      }

      // glint: a light band clipped to the thread stripes
      const G = b.glint;
      const W = G.c.width;
      const H = G.c.height;
      G.ctx.globalCompositeOperation = "source-over";
      G.ctx.clearRect(0, 0, W, H);
      G.ctx.drawImage(b.threads, 0, 0);
      G.ctx.globalCompositeOperation = "source-in";
      const cx = gx * W;
      const band = W * 0.12;
      const gr = G.ctx.createLinearGradient(cx - band, H * 0.2, cx + band, H * 0.8);
      gr.addColorStop(0, "rgba(255,255,255,0)");
      gr.addColorStop(0.5, "rgba(255,250,235,0.9)");
      gr.addColorStop(1, "rgba(255,255,255,0)");
      G.ctx.fillStyle = gr;
      G.ctx.fillRect(0, 0, W, H);
      ctx.save();
      ctx.globalCompositeOperation = "lighter";
      ctx.globalAlpha = (0.3 + 0.4 * hover) * (1 - press * 0.7);
      ctx.translate(w / 2, h / 2);
      ctx.scale(s, s);
      ctx.translate(-w / 2, -h / 2);
      ctx.drawImage(G.c, 0, 0, w, h);
      ctx.restore();
    },
  };
}

export default function EmbroideredPatches() {
  const ref = useStage(createScene, 2);
  return <div ref={ref} className="relative h-full w-full overflow-hidden bg-[#2a2233]" />;
}
Prompt

Build "Embroidered Word Patches", a text animation. What it does: Embroidered made / with / love patches stitched onto dark fabric; hovering sends a glint across the threads and pressing pushes them into the cloth. 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 dark 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).