Baktiyar Ablaikhan

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