Impeccable

Scratch-me

A scratch-off foil hides a promo code that copies itself once revealed. Use it on a pricing, upgrade or thank-you page for one special offer.

InteractiveNo extra packagesLight + darkMobile
IMPECCABLE20

Scratch to reveal your code.

npx shadcn@latest add https://impeccableui.vercel.app/r/scratch-me.json
Scratch past half and let go. Keyboard: Tab to the card and press Enter. With reduced motion on, there are no shavings and the foil simply fades.

Run it in a project set up with shadcn. No other packages needed. Or open and copy the file into components/ui/scratch-me.tsx.

Prompts

Paste one into Claude Code, Cursor or any AI coding tool. Install adds this exact component and asks only where it goes and which code it shows. Blend asks four short questions (where, the code, the wording and how much to scratch), then fits it to your product without changing how it scratches.

# Scratch-me

A 1-file React component from Impeccable. Add it to this project so it works and looks exactly like the source below, in the one place I choose, and change nothing else.

A promo code hidden under a scratch-off foil. Scratch at least half, let go, and the rest flakes away, the code is copied to the clipboard and the card says so.

## Before you build: one question

Look through this project first: its pages, upgrade or pricing screens, and any promo or discount codes it already uses. Then ask me this in one message and wait for my answer:

- **Where should it go, and which code should it show?** Suggest the 1–3 places that fit best (for example the upgrade screen, the pricing page or an onboarding step) and the code you found, if any. I can pick one of them or name another, and give the code.
  *Why: a reward works best at the moment someone is deciding, like upgrading or checking out, and the code is copied straight to their clipboard, so it must be real.*

Ask nothing else. If I say *skip*, use the best suggestion and a placeholder code I can find easily.

## Keep it as it is

The scratch is the design. Keep the code pill with its copy icon, the foil look, the brush ring, the falling shavings, the 50% rule and the copy on reveal exactly as in the source. If something doesn't fit my project, tell me instead of changing it.

- Add only the component file and the one place I picked. Don't add packages, change other files, or restyle anything.
- Don't add demo controls ("Scratch again" buttons or sliders) from the Impeccable website.

## Fastest path

If this project has a `components.json` (shadcn), run `npx shadcn@latest add https://impeccableui.vercel.app/r/scratch-me.json`.
If the command succeeds, only fix import paths to match this project and skip ahead. If it fails or there is no `components.json`, build it by hand as below.

## Build it

- Stack: React 18 or 19 with TypeScript. No other packages: it only uses two canvases, pointer events and `requestAnimationFrame`.
- JavaScript project? Name the file `scratch-me.jsx` and remove the type annotations; nothing else changes.
- Create this file, in this project's usual place for components:
  - `components/ui/scratch-me.tsx`
- Keep these exports and their names: `ScratchMe`, `ScratchMeProps`. The `react-dom` import (for the "Code copied" pop) comes with React.
- Keep `"use client"` at the top in Next.js App Router projects. In other setups it is harmless.
- Keep the foil painting (`paintFoil`), the brush, the shavings, the progress check and the tuning values at the top (`SAMPLE_STEP`, `CLEAR_MS`, `GRAVITY`, `MAX_SHAVINGS`) exactly as written. They are the design.
- Keep the fallbacks: with `prefers-reduced-motion: reduce` there are no shavings and the foil just fades; Enter or Space on the focused card reveals it; if the Clipboard API is blocked, it falls back to a hidden textarea copy.

## Use it

```tsx
"use client";
import { ScratchMe } from "@/components/ui/scratch-me";

export function UpgradeOffer() {
  return <ScratchMe code="SAVE20" />;
}
```

- Put the code where this project keeps its other promo codes or settings, if it has such a place. Otherwise write it inline.
- Wire `onReveal` only if the project already tracks events, to record the reveal the same way.

## How the scratch works

Read this before you touch anything, so you know what the code does and why it must stay as it is.

1. **Pill and foil.** The code sits in a soft gray rounded pill with a copy icon on the right, like a code field. A canvas over the pill is painted like a scratch-off: flat metallic gray with fine grain, a soft diagonal sheen, a dashed guide and "SCRATCH HERE". Darker foil in dark mode.
2. **Brush.** On a mouse, a thin ring follows the pointer instead of the cursor. Pressing and moving erases the foil along the path with a round brush (26px), with a few tiny bites at the edge so it looks ragged, not like a clean stroke.
3. **Shavings.** Moving fast throws more of them. Each is a tiny gray flake that spins, falls with gravity and lands just below the hint line, a little unevenly, so they pile up. After the reveal the pile fades away.
4. **Progress.** While scratching, the foil is checked every 120ms on a grid (one pixel every 6px). The line under the card shows the percentage toward the goal.
5. **Reveal.** When you let go with at least half the foil gone (`threshold`, 0.5), the rest of the foil fades and blurs away in about half a second, the code settles to full size and it is copied to the clipboard. Let go before half and nothing happens: keep scratching.
6. **Copied.** On every copy the copy icon blurs and shrinks away while a check draws itself in its place, the line under the code says "Copied", and a small dark pill saying "Code copied" pops up from the bottom center of the screen, stays about 1.5 seconds and drops away. After 2 seconds the check animates back into the copy icon, so it's clear the code can be copied again.
7. **Copy again.** Clicking (or Enter on) the revealed code copies it again. That is the only way to copy it: nothing in the component can be selected as text, and copy shortcuts on it do nothing.

The pill, foil, text and shavings read the text color around the component, so they follow light and dark mode on their own. The code is in the page, so it is not a secret.

## Check

- It type-checks and the page renders with no console errors.
- Scratching erases the foil under the ring and shavings fall and pile at the bottom.
- Letting go under half does nothing; over half, the rest of the foil flakes away and the code is in the clipboard (paste it to check). "Code copied" pops up at the bottom center, and after 2 seconds the check turns back into the copy icon.
- Scratching never selects the code or any text, and the code can't be copied by selecting it.
- It works with a finger on a phone without scrolling the page while scratching, and looks right in light and dark mode.
- With `prefers-reduced-motion: reduce`, there are no shavings.
- Nothing else on the site looks or behaves differently.
- Finish with a short report: the files you added or changed, where it's placed, and every place where you had to do something differently from the source, and why.

## Source

The reference implementation. Copy it as-is, then apply only the changes listed above.

### `components/ui/scratch-me.tsx`

```tsx
"use client";

/**
 * Scratch-me, from Impeccable.
 *
 * A promo code hidden under a scratch-off foil. Scratch it with the mouse or a finger:
 * the foil comes off where you rub and the shavings fall and pile up at the bottom.
 * Let go after scratching at least half and the rest of the foil flakes away, the code
 * is copied to the clipboard and the card says so.
 *
 * No packages: two canvases (foil and shavings) and one requestAnimationFrame loop
 * that only runs while something moves.
 */

import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { createPortal } from "react-dom";

export type ScratchMeProps = {
  /** The code under the foil. Copied to the clipboard on reveal. */
  code: string;
  /** Optional small line above the code. */
  label?: ReactNode;
  /** Line under the card before it's revealed. */
  hint?: ReactNode;
  /** Line under the card for 2 seconds after the code is copied. */
  copiedText?: ReactNode;
  /** Line under the card after that, once the copy icon is back. */
  copyAgainText?: ReactNode;
  /** The little pop at the bottom center of the screen on every copy. Pass null to turn it off. */
  toast?: ReactNode;
  /** Share of the foil (0.3 to 0.9) that must be scratched; on release the rest comes off. */
  threshold?: number;
  /** Brush diameter in px. */
  brushSize?: number;
  /** Shavings that fall and pile up. Off with reduced motion. */
  shavings?: boolean;
  /** Called once when the code is revealed. */
  onReveal?: (code: string) => void;
  /** Called after the code is copied (each time). */
  onCopy?: (code: string) => void;
  className?: string;
  style?: CSSProperties;
};

/* ---------- tuning (the design: keep as written) ---------- */

const SAMPLE_STEP = 6; // px between pixels checked for progress
const CLEAR_MS = 520; // the rest of the foil flaking away
const GRAVITY = 0.32;
const MAX_SHAVINGS = 260;
const COPIED_MS = 2000; // the check shows this long, then the copy icon comes back
const TOAST_MS = 1800; // the "Code copied" pop

/** Styles that can't be inline: icon swap, toast motion, and no text selection anywhere. */
const CSS = `
.isc-root, .isc-root * { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
.isc-icon { grid-area: 1 / 1; transition: opacity .2s cubic-bezier(.23,1,.32,1), transform .3s cubic-bezier(.23,1,.32,1), filter .2s; }
.isc-icon[data-on="false"] { opacity: 0; transform: scale(.5); filter: blur(4px); }
.isc-check path { stroke-dasharray: 16; stroke-dashoffset: 16; transition: stroke-dashoffset .3s .08s cubic-bezier(.23,1,.32,1); }
.isc-check[data-on="true"] path { stroke-dashoffset: 0; }
.isc-toast { animation: isc-toast ${TOAST_MS}ms cubic-bezier(.23,1,.32,1) both; }
@keyframes isc-toast {
  0% { opacity: 0; transform: translate(-50%, 16px) scale(.94); }
  14% { opacity: 1; transform: translate(-50%, 0) scale(1); }
  82% { opacity: 1; transform: translate(-50%, 0) scale(1); }
  100% { opacity: 0; transform: translate(-50%, 8px) scale(.98); }
}
@media (prefers-reduced-motion: reduce) {
  .isc-icon, .isc-check path { transition: opacity .15s; transform: none !important; filter: none !important; stroke-dashoffset: 0; }
  .isc-toast { animation-name: isc-toast-fade; }
  @keyframes isc-toast-fade { 0%, 100% { opacity: 0; transform: translate(-50%, 0); } 14%, 82% { opacity: 1; transform: translate(-50%, 0); } }
}`;

type Shaving = { x: number; y: number; vx: number; vy: number; r: number; spin: number; a: number; tone: number; landed: boolean };

/** Dark or light, read from the text color around the card, so it follows any theme setup. */
function useIsDark(ref: React.RefObject<HTMLElement | null>) {
  const [dark, setDark] = useState(false);
  useEffect(() => {
    const read = () => {
      // Read the parent: the card sets its own text color.
      const el = ref.current?.parentElement ?? ref.current;
      const m = el && getComputedStyle(el).color.match(/[\d.]+/g);
      if (!m) return;
      const [r, g, b] = m.map(Number);
      setDark(0.299 * r + 0.587 * g + 0.114 * b > 140);
    };
    read();
    const mo = new MutationObserver(read);
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class", "data-theme", "style"] });
    const mq = window.matchMedia("(prefers-color-scheme: dark)");
    mq.addEventListener("change", read);
    return () => {
      mo.disconnect();
      mq.removeEventListener("change", read);
    };
  }, [ref]);
  return dark;
}

/** Paints the foil: a flat metallic gray with fine grain, a soft sheen and a dashed hint
 * of the code's outline, like the printed guide on a real card. */
function paintFoil(ctx: CanvasRenderingContext2D, w: number, h: number, dark: boolean) {
  ctx.globalCompositeOperation = "source-over";
  ctx.clearRect(0, 0, w, h);
  ctx.fillStyle = dark ? "#2b2b30" : "#cfcfd2";
  ctx.fillRect(0, 0, w, h);
  const sheen = ctx.createLinearGradient(0, 0, w, h);
  sheen.addColorStop(0, dark ? "rgba(255,255,255,0.05)" : "rgba(255,255,255,0.35)");
  sheen.addColorStop(0.5, "rgba(255,255,255,0)");
  sheen.addColorStop(1, dark ? "rgba(255,255,255,0.04)" : "rgba(255,255,255,0.22)");
  ctx.fillStyle = sheen;
  ctx.fillRect(0, 0, w, h);
  // Grain.
  for (let i = 0; i < (w * h) / 14; i++) {
    const v = Math.random();
    ctx.fillStyle = v > 0.5 ? `rgba(255,255,255,${dark ? 0.05 : 0.18})` : `rgba(0,0,0,${dark ? 0.18 : 0.06})`;
    ctx.fillRect(Math.random() * w, Math.random() * h, 1, 1);
  }
  // Dashed guide.
  ctx.setLineDash([4, 4]);
  ctx.lineWidth = 1;
  ctx.strokeStyle = dark ? "rgba(255,255,255,0.16)" : "rgba(0,0,0,0.16)";
  const inset = 7;
  ctx.beginPath();
  ctx.roundRect(inset, inset, w - inset * 2, h - inset * 2, 7);
  ctx.stroke();
  ctx.setLineDash([]);
  ctx.fillStyle = dark ? "rgba(255,255,255,0.38)" : "rgba(0,0,0,0.36)";
  ctx.font = "500 11px ui-sans-serif, system-ui, sans-serif";
  ctx.textAlign = "center";
  ctx.textBaseline = "middle";
  ctx.fillText("SCRATCH HERE", w / 2, h / 2);
}

export function ScratchMe({
  code,
  label,
  hint = "Scratch to reveal your code.",
  copiedText = "Copied. Paste it at checkout.",
  copyAgainText = "Click the code to copy it again.",
  toast = "Code copied",
  threshold = 0.5,
  brushSize = 26,
  shavings = true,
  onReveal,
  onCopy,
  className,
  style,
}: ScratchMeProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const zoneRef = useRef<HTMLDivElement>(null);
  const foilRef = useRef<HTMLCanvasElement>(null);
  const dustRef = useRef<HTMLCanvasElement>(null);
  const ringRef = useRef<HTMLDivElement>(null);
  const dark = useIsDark(rootRef);

  const [revealed, setRevealed] = useState(false);
  const [copied, setCopied] = useState(false);
  const [progress, setProgress] = useState(0);
  const [focused, setFocused] = useState(false);
  const [toastKey, setToastKey] = useState(0); // bumps on every copy to replay the pop
  const copiedTimer = useRef(0);

  const st = useRef({
    w: 0,
    h: 0,
    dpr: 1,
    last: null as null | { x: number; y: number },
    down: false,
    pieces: [] as Shaving[],
    raf: 0,
    clearing: 0, // time the final clear started
    reduced: false,
    lastSample: 0,
    revealed: false,
    started: false,
    skipClick: false, // the click that follows the revealing pointerup
  });

  const need = Math.min(0.9, Math.max(0.3, threshold));

  /* ---------- clipboard ---------- */

  const copy = useCallback(async () => {
    try {
      await navigator.clipboard.writeText(code);
    } catch {
      // Older browsers or blocked permission: fall back to a hidden textarea.
      const ta = document.createElement("textarea");
      ta.value = code;
      ta.style.cssText = "position:fixed;opacity:0";
      document.body.appendChild(ta);
      ta.select();
      try {
        document.execCommand("copy");
      } catch {}
      ta.remove();
    }
    setCopied(true);
    setToastKey((k) => k + 1);
    // After 2 seconds the check turns back into the copy icon: it can be copied again.
    window.clearTimeout(copiedTimer.current);
    copiedTimer.current = window.setTimeout(() => setCopied(false), COPIED_MS);
    onCopy?.(code);
  }, [code, onCopy]);

  useEffect(() => () => window.clearTimeout(copiedTimer.current), []);

  /* ---------- drawing loop ---------- */

  const frame = useCallback(() => {
    const s = st.current;
    const dust = dustRef.current?.getContext("2d");
    const foil = foilRef.current?.getContext("2d");
    let busy = false;

    // Final clear: the leftover foil fades and breaks up.
    if (s.clearing && foil) {
      const t = Math.min(1, (performance.now() - s.clearing) / CLEAR_MS);
      foilRef.current!.style.opacity = String(1 - t);
      foilRef.current!.style.filter = s.reduced ? "" : `blur(${(t * 3).toFixed(2)}px)`;
      if (t < 1) busy = true;
    }

    if (dust) {
      const W = dustRef.current!.width / s.dpr;
      const H = dustRef.current!.height / s.dpr;
      dust.setTransform(s.dpr, 0, 0, s.dpr, 0, 0);
      dust.clearRect(0, 0, W, H);
      for (const p of s.pieces) {
        if (!p.landed) {
          p.vy += GRAVITY;
          p.vx *= 0.97;
          p.x += p.vx;
          p.y += p.vy;
          p.spin += p.vx * 0.2;
          const floor = H - p.r - Math.random() * 3; // lands a little unevenly, so it piles
          if (p.y >= floor) {
            p.y = floor;
            p.landed = true;
          } else busy = true;
        }
        if (s.revealed && p.landed) {
          p.a -= 0.012; // the pile fades after the reveal
          if (p.a > 0) busy = true;
        }
        if (p.a <= 0) continue;
        dust.save();
        dust.translate(p.x, p.y);
        dust.rotate(p.spin);
        dust.globalAlpha = p.a;
        dust.fillStyle = dark ? `hsl(240 4% ${22 + p.tone * 18}%)` : `hsl(240 3% ${70 + p.tone * 16}%)`;
        dust.fillRect(-p.r, -p.r * 0.6, p.r * 2, p.r * 1.2);
        dust.restore();
      }
      s.pieces = s.pieces.filter((p) => p.a > 0);
    }

    s.raf = busy ? requestAnimationFrame(frame) : 0;
  }, [dark]);

  const wake = useCallback(() => {
    if (!st.current.raf) st.current.raf = requestAnimationFrame(frame);
  }, [frame]);

  /* ---------- size and foil ---------- */

  const resetFoil = useCallback(() => {
    const s = st.current;
    const zone = zoneRef.current;
    const foil = foilRef.current;
    const dust = dustRef.current;
    const root = rootRef.current;
    if (!zone || !foil || !dust || !root) return;
    s.dpr = Math.min(2, window.devicePixelRatio || 1);
    s.w = zone.clientWidth;
    s.h = zone.clientHeight;
    foil.width = Math.round(s.w * s.dpr);
    foil.height = Math.round(s.h * s.dpr);
    dust.width = Math.round(root.clientWidth * s.dpr);
    dust.height = Math.round(root.clientHeight * s.dpr);
    const ctx = foil.getContext("2d", { willReadFrequently: true });
    if (!ctx) return;
    ctx.setTransform(s.dpr, 0, 0, s.dpr, 0, 0);
    paintFoil(ctx, s.w, s.h, dark);
  }, [dark]);

  useEffect(() => {
    const s = st.current;
    s.reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    // Paint the foil until the first scratch; after that a resize only resizes the shavings layer.
    const fit = () => {
      if (s.revealed) return;
      if (!s.started) resetFoil();
      else if (dustRef.current && rootRef.current) {
        dustRef.current.width = Math.round(rootRef.current.clientWidth * s.dpr);
        dustRef.current.height = Math.round(rootRef.current.clientHeight * s.dpr);
      }
    };
    fit();
    const ro = new ResizeObserver(fit);
    if (zoneRef.current) ro.observe(zoneRef.current);
    return () => ro.disconnect();
  }, [resetFoil]);

  useEffect(() => () => cancelAnimationFrame(st.current.raf), []);

  /* ---------- scratching ---------- */

  const measure = () => {
    const s = st.current;
    const ctx = foilRef.current?.getContext("2d", { willReadFrequently: true });
    if (!ctx) return 0;
    const data = ctx.getImageData(0, 0, foilRef.current!.width, foilRef.current!.height).data;
    const step = Math.round(SAMPLE_STEP * s.dpr);
    let clear = 0;
    let total = 0;
    for (let y = 0; y < foilRef.current!.height; y += step)
      for (let x = 0; x < foilRef.current!.width; x += step) {
        total++;
        if (data[(y * foilRef.current!.width + x) * 4 + 3] < 128) clear++;
      }
    return total ? clear / total : 0;
  };

  const scratch = (x: number, y: number) => {
    const s = st.current;
    const ctx = foilRef.current?.getContext("2d", { willReadFrequently: true });
    if (!ctx) return;
    const from = s.last ?? { x, y };
    ctx.globalCompositeOperation = "destination-out";
    ctx.strokeStyle = ctx.fillStyle = "#000"; // fully opaque, so it fully erases
    ctx.lineCap = "round";
    ctx.lineJoin = "round";
    ctx.lineWidth = brushSize;
    ctx.beginPath();
    ctx.moveTo(from.x, from.y);
    ctx.lineTo(x + 0.01, y);
    ctx.stroke();
    // A ragged edge: a few tiny bites around the brush.
    for (let i = 0; i < 3; i++) {
      const a = Math.random() * Math.PI * 2;
      const d = brushSize / 2 + Math.random() * 2;
      ctx.beginPath();
      ctx.arc(x + Math.cos(a) * d, y + Math.sin(a) * d, 1 + Math.random() * 2, 0, Math.PI * 2);
      ctx.fill();
    }
    ctx.globalCompositeOperation = "source-over";

    // Shavings: more when you move faster.
    const dist = Math.hypot(x - from.x, y - from.y);
    if (shavings && !s.reduced && dist > 0.5) {
      const zone = zoneRef.current!.getBoundingClientRect();
      const root = rootRef.current!.getBoundingClientRect();
      const ox = zone.left - root.left;
      const oy = zone.top - root.top;
      const n = Math.min(4, 1 + Math.floor(dist / 6));
      for (let i = 0; i < n && s.pieces.length < MAX_SHAVINGS; i++) {
        s.pieces.push({
          x: ox + x + (Math.random() - 0.5) * brushSize * 0.6,
          y: oy + y + (Math.random() - 0.5) * brushSize * 0.6,
          vx: (x - from.x) * 0.15 + (Math.random() - 0.5) * 1.6,
          vy: -Math.random() * 1.6,
          r: 0.6 + Math.random() * 1.3,
          spin: Math.random() * Math.PI,
          a: 0.9,
          tone: Math.random(),
          landed: false,
        });
      }
      wake();
    }
    s.last = { x, y };

    const now = performance.now();
    if (now - s.lastSample > 120) {
      s.lastSample = now;
      setProgress(measure());
    }
  };

  const reveal = useCallback(() => {
    const s = st.current;
    if (s.revealed) return;
    s.revealed = true;
    s.clearing = performance.now();
    if (ringRef.current) ringRef.current.style.opacity = "0";
    setRevealed(true);
    setProgress(1);
    onReveal?.(code);
    copy();
    wake();
  }, [code, copy, onReveal, wake]);

  const point = (e: React.PointerEvent) => {
    const r = zoneRef.current!.getBoundingClientRect();
    return { x: e.clientX - r.left, y: e.clientY - r.top };
  };

  const moveRing = (e: React.PointerEvent, show: boolean) => {
    const ring = ringRef.current;
    if (!ring) return;
    const p = point(e);
    ring.style.transform = `translate(${p.x - brushSize / 2}px, ${p.y - brushSize / 2}px)`;
    ring.style.opacity = show && e.pointerType === "mouse" && !st.current.revealed ? "1" : "0";
  };

  const onDown = (e: React.PointerEvent) => {
    e.preventDefault(); // never start a text selection from the card
    if (st.current.revealed || e.button !== 0) return;
    e.currentTarget.setPointerCapture(e.pointerId);
    st.current.down = true;
    st.current.started = true;
    st.current.last = null;
    const p = point(e);
    scratch(p.x, p.y);
  };

  const onMove = (e: React.PointerEvent) => {
    moveRing(e, true);
    if (!st.current.down || st.current.revealed) return;
    const p = point(e);
    scratch(p.x, p.y);
  };

  const onUp = () => {
    const s = st.current;
    if (!s.down) return;
    s.down = false;
    s.last = null;
    const done = measure();
    setProgress(done);
    if (done >= need) {
      s.skipClick = true;
      reveal();
    }
  };

  const ink = dark ? "#f4f4f5" : "#111113";
  const muted = dark ? "rgba(244,244,245,0.55)" : "rgba(17,17,19,0.55)";

  return (
    <div
      ref={rootRef}
      className={className ? `isc-root ${className}` : "isc-root"}
      // The code is copied only by clicking it, never by selecting it as text.
      onCopy={(e) => e.preventDefault()}
      onCut={(e) => e.preventDefault()}
      onDragStart={(e) => e.preventDefault()}
      onContextMenu={(e) => !revealed && e.preventDefault()}
      style={{
        position: "relative",
        display: "inline-flex",
        flexDirection: "column",
        alignItems: "center",
        gap: 10,
        maxWidth: "100%",
        paddingBottom: 14, // room for the shavings to pile up
        color: ink,
        ...style,
      }}
    >
      <style>{CSS}</style>
      {label && <p style={{ margin: 0, fontSize: 13, color: muted }}>{label}</p>}

      <div
        ref={zoneRef}
        role="button"
        tabIndex={0}
        aria-label={revealed ? `Code ${code}. Press to copy again.` : "Scratch card. Press Enter to reveal the code."}
        onPointerDown={onDown}
        onPointerMove={onMove}
        onPointerUp={onUp}
        onPointerCancel={onUp}
        onPointerLeave={(e) => moveRing(e, false)}
        onClick={() => {
          if (st.current.skipClick) st.current.skipClick = false;
          else if (revealed) copy();
        }}
        onFocus={(e) => setFocused(e.currentTarget.matches(":focus-visible"))}
        onBlur={() => setFocused(false)}
        onKeyDown={(e) => {
          if (e.key === "Enter" || e.key === " ") {
            e.preventDefault();
            if (revealed) copy();
            else reveal();
          }
        }}
        style={{
          position: "relative",
          height: 64,
          minWidth: 260,
          maxWidth: "100%",
          padding: "0 18px 0 24px",
          borderRadius: 12,
          background: dark ? "#1c1c1f" : "#f4f4f5",
          boxShadow: focused
            ? `inset 0 0 0 2px ${dark ? "rgba(255,255,255,0.5)" : "rgba(0,0,0,0.45)"}`
            : dark
              ? "inset 0 0 0 1px rgba(255,255,255,0.08)"
              : "inset 0 0 0 1px rgba(0,0,0,0.06)",
          display: "flex",
          alignItems: "center",
          justifyContent: "space-between",
          gap: 20,
          cursor: revealed ? "copy" : "none",
          touchAction: "none",
          outline: "none",
          overflow: "hidden",
        }}
      >
        <span
          style={{
            fontSize: "clamp(18px, 5.5vw, 22px)",
            fontWeight: 500,
            letterSpacing: "0.01em",
            whiteSpace: "nowrap",
            transform: revealed ? "scale(1)" : "scale(0.97)",
            transformOrigin: "left center",
            transition: "transform .45s cubic-bezier(.23,1,.32,1)",
          }}
        >
          {code}
        </span>
        <span aria-hidden style={{ display: "grid", flex: "none", color: dark ? "rgba(244,244,245,0.45)" : "rgba(17,17,19,0.35)" }}>
          <svg className="isc-icon" data-on={!copied} width="20" height="20" viewBox="0 0 20 20" fill="none">
            <rect x="6.5" y="6.5" width="10" height="10" rx="2.5" stroke="currentColor" strokeWidth="1.6" />
            <path d="M13.5 4.5v-.25A1.75 1.75 0 0 0 11.75 2.5h-6A2.75 2.75 0 0 0 3 5.25v6.5c0 .97.78 1.75 1.75 1.75H5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
          </svg>
          <svg className="isc-icon isc-check" data-on={copied} width="20" height="20" viewBox="0 0 20 20" fill="none">
            <path d="M5 10.5l3.2 3.2L15 7" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" />
          </svg>
        </span>
        <canvas
          ref={foilRef}
          aria-hidden
          style={{ position: "absolute", inset: 0, width: "100%", height: "100%", pointerEvents: "none" }}
        />
        <div
          ref={ringRef}
          aria-hidden
          style={{
            position: "absolute",
            left: 0,
            top: 0,
            width: brushSize,
            height: brushSize,
            borderRadius: "50%",
            border: `1px solid ${dark ? "rgba(255,255,255,0.6)" : "rgba(0,0,0,0.45)"}`,
            opacity: 0,
            pointerEvents: "none",
            transition: "opacity .15s",
          }}
        />
      </div>

      <p
        aria-live="polite"
        style={{ margin: 0, fontSize: 12.5, lineHeight: 1.45, color: muted, display: "flex", gap: 6, alignItems: "center", minHeight: 18, textAlign: "center" }}
      >
        {copied ? (
          copiedText
        ) : revealed ? (
          copyAgainText
        ) : (
          <>
            {hint}
            {progress > 0.02 && !revealed && (
              <span style={{ fontVariantNumeric: "tabular-nums", opacity: 0.8 }}>{Math.round(Math.min(progress / need, 1) * 100)}%</span>
            )}
          </>
        )}
      </p>

      <canvas
        ref={dustRef}
        aria-hidden
        style={{ position: "absolute", inset: 0, width: "100%", height: "100%", pointerEvents: "none" }}
      />

      {toast != null &&
        toastKey > 0 &&
        createPortal(
          <div
            key={toastKey}
            className="isc-toast"
            role="status"
            onAnimationEnd={() => setToastKey(0)}
            style={{
              position: "fixed",
              left: "50%",
              bottom: "max(24px, env(safe-area-inset-bottom))",
              zIndex: 2147483000,
              display: "flex",
              alignItems: "center",
              gap: 8,
              padding: "9px 14px 9px 11px",
              borderRadius: 999,
              background: dark ? "#f4f4f5" : "#111113",
              color: dark ? "#111113" : "#f4f4f5",
              fontSize: 13.5,
              fontWeight: 500,
              lineHeight: 1,
              whiteSpace: "nowrap",
              boxShadow: "0 10px 30px -10px rgba(0,0,0,0.35)",
              pointerEvents: "none",
            }}
          >
            <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden>
              <circle cx="8" cy="8" r="7" fill="currentColor" opacity=".15" />
              <path d="M5 8.3l2 2L11 6" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" />
            </svg>
            {toast}
          </div>,
          document.body,
        )}
    </div>
  );
}
```

Usage

Give it the code. Everything else has a default.

"use client";
import { ScratchMe } from "@/components/ui/scratch-me";

export function UpgradeOffer() {
  return (
    <ScratchMe
      code="SAVE20"
      label="20% off Pro"
      onReveal={(code) => track("promo_revealed", { code })} // optional
    />
  );
}

The code sits in the page, so anyone can read it in the page source. Use it for friendly offers, not secrets.

Props

PropDefaultWhat it does
code—The code under the foil. Copied to the clipboard when it's revealed.
label—Optional small line above the code, like "20% off Pro".
hint"Scratch to reveal…"Line under the card before it's revealed.
copiedText"Copied. Paste it…"Line under the code for 2 seconds after each copy.
copyAgainText"Click the code to copy it again."Line under the code after that, once the copy icon is back.
toast"Code copied"The little pop at the bottom center of the screen on every copy. Pass null to turn it off.
threshold0.5Share of the foil to scratch (0.3 to 0.9). Let go past it and the rest comes off.
brushSize26Brush size in px.
shavingstrueShavings that fall and pile up at the bottom. Always off with reduced motion.
onReveal—Called once when the code is revealed.
onCopy—Called after each copy. Clicking a revealed code copies it again.
className / style—Layout for the outer box. The code pill is at least 260px wide and grows with the code.