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).