Embossed Plaster Panels
- Source
- arlan.me/vault ↗
- Author
- Arlan
- Licence
- MIT
Loading…
Three coloured plaster panels with words pressed into them — a soft deboss, a hard letterpress and a raised emboss — relit as the light follows the pointer.
Code
"use client";
import { approach, makeCanvas, mulberry32, useStage, type Scene } from "./stage";
/*
* Three coloured plaster panels with words pressed into them, each a different
* emboss: a soft deboss, a hard letterpress and a raised emboss. The light
* follows the pointer (or circles slowly), so the relief shifts as it moves.
*/
type Style = "deboss" | "press" | "emboss";
const PANELS: { word: string; color: string; style: Style }[] = [
{ word: "beep", color: "#cfd0d4", style: "deboss" },
{ word: "bonk", color: "#ffd83d", style: "press" },
{ word: "boop", color: "#8f80f4", style: "emboss" },
];
type Cv = { c: HTMLCanvasElement; ctx: CanvasRenderingContext2D };
type Built = { size: number; r: number; masks: Cv[]; invs: Cv[]; grain: Cv; scratch: Cv; panel: Cv };
function createScene(): Scene {
let b: Built | null = null;
const light = { x: -0.7, y: -0.7 };
const build = (w: number, h: number, dpr: number): Built => {
const r = Math.min(2, dpr);
const size = Math.max(1, Math.round(Math.min((w * 0.9) / 3.2, h * 0.62)));
const S = Math.round(size * r);
const font = `900 ${S * 0.3}px "Arial Rounded MT Bold", "Nunito", "Arial Black", Arial, sans-serif`;
const masks: Cv[] = [];
const invs: Cv[] = [];
for (const pd of PANELS) {
const m = makeCanvas(S, S);
m.ctx.font = font;
m.ctx.textAlign = "center";
m.ctx.textBaseline = "middle";
m.ctx.fillStyle = "#000";
m.ctx.fillText(pd.word, S / 2, S / 2);
const inv = makeCanvas(S, S);
inv.ctx.fillStyle = "#000";
inv.ctx.fillRect(-S, -S, S * 3, S * 3);
inv.ctx.globalCompositeOperation = "destination-out";
inv.ctx.drawImage(m.c, 0, 0);
masks.push(m);
invs.push(inv);
}
const grain = makeCanvas(S, S);
const img = grain.ctx.createImageData(S, S);
const rnd = mulberry32(13);
for (let i = 0; i < img.data.length; i += 4) {
const v = 205 + rnd() * 50;
img.data[i] = v;
img.data[i + 1] = v;
img.data[i + 2] = v;
img.data[i + 3] = 255;
}
grain.ctx.putImageData(img, 0, 0);
return { size, r, masks, invs, grain, scratch: makeCanvas(S, S), panel: makeCanvas(S, S) };
};
/** Blurred, offset copy of `src` in `color`, clipped in or out of `mask`, painted onto `dst`. */
const layer = (B: Built, dst: CanvasRenderingContext2D, src: HTMLCanvasElement, mask: HTMLCanvasElement, dx: number, dy: number, blur: number, color: string, clip: "in" | "out") => {
const T = B.scratch.ctx;
const S = B.scratch.c.width;
T.globalCompositeOperation = "source-over";
T.clearRect(0, 0, S, S);
T.save();
T.shadowColor = color;
T.shadowBlur = blur;
T.shadowOffsetX = S * 3 + dx;
T.shadowOffsetY = dy;
T.drawImage(src, -S * 3, 0);
T.restore();
T.globalCompositeOperation = clip === "in" ? "destination-in" : "destination-out";
T.drawImage(mask, 0, 0);
dst.drawImage(B.scratch.c, 0, 0);
};
return {
resize(w, h, dpr) {
b = build(w, h, dpr);
},
frame({ ctx, w, h, t, dt, p }) {
ctx.fillStyle = "#fbfbfb";
ctx.fillRect(0, 0, w, h);
if (!b) return;
const B = b;
const size = B.size;
const S = B.panel.c.width;
const gap = (w - size * 3) / 4;
const top = (h - size) / 2;
// light direction: toward the pointer, else a slow orbit
let lx = Math.cos(t * 0.45 + 3.9);
let ly = Math.sin(t * 0.45 + 3.9) * 0.8 - 0.3;
if (p.inside) {
lx = (p.x - w / 2) / (w / 2);
ly = (p.y - h / 2) / (h / 2);
}
const ll = Math.hypot(lx, ly) || 1;
light.x = approach(light.x, lx / Math.max(1, ll), 5, dt);
light.y = approach(light.y, ly / Math.max(1, ll), 5, dt);
PANELS.forEach((pd, i) => {
const P = B.panel.ctx;
P.globalCompositeOperation = "source-over";
P.fillStyle = pd.color;
P.fillRect(0, 0, S, S);
P.globalCompositeOperation = "multiply";
P.globalAlpha = 0.55;
P.drawImage(B.grain.c, 0, 0);
P.globalAlpha = 1;
P.globalCompositeOperation = "source-over";
const mask = B.masks[i].c;
const inv = B.invs[i].c;
const d = S * 0.022;
const ax = -light.x * d;
const ay = -light.y * d;
if (pd.style === "deboss") {
layer(B, P, inv, mask, ax, ay, S * 0.03, "rgba(40,40,50,0.6)", "in");
layer(B, P, inv, mask, -ax, -ay, S * 0.025, "rgba(255,255,255,0.75)", "in");
} else if (pd.style === "press") {
P.globalAlpha = 0.18;
P.globalCompositeOperation = "multiply";
P.drawImage(mask, 0, 0);
P.globalAlpha = 1;
P.globalCompositeOperation = "source-over";
layer(B, P, inv, mask, ax * 1.6, ay * 1.6, S * 0.006, "rgba(90,60,0,0.55)", "in");
layer(B, P, inv, mask, -ax * 0.9, -ay * 0.9, S * 0.004, "rgba(255,255,240,0.7)", "in");
} else {
layer(B, P, mask, mask, ax * 1.2, ay * 1.2, S * 0.035, "rgba(30,20,90,0.5)", "out");
layer(B, P, inv, mask, ax, ay, S * 0.02, "rgba(255,255,255,0.65)", "in");
layer(B, P, inv, mask, -ax, -ay, S * 0.02, "rgba(40,25,120,0.45)", "in");
}
// soft vignette so each panel reads as a slab
const vg = P.createRadialGradient(S / 2, S / 2, S * 0.3, S / 2, S / 2, S * 0.75);
vg.addColorStop(0, "rgba(255,255,255,0)");
vg.addColorStop(1, "rgba(0,0,0,0.12)");
P.fillStyle = vg;
P.fillRect(0, 0, S, S);
const x = gap + i * (size + gap);
ctx.save();
ctx.shadowColor = "rgba(0,0,0,0.12)";
ctx.shadowBlur = 14;
ctx.shadowOffsetY = 4;
ctx.drawImage(B.panel.c, x, top, size, size);
ctx.restore();
});
},
};
}
export default function PlasterPanels() {
const ref = useStage(createScene, 1);
return <div ref={ref} className="relative h-full w-full overflow-hidden bg-[#fbfbfb]" />;
}
Prompt
Build "Embossed Plaster Panels", a card animation. What it does: Three coloured plaster panels with words pressed into them — a soft deboss, a hard letterpress and a raised emboss — relit as the light follows the pointer. 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 light 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).