{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scratch-to-reveal",
  "type": "registry:component",
  "title": "Scratch to reveal",
  "description": "A scratch card: a foil you rub away with the pointer or a finger to reveal what is underneath, which clears itself once enough is gone.",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/premium/scratch-to-reveal.tsx",
      "type": "registry:ui",
      "target": "components/ui/scratch-to-reveal.tsx",
      "content": "\"use client\"\nimport * as React from \"react\"\nimport { motion, useReducedMotion } from \"motion/react\"\nimport { CopyIcon, CheckIcon } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\n\n/**\n * A scratch card: a foil you rub away with the pointer or a finger to reveal what is underneath, which clears itself once enough is gone.\n * The foil is a canvas painted with a gradient from your tokens. Progress is\n * tracked on a coarse grid, so there is no pixel reading on every move. Past\n * the threshold the rest lifts away on its own and onComplete fires. A Reveal\n * button serves keyboard users, and reduced motion skips the fade.\n */\ntype ScratchToRevealProps = {\n  /** Percent of the foil that must be scratched before the rest clears itself. */\n  threshold?: number\n  /** Brush radius in pixels. */\n  brush?: number\n  /** Colours of the foil gradient. CSS colours and var(--token) both work. */\n  gradient?: string[]\n  /** Hint printed on the foil. */\n  label?: string\n  /** Called once the card is fully revealed. */\n  onComplete?: () => void\n  /** Called with the scratched percentage as it changes. */\n  onProgress?: (percent: number) => void\n  children?: React.ReactNode\n  className?: string\n}\n\nconst CELL = 6\nconst DEFAULT_GRADIENT = [\"var(--accent)\", \"var(--accent-2)\"]\n\n/** The sample reward: a discount code for Lumen. */\nfunction SampleReward() {\n  const [copied, setCopied] = React.useState(false)\n  return (\n    <div data-slot=\"scratch-to-reveal-sample\" className=\"flex flex-col items-center gap-2 px-4 py-6 text-center\">\n      <span className=\"text-[11px] font-medium tracking-[0.08em] text-fg-subtle uppercase\">Your reward</span>\n      <p className=\"text-2xl font-medium tracking-[-0.018em] text-fg\">20% off Lumen Pro</p>\n      <button\n        type=\"button\"\n        onClick={() => {\n          try {\n            void navigator.clipboard?.writeText(\"LUMEN20\")\n          } catch {}\n          setCopied(true)\n          setTimeout(() => setCopied(false), 1600)\n        }}\n        className=\"mt-1 inline-flex h-9 items-center gap-2 rounded-lg border border-dashed border-border-strong bg-sunken px-3 font-mono text-sm text-fg outline-none transition-colors duration-[140ms] hover:border-accent-line focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-surface\"\n      >\n        LUMEN20\n        {copied ? <CheckIcon className=\"size-3.5 text-success\" /> : <CopyIcon className=\"size-3.5 text-fg-subtle\" />}\n      </button>\n      <p className=\"text-xs text-fg-muted\">Valid on annual plans until 31 December.</p>\n    </div>\n  )\n}\n\nfunction ScratchToReveal({\n  threshold = 50,\n  brush = 24,\n  gradient = DEFAULT_GRADIENT,\n  label = \"Scratch to reveal\",\n  onComplete,\n  onProgress,\n  children,\n  className,\n}: ScratchToRevealProps) {\n  const reduce = useReducedMotion()\n  const boxRef = React.useRef<HTMLDivElement>(null)\n  const canvasRef = React.useRef<HTMLCanvasElement>(null)\n  const grid = React.useRef<{ cells: Uint8Array; cols: number; rows: number; cleared: number }>({ cells: new Uint8Array(0), cols: 0, rows: 0, cleared: 0 })\n  const last = React.useRef<{ x: number; y: number } | null>(null)\n  const [percent, setPercent] = React.useState(0)\n  const [complete, setComplete] = React.useState(false)\n  const [gone, setGone] = React.useState(false)\n  const statusId = React.useId()\n\n  const completeRef = React.useRef(onComplete)\n  const progressRef = React.useRef(onProgress)\n  React.useEffect(() => {\n    completeRef.current = onComplete\n    progressRef.current = onProgress\n  }, [onComplete, onProgress])\n\n  // Key on the colours themselves, so an inline array literal does not repaint on every render.\n  const gradientKey = gradient.join(\"|\")\n  const paint = React.useCallback(() => {\n    const canvas = canvasRef.current\n    const box = boxRef.current\n    const ctx = canvas?.getContext(\"2d\")\n    if (!canvas || !box || !ctx) return\n    const stopsIn = gradientKey.split(\"|\").filter(Boolean)\n    const w = Math.max(1, Math.round(box.clientWidth))\n    const h = Math.max(1, Math.round(box.clientHeight))\n    const dpr = Math.min(window.devicePixelRatio || 1, 2)\n    canvas.width = Math.round(w * dpr)\n    canvas.height = Math.round(h * dpr)\n    ctx.setTransform(dpr, 0, 0, dpr, 0, 0)\n    ctx.globalCompositeOperation = \"source-over\"\n    // Resolve var(--token) colours through the cascade so the foil follows the theme.\n    const stops = (stopsIn.length ? stopsIn : DEFAULT_GRADIENT).map((c) => {\n      canvas.style.color = c\n      return getComputedStyle(canvas).color || c\n    })\n    canvas.style.color = \"\"\n    const g = ctx.createLinearGradient(0, 0, w, h)\n    stops.forEach((c, i) => g.addColorStop(stops.length === 1 ? 0 : i / (stops.length - 1), c))\n    ctx.fillStyle = g\n    ctx.fillRect(0, 0, w, h)\n    // Foil sheen: a few soft diagonal bands.\n    ctx.globalCompositeOperation = \"overlay\"\n    for (let i = -2; i < 6; i++) {\n      const band = ctx.createLinearGradient(0, 0, w, h)\n      band.addColorStop(0, \"rgba(255,255,255,0)\")\n      band.addColorStop(0.5, \"rgba(255,255,255,0.35)\")\n      band.addColorStop(1, \"rgba(255,255,255,0)\")\n      ctx.fillStyle = band\n      ctx.save()\n      ctx.translate(i * (w / 4), 0)\n      ctx.transform(1, 0, -0.9, 1, 0, 0)\n      ctx.fillRect(0, 0, w / 9, h)\n      ctx.restore()\n    }\n    ctx.globalCompositeOperation = \"destination-out\"\n    ctx.lineCap = \"round\"\n    ctx.lineJoin = \"round\"\n    ctx.lineWidth = brush * 2\n    const cols = Math.ceil(w / CELL)\n    const rows = Math.ceil(h / CELL)\n    grid.current = { cells: new Uint8Array(cols * rows), cols, rows, cleared: 0 }\n    setPercent(0)\n  }, [gradientKey, brush])\n\n  React.useEffect(() => {\n    if (complete) return\n    paint()\n    const box = boxRef.current\n    if (!box) return\n    const ro = new ResizeObserver(() => paint())\n    ro.observe(box)\n    const mo = new MutationObserver(() => paint())\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\", \"data-material\"] })\n    return () => {\n      ro.disconnect()\n      mo.disconnect()\n    }\n  }, [paint, complete])\n\n  const finish = React.useCallback(() => {\n    setComplete(true)\n    setPercent(100)\n    progressRef.current?.(100)\n    completeRef.current?.()\n  }, [])\n\n  // Reduced motion skips the fade, so the foil goes straight away.\n  React.useEffect(() => {\n    if (complete && reduce) setGone(true)\n  }, [complete, reduce])\n\n  const mark = (x: number, y: number) => {\n    const g = grid.current\n    const r = brush\n    const c0 = Math.max(0, Math.floor((x - r) / CELL))\n    const c1 = Math.min(g.cols - 1, Math.floor((x + r) / CELL))\n    const r0 = Math.max(0, Math.floor((y - r) / CELL))\n    const r1 = Math.min(g.rows - 1, Math.floor((y + r) / CELL))\n    for (let row = r0; row <= r1; row++) {\n      for (let col = c0; col <= c1; col++) {\n        const i = row * g.cols + col\n        if (g.cells[i]) continue\n        const cx = col * CELL + CELL / 2\n        const cy = row * CELL + CELL / 2\n        if ((cx - x) ** 2 + (cy - y) ** 2 <= r * r) {\n          g.cells[i] = 1\n          g.cleared++\n        }\n      }\n    }\n  }\n\n  const scratch = (x: number, y: number) => {\n    const ctx = canvasRef.current?.getContext(\"2d\")\n    if (!ctx || complete) return\n    const from = last.current ?? { x, y }\n    ctx.beginPath()\n    ctx.moveTo(from.x, from.y)\n    ctx.lineTo(x, y)\n    ctx.stroke()\n    const dist = Math.hypot(x - from.x, y - from.y)\n    const steps = Math.max(1, Math.ceil(dist / (brush / 2)))\n    for (let s = 0; s <= steps; s++) mark(from.x + ((x - from.x) * s) / steps, from.y + ((y - from.y) * s) / steps)\n    last.current = { x, y }\n    const g = grid.current\n    const p = g.cells.length ? Math.min(100, Math.round((g.cleared / g.cells.length) * 100)) : 0\n    if (p !== percent) {\n      setPercent(p)\n      progressRef.current?.(p)\n    }\n    if (p >= threshold) finish()\n  }\n\n  const point = (e: React.PointerEvent) => {\n    const r = canvasRef.current!.getBoundingClientRect()\n    return { x: e.clientX - r.left, y: e.clientY - r.top }\n  }\n\n  const foilOpacity = complete ? 0 : 1\n  const hintOpacity = Math.max(0, 1 - percent / Math.max(1, threshold))\n\n  return (\n    <div\n      ref={boxRef}\n      data-slot=\"scratch-to-reveal\"\n      data-tier=\"premium\"\n      data-state={gone ? \"revealed\" : complete ? \"revealing\" : \"covered\"}\n      className={cn(\"relative mx-auto w-full max-w-sm overflow-hidden rounded-xl border border-border bg-surface shadow-raised\", className)}\n    >\n      <div aria-hidden={!complete} inert={!complete} data-slot=\"scratch-to-reveal-content\">\n        {children ?? <SampleReward />}\n      </div>\n      {!gone ? (\n        <motion.canvas\n          ref={canvasRef}\n          aria-hidden\n          data-slot=\"scratch-to-reveal-foil\"\n          initial={false}\n          animate={{ opacity: foilOpacity }}\n          transition={reduce ? { duration: 0 } : { duration: 0.5, ease: [0.3, 0, 0.8, 0.15] }}\n          onAnimationComplete={() => {\n            if (complete) setGone(true)\n          }}\n          className={cn(\"absolute inset-0 size-full touch-none\", complete ? \"pointer-events-none\" : \"cursor-crosshair\")}\n          onPointerDown={(e) => {\n            if (complete || e.button !== 0) return\n            e.currentTarget.setPointerCapture(e.pointerId)\n            last.current = null\n            const p = point(e)\n            scratch(p.x, p.y)\n          }}\n          onPointerMove={(e) => {\n            if (complete || !e.currentTarget.hasPointerCapture(e.pointerId)) return\n            const p = point(e)\n            scratch(p.x, p.y)\n          }}\n          onPointerUp={() => {\n            last.current = null\n          }}\n          onPointerCancel={() => {\n            last.current = null\n          }}\n        />\n      ) : null}\n      {!complete ? (\n        <div aria-hidden className=\"pointer-events-none absolute inset-0 grid place-items-center\" style={{ opacity: hintOpacity }}>\n          <span data-slot=\"scratch-to-reveal-label\" className=\"rounded-full bg-ink/85 px-3 py-1 text-xs font-medium text-on-ink shadow-ink\">\n            {label}\n          </span>\n        </div>\n      ) : null}\n      {!complete ? (\n        <button\n          type=\"button\"\n          onClick={finish}\n          data-slot=\"scratch-to-reveal-reveal\"\n          className=\"absolute right-3 bottom-3 inline-flex h-7 items-center rounded-md border border-border bg-surface px-2.5 text-xs font-medium text-fg-muted shadow-key outline-none transition-colors duration-[140ms] hover:text-fg focus-visible:ring-2 focus-visible:ring-accent\"\n        >\n          Reveal\n        </button>\n      ) : null}\n      <span id={statusId} role=\"status\" aria-live=\"polite\" className=\"sr-only\">\n        {complete ? \"Revealed.\" : percent ? `${percent}% scratched.` : \"\"}\n      </span>\n    </div>\n  )\n}\n\nexport { ScratchToReveal }\nexport type { ScratchToRevealProps }\n"
    }
  ]
}
