Cards
ScratchCard
A printed card with a silver foil panel. Drag to scratch it off with a coin while flecks fall away; past the threshold the rest clears. Enter scratches it for you.
Download
- React
- Tailwind CSS v4
- motion
components/ui/scratch-card.tsx
"use client";
import { useEffect, useRef, useState, type PointerEvent } from "react";
import { motion, useMotionValue, useReducedMotion } from "motion/react";
type ScratchCardProps = {
/** What sits under the foil. */
prize: string;
/** Small line under the prize. */
note?: string;
title?: string;
/** Printed number at the top. */
serial?: string;
/** Share of foil to clear before the rest falls away, 0 to 1. */
threshold?: number;
/** Printed card colour. */
color?: string;
onReveal?: () => void;
className?: string;
};
// Canvas size. CSS stretches it, so scratches survive a resize.
const W = 600;
const H = 250;
const STROKE = 34;
type Fleck = { x: number; y: number; vx: number; vy: number; size: number };
// Same grain every time, so every card comes from the same print run.
function roll(n: number) {
let h = Math.imul(n ^ 0x9e3779b9, 0x85ebca6b);
h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35);
return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
}
function paintFoil(c: CanvasRenderingContext2D) {
c.globalCompositeOperation = "source-over";
const steel = c.createLinearGradient(0, 0, W, H);
["#aeb2b8", "#dcdfe3", "#a4a8ae", "#d2d5da", "#b4b8be"].forEach((s, i) => steel.addColorStop(i / 4, s));
c.fillStyle = steel;
c.fillRect(0, 0, W, H);
for (let i = 0; i < 2600; i++) {
c.fillStyle = i % 2 ? "rgba(255,255,255,0.3)" : "rgba(60,64,72,0.2)";
c.fillRect(roll(i * 2) * W, roll(i * 2 + 1) * H, 1.4, 1.4);
}
c.save();
c.translate(W / 2, H / 2);
c.rotate(-0.2);
c.font = "800 26px Arial, sans-serif";
c.textAlign = "center";
c.fillStyle = "rgba(70,74,84,0.3)";
for (let row = -3; row <= 3; row++) for (let col = -3; col <= 3; col++) c.fillText("SCRATCH", col * 170 + (row % 2) * 85, row * 58);
c.restore();
}
/* ScratchCard: a printed card with a silver foil panel. Drag to scratch it off with a coin while flecks fall away. Enter scratches it for you. */
export function ScratchCard({ prize, note, title = "Lucky Seven", serial = "No. 0047291", threshold = 0.55, color = "#0f766e", onReveal, className = "" }: ScratchCardProps) {
const reduce = useReducedMotion();
const [round, setRound] = useState(0);
const [won, setWon] = useState(false);
const [coin, setCoin] = useState(false);
const foil = useRef<HTMLCanvasElement>(null);
const dust = useRef<HTMLCanvasElement>(null);
const panel = useRef<HTMLButtonElement>(null);
const again = useRef<HTMLButtonElement>(null);
const last = useRef<{ x: number; y: number } | null>(null);
const flecks = useRef<Fleck[]>([]);
const falling = useRef(0);
const strokes = useRef(0);
const finished = useRef(false);
const coinX = useMotionValue(0);
const coinY = useMotionValue(0);
useEffect(() => {
const c = foil.current?.getContext("2d", { willReadFrequently: true });
if (c) paintFoil(c);
finished.current = false;
strokes.current = 0;
}, [round]);
// The panel turns off once it is won, so focus moves to the next control.
useEffect(() => {
if (won) again.current?.focus({ preventScroll: true });
else if (round) panel.current?.focus({ preventScroll: true });
}, [won, round]);
useEffect(() => () => cancelAnimationFrame(falling.current), []);
function reveal() {
if (finished.current) return;
finished.current = true;
last.current = null;
setCoin(false);
setWon(true);
onReveal?.();
}
function check() {
const c = foil.current?.getContext("2d");
if (!c) return;
const { data } = c.getImageData(0, 0, W, H);
let clear = 0;
let all = 0;
for (let i = 3; i < data.length; i += 4 * 29) {
all += 1;
if (data[i] < 40) clear += 1;
}
if (clear / all >= threshold) reveal();
}
function fall() {
const c = dust.current?.getContext("2d");
if (!c) return;
c.clearRect(0, 0, W, H);
flecks.current = flecks.current.filter((f) => f.y < H + 8);
c.fillStyle = "#c4c7cc";
for (const f of flecks.current) {
f.vy += 0.5;
f.x += f.vx;
f.y += f.vy;
c.fillRect(f.x, f.y, f.size, f.size);
}
falling.current = flecks.current.length ? requestAnimationFrame(fall) : 0;
}
function scratch(x: number, y: number, x2: number, y2: number) {
const c = foil.current?.getContext("2d");
if (!c || finished.current) return;
c.globalCompositeOperation = "destination-out";
c.lineCap = "round";
// One wide stroke plus thin wobbly ones, for a rough edge.
for (let i = 0; i < 4; i++) {
const wobble = () => (Math.random() - 0.5) * (i ? 22 : 0);
c.lineWidth = i ? 12 : STROKE;
c.beginPath();
c.moveTo(x + wobble(), y + wobble());
c.lineTo(x2 + wobble(), y2 + wobble());
c.stroke();
}
if (!reduce && flecks.current.length < 90) {
for (let i = 0; i < 3; i++) flecks.current.push({ x: x2, y: y2, vx: (Math.random() - 0.5) * 5, vy: -Math.random() * 3, size: 1.5 + Math.random() * 3 });
falling.current ||= requestAnimationFrame(fall);
}
strokes.current += 1;
if (strokes.current % 6 === 0) check();
}
function at(e: PointerEvent<HTMLButtonElement>) {
const box = e.currentTarget.getBoundingClientRect();
coinX.set(e.clientX - box.left);
coinY.set(e.clientY - box.top);
return { x: ((e.clientX - box.left) / box.width) * W, y: ((e.clientY - box.top) / box.height) * H };
}
function down(e: PointerEvent<HTMLButtonElement>) {
e.currentTarget.setPointerCapture(e.pointerId);
const p = at(e);
last.current = p;
setCoin(true);
scratch(p.x, p.y, p.x + 0.1, p.y);
}
function move(e: PointerEvent<HTMLButtonElement>) {
const p = at(e);
if (!last.current) return;
scratch(last.current.x, last.current.y, p.x, p.y);
last.current = p;
}
function up() {
last.current = null;
check();
}
// Keyboard and screen reader: the zigzag runs by itself.
function autoScratch() {
if (reduce) return reveal();
const rows = 6;
let t = 0;
const step = () => {
if (finished.current) return;
if (t >= rows) return reveal();
const row = Math.floor(t);
const across = row % 2 ? 1 - (t - row) : t - row;
const x = across * W;
const y = (t / rows) * H;
scratch(x, y, x + 1, y + 1);
t += 0.07;
requestAnimationFrame(step);
};
step();
}
return (
<div className={`@container w-full max-w-[460px] ${className}`}>
<div className="relative aspect-[8/5] select-none overflow-hidden rounded-[0.8em] text-[2.9cqw] leading-tight text-white shadow-[0_0.8em_1.6em_-0.4em_rgba(0,0,0,0.7)]" style={{ background: color }}>
<div className="absolute inset-0 bg-[repeating-radial-gradient(circle_at_50%_-30%,rgba(255,255,255,0.1)_0_0.08em,transparent_0.08em_0.5em)]" />
<div className="absolute inset-[0.5em] rounded-[0.5em] border-[0.12em] border-dashed border-white/40" />
<div className="absolute inset-x-[1.6em] top-[1.1em] flex items-baseline justify-between">
<span className="font-serif text-[2.2em] font-black italic tracking-tight text-[#ffe08a] [text-shadow:0.04em_0.06em_0_rgba(0,0,0,0.35)]">{title}</span>
<span className="font-mono text-[0.84em] tracking-widest text-white/80">{serial}</span>
</div>
<div className="absolute inset-x-[1.6em] top-[5em] aspect-[600/250] overflow-hidden rounded-[0.35em] bg-[#fff8e4] text-[#1e2430] shadow-[inset_0_0.1em_0.3em_rgba(0,0,0,0.3)]">
<div aria-hidden className="absolute inset-0 grid place-content-center text-center">
<motion.p
className="font-serif text-[3.2em] font-black leading-none"
style={{ color }}
animate={won && !reduce ? { scale: [1, 1.22, 1] } : { scale: 1 }}
transition={{ duration: 0.5, delay: 0.1 }}
>
{prize}
</motion.p>
{note && <p className="mt-[0.5em] text-[0.92em] text-[#5b6272]">{note}</p>}
</div>
<p className="sr-only" aria-live="polite">{won ? `You won ${prize}. ${note ?? ""}` : ""}</p>
<button
ref={panel}
type="button"
disabled={won}
aria-label="Scratch the foil to see your prize"
onPointerDown={down}
onPointerMove={move}
onPointerUp={up}
onPointerCancel={up}
onPointerEnter={(e) => e.pointerType === "mouse" && setCoin(true)}
onPointerLeave={() => !last.current && setCoin(false)}
onClick={(e) => e.detail === 0 && autoScratch()}
className={`absolute inset-0 block touch-none ${won ? "pointer-events-none" : "cursor-none"}`}
>
<motion.canvas ref={foil} width={W} height={H} className="absolute inset-0 h-full w-full" initial={false} animate={{ opacity: won ? 0 : 1 }} transition={{ duration: reduce ? 0 : 0.45 }} />
<canvas ref={dust} width={W} height={H} className="pointer-events-none absolute inset-0 h-full w-full" />
{coin && (
<motion.span
className="pointer-events-none absolute left-[-1.7em] top-[-1.7em] size-[3.4em] rounded-full border-[0.26em] border-dotted border-[#8a6a1f] bg-[radial-gradient(circle_at_35%_30%,#f6e39b,#c79a35)] shadow-[0_0.2em_0.4em_rgba(0,0,0,0.45)]"
style={{ x: coinX, y: coinY }}
/>
)}
</button>
</div>
<div className="absolute inset-x-[1.6em] bottom-[1em] flex items-center justify-between text-[0.86em] text-white/85">
<span>{won ? "Show this card at the counter" : "Scratch the silver panel with a coin"}</span>
{won && (
<button
ref={again}
type="button"
onClick={() => {
setWon(false);
setRound((r) => r + 1);
}}
className="shrink-0 rounded-full bg-white/15 px-[0.8em] py-[0.15em] text-white hover:bg-white/25"
>
New card
</button>
)}
</div>
</div>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/scratch-card.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/scratch-card-demo.tsx
import { ScratchCard } from "@/components/ui/scratch-card"; export default function ScratchCardExample() { return ( <div className="grid h-full place-items-center overflow-hidden bg-[#1c1712] bg-[radial-gradient(circle_at_50%_0%,#3b2d1f,transparent_70%)] p-4"> <ScratchCard prize="₹500 off" note="On your next order" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| prize | string | — | What sits under the foil. |
| note | string | — | Small line under the prize. |
| title | string | "Lucky Seven" | Name printed on the card. |
| serial | string | "No. 0047291" | Printed number at the top. |
| threshold | number | 0.55 | Share of foil to clear before the rest falls away, 0 to 1. |
| color | string | "#0f766e" | Printed card colour. |
| onReveal | () => void | — | Runs when the prize shows. |
| className | string | "" | Size. Everything inside scales with the width. |