Reveals & Transitions

Scratch Reveal

A Canvas foil layer the pointer erases like a scratch card; the uncovered fraction is tracked and the rest auto-clears past a threshold.

Install

npx shadcn@latest add @paragon/scratch-reveal

scratch-reveal.tsx

"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>
  );
}