A Canvas foil layer the pointer erases like a scratch card; the uncovered fraction is tracked and the rest auto-clears past a threshold.
npx shadcn@latest add @paragon/scratch-reveal"use client";
import * as React from "react";
import { cn } from "@/lib/utils";
export interface ScratchRevealProps
extends Omit<React.ComponentProps<"div">, "onProgress"> {
/** Brush radius, in CSS px. */
brushSize?: number;
/** Foil overlay color (a flat fill or the base of a subtle sheen). */
foilColor?: string;
/** 0–1: fraction scratched at which the rest auto-clears. */
threshold?: number;
/** Label rendered on the foil ("Scratch to reveal"). Pass "" to hide. */
hint?: string;
/** Fires with the uncovered fraction (0–1) as the user scratches. */
onProgress?: (fraction: number) => void;
children: React.ReactNode;
}
/**
* ScratchReveal — a foil layer painted on a Canvas 2D overlay that the pointer
* erases with `globalCompositeOperation = "destination-out"`, uncovering the
* real content beneath like a scratch card. The uncovered fraction is sampled
* from the alpha channel; once it crosses `threshold` the remaining foil fades
* away so the reveal always completes.
*
* Interactive (pointer/touch), so there is no looping rAF to pause — but all
* listeners and observers are cleaned up on unmount, and under
* `prefers-reduced-motion` (or coarse pointer where scratching is awkward) the
* foil starts fully cleared and the content is shown outright. Content is real
* DOM under an `aria-hidden` foil canvas.
*/
export function ScratchReveal({
brushSize = 26,
foilColor = "var(--color-muted)",
threshold = 0.55,
hint = "Scratch to reveal",
onProgress,
className,
children,
...props
}: ScratchRevealProps) {
const wrapperRef = React.useRef<HTMLDivElement>(null);
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const [cleared, setCleared] = React.useState(false);
const [pct, setPct] = React.useState(0);
const onProgressRef = React.useRef(onProgress);
onProgressRef.current = onProgress;
React.useEffect(() => {
const wrapper = wrapperRef.current;
const canvas = canvasRef.current;
if (!wrapper || !canvas) return;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) return;
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
const coarse = window.matchMedia("(pointer: coarse)");
const skip = reduceMotion.matches;
let width = 0;
let height = 0;
let dpr = 1;
let cleaning = false;
let done = false;
let last: { x: number; y: number } | null = null;
const paintFoil = () => {
ctx.globalCompositeOperation = "source-over";
ctx.clearRect(0, 0, canvas.width, canvas.height);
canvas.style.color = foilColor;
const fill = getComputedStyle(canvas).color;
// Base foil + a soft diagonal sheen for a metallic feel.
ctx.save();
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.fillStyle = fill;
ctx.fillRect(0, 0, width, height);
const sheen = ctx.createLinearGradient(0, 0, width, height);
sheen.addColorStop(0, "rgba(255,255,255,0.10)");
sheen.addColorStop(0.5, "rgba(255,255,255,0.02)");
sheen.addColorStop(1, "rgba(0,0,0,0.10)");
ctx.fillStyle = sheen;
ctx.fillRect(0, 0, width, height);
ctx.restore();
};
const resize = () => {
const rect = wrapper.getBoundingClientRect();
dpr = Math.min(window.devicePixelRatio || 1, 2);
width = rect.width;
height = rect.height;
canvas.width = Math.max(1, Math.round(width * dpr));
canvas.height = Math.max(1, Math.round(height * dpr));
if (!done && !skip) paintFoil();
};
const sampleProgress = (): number => {
// Sample alpha on a coarse grid to estimate the cleared fraction cheaply.
const step = 8;
const w = canvas.width;
const h = canvas.height;
if (w === 0 || h === 0) return 0;
let clearedCount = 0;
let total = 0;
const data = ctx.getImageData(0, 0, w, h).data;
for (let y = 0; y < h; y += step) {
for (let x = 0; x < w; x += step) {
const a = data[(y * w + x) * 4 + 3];
if (a < 40) clearedCount++;
total++;
}
}
return total ? clearedCount / total : 0;
};
const finish = () => {
if (cleaning || done) return;
cleaning = true;
const t0 = performance.now();
const dur = 340;
// Fade the whole canvas element out (leaving its erased pixels intact) so
// the remaining foil dissolves smoothly rather than snapping away.
const ease = (t: number) => 1 - Math.pow(1 - t, 3);
const fade = (now: number) => {
const k = Math.min(1, (now - t0) / dur);
canvas.style.opacity = String(1 - ease(k));
if (k < 1) {
fadeRaf = requestAnimationFrame(fade);
} else {
done = true;
canvas.style.opacity = "0";
setCleared(true);
setPct(1);
onProgressRef.current?.(1);
}
};
let fadeRaf = requestAnimationFrame(fade);
cleanups.push(() => cancelAnimationFrame(fadeRaf));
};
const scratchAt = (x: number, y: number) => {
if (done || cleaning) return;
ctx.globalCompositeOperation = "destination-out";
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.lineWidth = brushSize * 2;
ctx.beginPath();
if (last) {
ctx.moveTo(last.x, last.y);
ctx.lineTo(x, y);
ctx.stroke();
}
ctx.beginPath();
ctx.arc(x, y, brushSize, 0, Math.PI * 2);
ctx.fill();
last = { x, y };
};
const toLocal = (e: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
};
let scratching = false;
let sampleTimer: number | null = null;
const scheduleSample = () => {
if (sampleTimer !== null) return;
sampleTimer = window.setTimeout(() => {
sampleTimer = null;
const p = sampleProgress();
setPct(p);
onProgressRef.current?.(p);
if (p >= threshold) finish();
}, 90);
};
const onDown = (e: PointerEvent) => {
if (done) return;
scratching = true;
last = null;
const { x, y } = toLocal(e);
scratchAt(x, y);
canvas.setPointerCapture?.(e.pointerId);
};
const onMove = (e: PointerEvent) => {
if (!scratching || done) return;
const { x, y } = toLocal(e);
scratchAt(x, y);
scheduleSample();
};
const onUp = () => {
scratching = false;
last = null;
scheduleSample();
};
const cleanups: Array<() => void> = [];
resize();
const resizeObserver = new ResizeObserver(resize);
resizeObserver.observe(wrapper);
cleanups.push(() => resizeObserver.disconnect());
if (skip) {
// Reduced motion: reveal immediately, no foil, no interaction.
done = true;
canvas.style.opacity = "0";
setCleared(true);
setPct(1);
} else {
canvas.addEventListener("pointerdown", onDown);
canvas.addEventListener("pointermove", onMove);
canvas.addEventListener("pointerup", onUp);
canvas.addEventListener("pointerleave", onUp);
cleanups.push(() => {
canvas.removeEventListener("pointerdown", onDown);
canvas.removeEventListener("pointermove", onMove);
canvas.removeEventListener("pointerup", onUp);
canvas.removeEventListener("pointerleave", onUp);
});
}
void coarse; // pointer-type handling is left to the browser's touch model.
return () => {
if (sampleTimer !== null) clearTimeout(sampleTimer);
for (const fn of cleanups) fn();
};
}, [brushSize, foilColor, threshold]);
return (
<div
ref={wrapperRef}
data-slot="scratch-reveal"
className={cn("relative overflow-hidden", className)}
{...props}
>
{children}
<canvas
ref={canvasRef}
aria-hidden
className="absolute inset-0 size-full touch-none select-none"
style={{
cursor: cleared ? "default" : "crosshair",
pointerEvents: cleared ? "none" : "auto",
}}
/>
{hint && !cleared && pct < 0.02 && (
<div
aria-hidden
className="pointer-events-none absolute inset-0 grid place-items-center"
>
<span className="rounded-full bg-background/70 px-3 py-1 text-xs font-medium text-foreground/80 shadow-border backdrop-blur-sm">
{hint}
</span>
</div>
)}
</div>
);
}