# Gift Card with a Silver Scratch off Panel, a Brass Coin and Foil Crumbs That Scatter as You Rub

> Scratch card on a canvas: rub the silver panel of a gift card with a brass coin and the foil comes off in streaks and crumbs. Free React and HTML to copy.

A shadcn/ui block from the [@weinc](https://we.inc/components?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/weinc/gift-card-with-a-silver-scratch-off-panel-a-brass-coin-and-foil-crumbs-that-scatter-as-you-rub).

- Page, with a live preview: https://shadcn.exchange/r/weinc/gift-card-with-a-silver-scratch-off-panel-a-brass-coin-and-foil-crumbs-that-scatter-as-you-rub
- Screenshot: https://shadcn.exchange/shot/weinc/gift-card-with-a-silver-scratch-off-panel-a-brass-coin-and-foil-crumbs-that-scatter-as-you-rub/light.png
- Registry item: https://we.inc/r/gift-card-with-a-silver-scratch-off-panel-a-brass-coin-and-foil-crumbs-that-scatter-as-you-rub.json
- Listed: 2026-10-28, source last changed 2026-10-28

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/weinc/gift-card-with-a-silver-scratch-off-panel-a-brass-coin-and-foil-crumbs-that-scatter-as-you-rub.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Files

### `components/we-inc/gift-card-with-a-silver-scratch-off-panel-a-brass-coin-and-foil-crumbs-that-scatter-as-you-rub.tsx`

```tsx
import { useEffect, useRef, useState } from "react";

// The card. `codes` are handed out one per card dealt, then start again.
const gift = {
  shop: "Fenn & Marlowe",
  trade: "FLORISTS",
  amount: "$60",
  line: "of seasonal stems, tied and delivered",
  hint: "SCRATCH FOR YOUR CODE",
  coin: "FM",
  photo: "https://images.pexels.com/photos/11731228/pexels-photo-11731228.jpeg?auto=compress&cs=tinysrgb&w=1200",
  alt: "Pink, yellow and white ranunculus heads laid together on a dark cloth",
  tone: "#3a2b2f",
  codes: ["FM-7Q4K-92TD", "FM-3HX8-51LW", "FM-9BN2-06RC"],
};
const W = 460; // card size in CSS pixels
const H = 292;
const PANEL = { x: 206, y: 213, w: 232, h: 54 }; // the foil, measured from the card's top left
const TILT = -5; // degrees the card lies at on the table
const MARGIN = 150; // how far crumbs may travel past the card edge
const START_FILLED = 0.56; // how much of the panel is already scratched when the page loads

type Crumb = { x: number; y: number; vx: number; vy: number; r: number; a: number; va: number; curl: number; shade: number; flake: boolean; still: boolean };

export default function ScratchOffGiftCard() {
  const root = useRef<HTMLDivElement>(null);
  const fitBox = useRef<HTMLDivElement>(null);
  const cardEl = useRef<HTMLDivElement>(null);
  const foil = useRef<HTMLCanvasElement>(null);
  const crumbsEl = useRef<HTMLCanvasElement>(null);
  const coinEl = useRef<HTMLDivElement>(null);
  const [scale, setScale] = useState(1);
  const [deal, setDeal] = useState(0);
  const [revealed, setRevealed] = useState(false);
  const api = useRef<{ rub: () => void; fresh: () => void } | null>(null);
  const code = gift.codes[deal % gift.codes.length];

  useEffect(() => {
    const el = root.current, box = fitBox.current, cv = foil.current, cc = crumbsEl.current, coin = coinEl.current, cardNode = cardEl.current;
    if (!el || !box || !cv || !cc || !coin || !cardNode) return;
    const ctx = cv.getContext("2d"), cx = cc.getContext("2d");
    if (!ctx || !cx) return;
    const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const d = Math.min(2, window.devicePixelRatio || 1);
    const PW = Math.round(PANEL.w * d), PH = Math.round(PANEL.h * d);
    cv.width = PW; cv.height = PH;
    const CW = W + MARGIN * 2, CH = H + MARGIN * 2;
    cc.width = Math.round(CW * d); cc.height = Math.round(CH * d);
    let seed = 23;
    const rand = () => { seed = (seed * 16807) % 2147483647; return seed / 2147483647; };
    const make = (w: number, h: number) => { const c = document.createElement("canvas"); c.width = w; c.height = h; return c; };

    // The foil as printed: dull silver, a wave pattern and tiny repeated lettering.
    const base = make(PW, PH), b = base.getContext("2d");
    if (b) {
      b.scale(d, d);
      const g = b.createLinearGradient(0, 0, PANEL.w, PANEL.h);
      g.addColorStop(0, "#cfd2d6"); g.addColorStop(0.3, "#9da1a8"); g.addColorStop(0.55, "#c6c9ce"); g.addColorStop(0.8, "#8f939b"); g.addColorStop(1, "#b4b8be");
      b.fillStyle = g; b.fillRect(0, 0, PANEL.w, PANEL.h);
      for (let i = 0; i < 2600; i++) { const v = rand(); b.fillStyle = v > 0.5 ? `rgba(255,255,255,${(v - 0.5) * 0.5})` : `rgba(40,44,52,${(0.5 - v) * 0.36})`; b.fillRect(rand() * PANEL.w, rand() * PANEL.h, 0.7, 0.7); }
      b.lineWidth = 0.6; b.strokeStyle = "rgba(70,76,86,0.34)";
      for (let y = -4; y < PANEL.h + 6; y += 4.5) {
        b.beginPath();
        for (let x = 0; x <= PANEL.w; x += 3) { const yy = y + Math.sin(x * 0.11 + y * 0.6) * 2.2; if (x === 0) b.moveTo(x, yy); else b.lineTo(x, yy); }
        b.stroke();
      }
      b.font = '700 6px "Helvetica Neue",Helvetica,Arial,sans-serif'; b.fillStyle = "rgba(58,64,74,0.5)"; b.textBaseline = "middle";
      for (let row = 0; row < 5; row++) for (let x = -30 + (row % 2) * 22; x < PANEL.w; x += 46) b.fillText("S C R A T C H", x, 7 + row * 10.5);
    }
    const mask = make(PW, PH), m = mask.getContext("2d");
    const probe = make(29, 7), pr = probe.getContext("2d", { willReadFrequently: true });
    if (!m || !pr) return;
    m.scale(d, d); m.lineCap = "round"; m.strokeStyle = "#000"; m.fillStyle = "#000";

    const s = {
      coinX: W + 34, coinY: H - 8, toX: W + 34, toY: H - 8, press: 0, pressTo: 0,
      down: false, lastX: 0, lastY: 0, lastUser: -1e9, hover: false,
      path: [] as [number, number][], seg: 0, along: 0, wait: 0, startAfter: 3.3, doneFor: 0, done: false, dealing: false,
      crumbs: [] as Crumb[], crumbsDirty: true, foilDirty: true, sheen: 0.3, t: 0, checkIn: 0,
    };
    // The route the coin takes by itself: a tight zigzag from left to right, in panel coordinates.
    const route = () => {
      const pts: [number, number][] = [];
      for (let i = 0, x = 7; x < PANEL.w - 3; i++, x += 10.5) pts.push([x + (rand() - 0.5) * 5, i % 2 ? PANEL.h - 3 - rand() * 7 : 3 + rand() * 7]);
      return pts;
    };

    const spawn = (x: number, y: number, dx: number, dy: number, settle: boolean) => {
      const len = Math.hypot(dx, dy) || 1, n = Math.max(1, Math.round(len / 13));
      for (let i = 0; i < n; i++) {
        const sp = 130 + rand() * 330, side = (rand() - 0.5) * 300;
        const c: Crumb = { x: PANEL.x + x + (rand() - 0.5) * 10, y: PANEL.y + y + (rand() - 0.5) * 10, vx: (dx / len) * sp - (dy / len) * side, vy: (dy / len) * sp + (dx / len) * side, r: 0.9 + rand() * 2.6, a: rand() * 6.3, va: (rand() - 0.5) * 22, curl: 0.9 + rand() * 2.6, shade: rand(), flake: rand() < 0.6, still: false };
        if (settle) { c.x += c.vx / 5.5; c.y += c.vy / 5.5; c.a += c.va / 5.5; c.still = true; }
        s.crumbs.push(c);
      }
      if (s.crumbs.length > 240) s.crumbs.splice(0, s.crumbs.length - 240);
      s.crumbsDirty = true;
    };
    // One rub of the coin from a to b: a ragged core, streaks beside it and loose specks.
    const rub = (x0: number, y0: number, x1: number, y1: number, settle: boolean) => {
      const dx = x1 - x0, dy = y1 - y0, len = Math.hypot(dx, dy) || 1, nx = -dy / len, ny = dx / len;
      for (let k = 0; k < 3; k++) {
        const o = (rand() - 0.5) * 9;
        m.globalAlpha = 1; m.lineWidth = 9 + rand() * 8;
        m.beginPath(); m.moveTo(x0 + nx * o, y0 + ny * o); m.lineTo(x1 + nx * o, y1 + ny * o); m.stroke();
      }
      for (let k = 0; k < 9; k++) {
        const o = (rand() - 0.5) * 30, t0 = rand() * 0.3, t1 = 0.7 + rand() * 0.3;
        m.globalAlpha = 0.55 + rand() * 0.45; m.lineWidth = 0.8 + rand() * 1.8;
        m.beginPath(); m.moveTo(x0 + dx * t0 + nx * o, y0 + dy * t0 + ny * o); m.lineTo(x0 + dx * t1 + nx * o, y0 + dy * t1 + ny * o); m.stroke();
      }
      m.globalAlpha = 1;
      for (let k = 0; k < 5; k++) { const t = rand(), o = (rand() - 0.5) * 28; m.beginPath(); m.arc(x0 + dx * t + nx * o, y0 + dy * t + ny * o, 0.5 + rand() * 1.3, 0, 6.3); m.fill(); }
      s.foilDirty = true;
      spawn(x1, y1, dx, dy, settle);
    };
    const cleared = () => {
      pr.clearRect(0, 0, 29, 7); pr.drawImage(mask, 0, 0, 29, 7);
      const px = pr.getImageData(0, 0, 29, 7).data; let sum = 0;
      for (let i = 3; i < px.length; i += 4) sum += px[i];
      return sum / (255 * 29 * 7);
    };
    const finish = () => { if (!s.done) { s.done = true; s.doneFor = 0; setRevealed(true); } };

    const drawFoil = () => {
      s.foilDirty = false;
      ctx.globalCompositeOperation = "source-over"; ctx.clearRect(0, 0, PW, PH); ctx.drawImage(base, 0, 0);
      const c = s.sheen * PW, g = ctx.createLinearGradient(c - PW * 0.3, 0, c + PW * 0.3, PH);
      g.addColorStop(0, "rgba(255,255,255,0)"); g.addColorStop(0.5, "rgba(255,255,255,0.85)"); g.addColorStop(1, "rgba(255,255,255,0)");
      ctx.globalCompositeOperation = "soft-light"; ctx.fillStyle = g; ctx.fillRect(0, 0, PW, PH);
      ctx.globalCompositeOperation = "destination-out"; ctx.drawImage(mask, 0, 0);
    };
    const drawCrumbs = () => {
      s.crumbsDirty = false;
      cx.setTransform(d, 0, 0, d, MARGIN * d, MARGIN * d);
      cx.clearRect(-MARGIN, -MARGIN, CW, CH);
      cx.lineCap = "round";
      for (const c of s.crumbs) {
        const v = 112 + c.shade * 84, tone = `rgb(${v | 0},${(v + 3) | 0},${(v + 8) | 0})`;
        if (c.flake) {
          // A flat flake: a small lopsided four-sided chip with a shadow under one edge.
          const cos = Math.cos(c.a), sin = Math.sin(c.a), w = c.r * 0.95, h = c.r * (0.3 + c.curl * 0.16);
          const chip = (ox: number, oy: number) => { cx.beginPath(); cx.moveTo(c.x + ox - cos * w - sin * h, c.y + oy - sin * w + cos * h); cx.lineTo(c.x + ox + cos * w - sin * h * 0.4, c.y + oy + sin * w + cos * h * 0.4); cx.lineTo(c.x + ox + cos * w * 0.7 + sin * h, c.y + oy + sin * w * 0.7 - cos * h); cx.lineTo(c.x + ox - cos * w * 0.8 + sin * h * 0.7, c.y + oy - sin * w * 0.8 - cos * h * 0.7); cx.closePath(); cx.fill(); };
          cx.fillStyle = "rgba(40,30,20,0.24)"; chip(0.7, 1);
          cx.fillStyle = tone; chip(0, 0);
        } else {
          cx.lineWidth = 1.2; cx.strokeStyle = "rgba(40,30,20,0.24)";
          cx.beginPath(); cx.arc(c.x + 0.8, c.y + 1.1, c.r, c.a, c.a + c.curl); cx.stroke();
          cx.lineWidth = 1; cx.strokeStyle = tone;
          cx.beginPath(); cx.arc(c.x, c.y, c.r, c.a, c.a + c.curl); cx.stroke();
          cx.lineWidth = 0.5; cx.strokeStyle = "rgba(255,255,255,0.55)";
          cx.beginPath(); cx.arc(c.x, c.y, c.r, c.a + c.curl * 0.3, c.a + c.curl * 0.5); cx.stroke();
        }
      }
    };
    const placeCoin = () => {
      const p = s.press;
      coin.style.transform = `translate(${(s.coinX - 7).toFixed(1)}px, ${(s.coinY - 8).toFixed(1)}px) rotate(${(-TILT + (s.coinX - s.toX) * 0.25).toFixed(2)}deg) scale(${(1 - p * 0.05).toFixed(3)})`;
      coin.style.filter = `drop-shadow(${(7 - p * 4).toFixed(1)}px ${(11 - p * 6).toFixed(1)}px ${(9 - p * 5).toFixed(1)}px rgba(52,34,12,${(0.34 + p * 0.14).toFixed(2)}))`;
    };

    const reset = (filled: number, settle: boolean) => {
      m.clearRect(0, 0, PANEL.w, PANEL.h);
      s.crumbs = []; s.path = route(); s.seg = 0; s.along = 0; s.wait = 0; s.done = false; s.doneFor = 0;
      const upto = Math.floor((s.path.length - 1) * filled);
      for (let i = 0; i < upto; i++) rub(s.path[i][0], s.path[i][1], s.path[i + 1][0], s.path[i + 1][1], true);
      s.seg = upto;
      if (filled >= 1) { s.done = true; s.toX = W + 34; s.toY = H - 8; }
      else if (upto > 0) { s.toX = PANEL.x + s.path[upto][0]; s.toY = PANEL.y + s.path[upto][1]; }
      else { s.toX = W + 34; s.toY = H - 8; }
      if (settle) { s.coinX = s.toX; s.coinY = s.toY; }
      s.foilDirty = true; s.crumbsDirty = true;
    };
    reset(reduce ? 1 : START_FILLED, true);
    if (reduce) setRevealed(true);

    const toLocal = (clientX: number, clientY: number) => {
      const r = box.getBoundingClientRect(), k = r.width / W;
      const dx = (clientX - (r.left + r.width / 2)) / k, dy = (clientY - (r.top + r.height / 2)) / k;
      const a = (TILT * Math.PI) / 180, cos = Math.cos(a), sin = Math.sin(a);
      return [dx * cos + dy * sin + W / 2, -dx * sin + dy * cos + H / 2];
    };

    const dealNew = () => {
      if (s.dealing) return;
      s.dealing = true; s.toX = W + 34; s.toY = H - 8;
      const next = () => { setDeal((n) => n + 1); setRevealed(false); reset(0, false); s.startAfter = 1.3; };
      if (reduce) { next(); s.dealing = false; paint(); return; }
      cc.animate([{ opacity: 1 }, { opacity: 0 }], { duration: 300, fill: "forwards" });
      const out = cardNode.animate([{ transform: "translate(0,0) rotate(0deg)" }, { transform: "translate(-150%,-20px) rotate(-9deg)" }], { duration: 460, easing: "cubic-bezier(.5,0,.9,.4)", fill: "forwards" });
      out.onfinish = () => {
        next();
        cc.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 10, fill: "forwards" });
        const back = cardNode.animate([{ transform: "translate(150%,24px) rotate(9deg)" }, { transform: "translate(0,0) rotate(0deg)" }], { duration: 620, easing: "cubic-bezier(.16,1,.3,1)", fill: "forwards" });
        back.onfinish = () => { s.dealing = false; };
      };
    };

    const paint = () => { if (s.foilDirty) drawFoil(); if (s.crumbsDirty) drawCrumbs(); placeCoin(); };

    let raf = 0, last = 0, visible = true;
    const frame = (now: number) => {
      raf = 0;
      const dt = Math.min(0.05, last ? (now - last) / 1000 : 0.016); last = now; s.t += dt;
      const userIdle = now - s.lastUser > 3600 && !s.down;
      if (!reduce && userIdle && !s.dealing) {
        if (s.done) { s.doneFor += dt; s.pressTo = 0; s.toX = W + 34; s.toY = H - 8; if (s.doneFor > 4.2) dealNew(); }
        else {
          s.wait += dt;
          if (s.wait > s.startAfter && s.seg < s.path.length - 1) {
            let travel = 250 * dt; s.pressTo = 1;
            while (travel > 0 && s.seg < s.path.length - 1) {
              const [ax, ay] = s.path[s.seg], [bx, by] = s.path[s.seg + 1], len = Math.hypot(bx - ax, by - ay);
              const from = s.along, to = Math.min(len, from + travel);
              rub(ax + ((bx - ax) * from) / len, ay + ((by - ay) * from) / len, ax + ((bx - ax) * to) / len, ay + ((by - ay) * to) / len, false);
              s.toX = PANEL.x + ax + ((bx - ax) * to) / len; s.toY = PANEL.y + ay + ((by - ay) * to) / len;
              travel -= to - from; s.along = to;
              if (to >= len) { s.seg++; s.along = 0; }
            }
            s.coinX = s.toX; s.coinY = s.toY;
            if (s.seg >= s.path.length - 1) finish();
          } else if (s.seg === 0) { s.toX = W + 34; s.toY = H - 8; }
        }
      }
      const k = Math.min(1, dt * (s.down ? 30 : 12));
      s.coinX += (s.toX - s.coinX) * k; s.coinY += (s.toY - s.coinY) * k;
      s.press += (s.pressTo - s.press) * Math.min(1, dt * 14);
      const sh = Math.max(0, Math.min(1, (s.coinX - PANEL.x) / PANEL.w)) * 0.8 + 0.1 + Math.sin(s.t * 0.7) * 0.06;
      if (Math.abs(sh - s.sheen) > 0.004) { s.sheen = sh; s.foilDirty = true; }
      let moving = false; const f = Math.exp(-dt * 5.5);
      for (const c of s.crumbs) {
        if (c.still) continue;
        c.x += c.vx * dt; c.y += c.vy * dt; c.a += c.va * dt; c.vx *= f; c.vy *= f; c.va *= f;
        if (Math.abs(c.vx) + Math.abs(c.vy) < 3) c.still = true;
        moving = true;
      }
      if (moving) s.crumbsDirty = true;
      paint();
      if (visible && !document.hidden) raf = requestAnimationFrame(frame);
    };
    const wake = () => { if (!raf && !reduce) { last = 0; raf = requestAnimationFrame(frame); } };

    const inPanel = (x: number, y: number) => x > PANEL.x - 6 && x < PANEL.x + PANEL.w + 6 && y > PANEL.y - 6 && y < PANEL.y + PANEL.h + 6;
    const down = (e: PointerEvent) => {
      const [x, y] = toLocal(e.clientX, e.clientY);
      s.lastUser = performance.now();
      if (!inPanel(x, y) || s.dealing) return;
      s.down = true; s.pressTo = 1; s.lastX = x; s.lastY = y; s.toX = x; s.toY = y;
      el.setPointerCapture(e.pointerId); e.preventDefault(); wake();
    };
    const move = (e: PointerEvent) => {
      const [x, y] = toLocal(e.clientX, e.clientY);
      if (e.pointerType === "mouse" || s.down) { s.lastUser = performance.now(); s.toX = x; s.toY = y; if (!s.down) s.pressTo = 0; }
      if (!s.down) return;
      if (Math.hypot(x - s.lastX, y - s.lastY) < 2.5) return;
      if (inPanel(x, y) || inPanel(s.lastX, s.lastY)) {
        rub(s.lastX - PANEL.x, s.lastY - PANEL.y, x - PANEL.x, y - PANEL.y, reduce);
        if ((s.checkIn = (s.checkIn + 1) % 5) === 0 && cleared() > 0.62) finish();
      }
      s.lastX = x; s.lastY = y;
      if (reduce) { s.coinX = x; s.coinY = y; paint(); }
    };
    const up = () => { if (s.down) { s.down = false; s.pressTo = 0; if (cleared() > 0.62) finish(); } };
    el.addEventListener("pointerdown", down);
    el.addEventListener("pointermove", move);
    el.addEventListener("pointerup", up);
    el.addEventListener("pointercancel", up);

    api.current = {
      // The keyboard route: each press rubs a third of the panel.
      rub: () => {
        if (s.done || s.dealing) return;
        s.lastUser = performance.now();
        const stop = Math.min(s.path.length - 1, s.seg + Math.ceil(s.path.length / 3));
        for (; s.seg < stop; s.seg++) rub(s.path[s.seg][0], s.path[s.seg][1], s.path[s.seg + 1][0], s.path[s.seg + 1][1], reduce);
        s.along = 0; s.toX = PANEL.x + s.path[s.seg][0]; s.toY = PANEL.y + s.path[s.seg][1];
        if (s.seg >= s.path.length - 1) finish();
        if (reduce) { s.coinX = s.toX; s.coinY = s.toY; paint(); } else wake();
      },
      fresh: () => { s.lastUser = performance.now(); dealNew(); wake(); },
    };

    const fit = () => setScale(Math.min(1.25, (el.clientWidth - 56) / (W + 26)));
    fit();
    const ro = new ResizeObserver(fit); ro.observe(el);
    const io = new IntersectionObserver(([en]) => { visible = en.isIntersecting; if (visible) wake(); });
    io.observe(el);
    const vis = () => { if (!document.hidden) wake(); };
    document.addEventListener("visibilitychange", vis);
    paint(); wake();

    return () => {
      cancelAnimationFrame(raf); ro.disconnect(); io.disconnect();
      document.removeEventListener("visibilitychange", vis);
      el.removeEventListener("pointerdown", down); el.removeEventListener("pointermove", move);
      el.removeEventListener("pointerup", up); el.removeEventListener("pointercancel", up);
      api.current = null;
    };
  }, []);

  return (
    <div ref={root} className="relative flex min-h-[660px] w-full select-none items-center justify-center overflow-hidden bg-[#e9dfcf] py-20">
      <style>{`
        .gift-card-scratch-gold { background-image: linear-gradient(100deg, #8a6420 0%, #e9c877 22%, #fff3c4 36%, #c79a3d 50%, #7d5a1b 64%, #e2bd66 82%, #9a7227 100%); -webkit-background-clip: text; background-clip: text; color: transparent; }
        .gift-card-scratch-milled { background: repeating-conic-gradient(#6b4a12 0deg 1.5deg, #f0cf7c 1.5deg 3deg); }
      `}</style>
      <div aria-hidden="true" className="pointer-events-none absolute inset-0" style={{ background: "radial-gradient(70% 60% at 38% 30%, #f6efe2 0%, #e9dfcf 55%, #d6c9b4 100%)" }} />

      <div ref={fitBox} className="relative" style={{ width: W * scale, height: H * scale }}>
        <div className="absolute left-1/2 top-1/2" style={{ width: W, height: H, marginLeft: -W / 2, marginTop: -H / 2, transform: `scale(${scale}) rotate(${TILT}deg)` }}>
          <div ref={cardEl} className="absolute inset-0">
            {/* Thickness: three stacked edges under the face, then the soft shadows of a card lying on a table. */}
            <div className="absolute inset-0 overflow-hidden rounded-[16px] bg-[#f6f0e4]" style={{ boxShadow: "0 1px 0 #e3d8c6, 0 2px 0 #d2c6b1, 0 3.5px 0 #b7a98f, 0 5px 3px rgba(60,40,15,0.3), 0 18px 28px -6px rgba(70,48,20,0.34), 0 44px 60px -18px rgba(70,48,20,0.3)" }}>
              <div className="absolute inset-x-0 top-0 h-[190px]" style={{ backgroundColor: gift.tone }}>
                <img src={gift.photo} alt={gift.alt} width={460} height={190} loading="eager" draggable={false} className="h-full w-full select-none object-cover" style={{ objectPosition: "50% 46%" }} />
                <div aria-hidden="true" className="absolute inset-0" style={{ background: "linear-gradient(180deg, rgba(20,10,14,0.05) 30%, rgba(20,10,14,0.72) 100%)" }} />
                <div aria-hidden="true" className="absolute inset-0" style={{ background: "linear-gradient(112deg, rgba(255,255,255,0.22) 0%, rgba(255,255,255,0) 26%, rgba(255,255,255,0) 60%, rgba(255,255,255,0.09) 78%, rgba(255,255,255,0) 100%)" }} />
                <div className="absolute bottom-[14px] left-[24px]">
                  <p className="relative font-serif text-[31px] leading-none tracking-[-0.01em]">
                    <span aria-hidden="true" className="absolute inset-0 text-black/60" style={{ transform: "translate(0.6px,1.1px)" }}>{gift.shop}</span>
                    <span className="gift-card-scratch-gold relative">{gift.shop}</span>
                  </p>
                </div>
                <p className="gift-card-scratch-gold absolute bottom-[19px] right-[24px] text-[8px] font-bold tracking-[0.34em]">{gift.trade}</p>
              </div>
              <div aria-hidden="true" className="absolute inset-x-0 top-[190px] h-px bg-[#b89243]" />
              <div className="absolute left-[24px] top-[207px]">
                <p className="text-[7.5px] font-bold tracking-[0.3em] text-[#6b5a3e]">GIFT CARD</p>
                <p className="mt-[3px] font-serif text-[36px] leading-none text-[#231c14]">{gift.amount}</p>
                <p className="mt-[5px] w-[150px] text-[8.5px] leading-[1.35] text-[#5d4f38]">{gift.line}</p>
              </div>
              <p className="absolute text-[6.5px] font-bold tracking-[0.3em] text-[#6b5a3e]" style={{ left: PANEL.x + 1, top: PANEL.y - 13 }}>{gift.hint}</p>
              <div className="absolute overflow-hidden rounded-[5px]" style={{ left: PANEL.x, top: PANEL.y, width: PANEL.w, height: PANEL.h, backgroundColor: "#fbf8f1", backgroundImage: "repeating-linear-gradient(135deg, rgba(120,100,60,0.09) 0 1px, transparent 1px 5px)", boxShadow: "inset 0 0 0 1px rgba(90,70,40,0.28)" }}>
                <p className="flex h-full items-center justify-center font-mono text-[17px] font-bold tracking-[0.14em] text-[#231c14]" aria-hidden={!revealed}>{code}</p>
                <canvas ref={foil} aria-hidden="true" className="absolute inset-0 h-full w-full" />
                <div aria-hidden="true" className="pointer-events-none absolute inset-0 rounded-[5px]" style={{ boxShadow: "inset 0 1px 0 rgba(255,255,255,0.5), inset 0 -1px 0 rgba(0,0,0,0.18)" }} />
              </div>
              <button
                type="button"
                onClick={(e) => { if (e.detail === 0) api.current?.rub(); }}
                aria-label={revealed ? `Scratch panel, already scratched. The code is ${code}.` : "Scratch panel. Press to rub some of the foil away."}
                className="absolute cursor-grab touch-none rounded-[5px] outline-none focus-visible:ring-2 focus-visible:ring-[#231c14] focus-visible:ring-offset-2 active:cursor-grabbing"
                style={{ left: PANEL.x, top: PANEL.y, width: PANEL.w, height: PANEL.h }}
              />
            </div>
          </div>
          <canvas ref={crumbsEl} aria-hidden="true" className="pointer-events-none absolute" style={{ left: -MARGIN, top: -MARGIN, width: W + MARGIN * 2, height: H + MARGIN * 2 }} />
          {/* The coin. It is drawn to the lower right of the point it rubs with. */}
          <div ref={coinEl} aria-hidden="true" className="pointer-events-none absolute left-0 top-0 h-[66px] w-[66px]" style={{ willChange: "transform" }}>
            <div className="gift-card-scratch-milled absolute inset-0 rounded-full" />
            <div className="absolute inset-[2.5px] rounded-full" style={{ background: "conic-gradient(from 210deg, #f7dd92, #b8872c 14%, #f3d481 30%, #8d6419 46%, #e6c26a 62%, #a87a25 78%, #f7dd92)" }} />
            <div className="absolute inset-[7px] rounded-full" style={{ background: "radial-gradient(circle at 34% 28%, #f6dc94 0%, #d3a747 46%, #a87a26 100%)", boxShadow: "inset 0 1.5px 1px rgba(255,255,255,0.6), inset 0 -2px 2px rgba(80,50,5,0.5), 0 0 0 0.6px rgba(80,50,5,0.6)" }} />
            <div className="absolute inset-[11px] rounded-full border border-dashed border-[#7a5615]/60" />
            <p className="absolute inset-0 flex items-center justify-center font-serif text-[21px] font-bold text-[#8a6218]" style={{ textShadow: "0 1px 0 rgba(255,240,190,0.9), 0 -0.8px 0 rgba(70,44,4,0.7)" }}>{gift.coin}</p>
          </div>
        </div>
      </div>

      <p className="sr-only" aria-live="polite">{revealed ? `Code revealed: ${code}` : ""}</p>
      <div className="absolute bottom-6 left-7 right-7 flex items-end justify-between gap-4">
        <p className="pointer-events-none font-mono text-[10px] font-semibold uppercase leading-5 tracking-[0.22em] text-[#231c14]">
          {gift.shop} gift card
          <span className="block font-medium text-[#5d4f38]">Rub the silver panel with the coin</span>
        </p>
        <button type="button" onClick={() => api.current?.fresh()} className="min-h-[40px] shrink-0 whitespace-nowrap rounded-full border border-[#231c14]/25 bg-[#f6f0e4]/70 px-4 font-mono text-[10px] font-semibold uppercase tracking-[0.2em] text-[#231c14] outline-none transition-colors hover:bg-[#f6f0e4] focus-visible:ring-2 focus-visible:ring-[#231c14]">
          New card
        </button>
      </div>
    </div>
  );
}
```
