{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "scratch-card",
  "title": "Scratch Card",
  "description": "A scratch-to-reveal coupon card with an HTML5 canvas foil, dust particles on scratch, and a reveal callback at a configurable threshold",
  "dependencies": [
    "framer-motion"
  ],
  "files": [
    {
      "path": "app/registry/scratch-card/scratch-card.tsx",
      "content": "/**\n * Spectrum UI — ScratchCard\n *\n * A scratch-to-reveal card. An HTML5 canvas foil covers the content and is\n * erased on pointer drag, emitting dust particles while scratching. Once the\n * cleared area passes the reveal threshold, the remaining foil fades out and\n * onReveal fires exactly once.\n *\n * Dependencies: framer-motion, @/lib/utils\n *\n * @example\n * <ScratchCard onReveal={() => {}} ariaLabel=\"Scratch to reveal your coupon\">\n *   <CouponContent />\n * </ScratchCard>\n */\n\n\"use client\"\n\nimport React, { useCallback, useEffect, useRef, useState } from \"react\"\nimport { motion, AnimatePresence, useReducedMotion } from \"framer-motion\"\nimport { cn } from \"@/lib/utils\"\nimport type { ReactNode } from \"react\"\n\n// ─── Types ───────────────────────────────────────────────────────────────────\n\nexport interface ScratchCardProps {\n  /** Content hidden underneath the scratch foil */\n  children: ReactNode\n  /** Fires once when the cleared area passes revealThreshold */\n  onReveal?: () => void\n  /** Fires with the cleared ratio (0–1) while scratching */\n  onProgress?: (progress: number) => void\n  /** Cleared ratio (0–1) that triggers the full reveal. Default 0.5 */\n  revealThreshold?: number\n  /** Scratch brush diameter in pixels. Default 28 */\n  brushSize?: number\n  /** Text printed on the foil surface */\n  overlayLabel?: string\n  /** Foil surface color */\n  overlayColor?: string\n  /** Foil label color */\n  overlayLabelColor?: string\n  /** Dust particle color */\n  particleColor?: string\n  /** Accessible label for the scratch surface */\n  ariaLabel?: string\n  /** Announced to screen readers when the content is revealed */\n  revealAnnouncement?: string\n  className?: string\n}\n\ninterface DustParticle {\n  x: number\n  y: number\n  vx: number\n  vy: number\n  size: number\n  life: number\n  maxLife: number\n}\n\n// ─── Constants ───────────────────────────────────────────────────────────────\n\nconst PROGRESS_SAMPLE_STRIDE = 32\nconst PROGRESS_CHECK_EVERY_N_MOVES = 10\nconst MAX_PARTICLES = 120\nconst PARTICLE_GRAVITY = 0.18\n\n// ─── Component ───────────────────────────────────────────────────────────────\n\nexport function ScratchCard({\n  children,\n  onReveal,\n  onProgress,\n  revealThreshold = 0.5,\n  brushSize = 28,\n  overlayLabel = \"Scratch to reveal\",\n  overlayColor = \"#171717\",\n  overlayLabelColor = \"#737373\",\n  particleColor = \"#a3a3a3\",\n  ariaLabel = \"Scratch surface. Press Enter to reveal the hidden content.\",\n  revealAnnouncement = \"Hidden content revealed\",\n  className,\n}: ScratchCardProps) {\n  const shouldReduceMotion = useReducedMotion()\n  const containerRef = useRef<HTMLDivElement>(null)\n  const contentRef = useRef<HTMLDivElement>(null)\n  const scratchCanvasRef = useRef<HTMLCanvasElement>(null)\n  const particleCanvasRef = useRef<HTMLCanvasElement>(null)\n  const particlesRef = useRef<DustParticle[]>([])\n  const particleRafRef = useRef<number>(0)\n  const lastPointRef = useRef<{ x: number; y: number } | null>(null)\n  const isScratchingRef = useRef(false)\n  const moveCountRef = useRef(0)\n  const revealedRef = useRef(false)\n  const [isRevealed, setIsRevealed] = useState(false)\n\n  // Keep hidden content out of the tab order and accessibility tree until revealed\n  useEffect(() => {\n    const node = contentRef.current\n    if (!node) return\n    if (isRevealed) node.removeAttribute(\"inert\")\n    else node.setAttribute(\"inert\", \"\")\n  }, [isRevealed])\n\n  const paintOverlay = useCallback(\n    (canvas: HTMLCanvasElement, width: number, height: number) => {\n      const ctx = canvas.getContext(\"2d\")\n      if (!ctx) return\n      const dpr = window.devicePixelRatio || 1\n      canvas.width = Math.max(1, Math.round(width * dpr))\n      canvas.height = Math.max(1, Math.round(height * dpr))\n      ctx.scale(dpr, dpr)\n\n      // Foil base\n      ctx.fillStyle = overlayColor\n      ctx.fillRect(0, 0, width, height)\n\n      // Subtle diagonal brushed texture\n      ctx.strokeStyle = \"rgba(255, 255, 255, 0.04)\"\n      ctx.lineWidth = 1\n      for (let x = -height; x < width; x += 8) {\n        ctx.beginPath()\n        ctx.moveTo(x, 0)\n        ctx.lineTo(x + height, height)\n        ctx.stroke()\n      }\n\n      // Centered label\n      if (overlayLabel) {\n        const extendedCtx = ctx as CanvasRenderingContext2D & {\n          letterSpacing?: string\n        }\n        if (\"letterSpacing\" in extendedCtx) extendedCtx.letterSpacing = \"3px\"\n        ctx.fillStyle = overlayLabelColor\n        ctx.font =\n          \"500 11px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace\"\n        ctx.textAlign = \"center\"\n        ctx.textBaseline = \"middle\"\n        ctx.fillText(overlayLabel.toUpperCase(), width / 2, height / 2)\n      }\n    },\n    [overlayColor, overlayLabel, overlayLabelColor],\n  )\n\n  // Size both canvases to the container and paint the foil\n  useEffect(() => {\n    const container = containerRef.current\n    const scratchCanvas = scratchCanvasRef.current\n    const particleCanvas = particleCanvasRef.current\n    if (!container || !scratchCanvas || !particleCanvas) return\n\n    let lastWidth = 0\n    let lastHeight = 0\n\n    const resize = () => {\n      const { width, height } = container.getBoundingClientRect()\n      if (width === lastWidth && height === lastHeight) return\n      lastWidth = width\n      lastHeight = height\n      if (revealedRef.current) return\n      const dpr = window.devicePixelRatio || 1\n      paintOverlay(scratchCanvas, width, height)\n      particleCanvas.width = Math.max(1, Math.round(width * dpr))\n      particleCanvas.height = Math.max(1, Math.round(height * dpr))\n      const particleCtx = particleCanvas.getContext(\"2d\")\n      particleCtx?.scale(dpr, dpr)\n    }\n\n    const observer = new ResizeObserver(resize)\n    observer.observe(container)\n    resize()\n\n    return () => observer.disconnect()\n  }, [paintOverlay])\n\n  useEffect(() => {\n    return () => cancelAnimationFrame(particleRafRef.current)\n  }, [])\n\n  const reveal = useCallback(() => {\n    if (revealedRef.current) return\n    revealedRef.current = true\n    setIsRevealed(true)\n    onReveal?.()\n  }, [onReveal])\n\n  const checkProgress = useCallback(() => {\n    const canvas = scratchCanvasRef.current\n    if (!canvas || revealedRef.current) return\n    const ctx = canvas.getContext(\"2d\")\n    if (!ctx || canvas.width === 0 || canvas.height === 0) return\n\n    const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height)\n    let cleared = 0\n    let sampled = 0\n    for (let i = 3; i < data.length; i += 4 * PROGRESS_SAMPLE_STRIDE) {\n      if (data[i] < 128) cleared++\n      sampled++\n    }\n    if (sampled === 0) return\n\n    const progress = cleared / sampled\n    onProgress?.(progress)\n    if (progress >= revealThreshold) reveal()\n  }, [onProgress, revealThreshold, reveal])\n\n  const runParticleLoop = useCallback(() => {\n    const canvas = particleCanvasRef.current\n    const ctx = canvas?.getContext(\"2d\")\n    if (!canvas || !ctx) return\n\n    cancelAnimationFrame(particleRafRef.current)\n\n    const tick = () => {\n      const { width, height } = canvas.getBoundingClientRect()\n      ctx.clearRect(0, 0, width, height)\n\n      const alive: DustParticle[] = []\n      for (const particle of particlesRef.current) {\n        particle.vy += PARTICLE_GRAVITY\n        particle.x += particle.vx\n        particle.y += particle.vy\n        particle.life -= 1\n        if (particle.life <= 0) continue\n        ctx.globalAlpha = particle.life / particle.maxLife\n        ctx.fillStyle = particleColor\n        ctx.fillRect(particle.x, particle.y, particle.size, particle.size)\n        alive.push(particle)\n      }\n      ctx.globalAlpha = 1\n      particlesRef.current = alive\n\n      if (alive.length > 0) {\n        particleRafRef.current = requestAnimationFrame(tick)\n      } else {\n        ctx.clearRect(0, 0, width, height)\n      }\n    }\n\n    particleRafRef.current = requestAnimationFrame(tick)\n  }, [particleColor])\n\n  const spawnParticles = useCallback(\n    (x: number, y: number) => {\n      if (shouldReduceMotion) return\n      if (particlesRef.current.length >= MAX_PARTICLES) return\n      for (let i = 0; i < 2; i++) {\n        const maxLife = 24 + Math.random() * 20\n        particlesRef.current.push({\n          x: x + (Math.random() - 0.5) * brushSize * 0.6,\n          y: y + (Math.random() - 0.5) * brushSize * 0.6,\n          vx: (Math.random() - 0.5) * 1.6,\n          vy: -Math.random() * 1.4,\n          size: 1.5 + Math.random() * 2,\n          life: maxLife,\n          maxLife,\n        })\n      }\n      runParticleLoop()\n    },\n    [shouldReduceMotion, brushSize, runParticleLoop],\n  )\n\n  const getPoint = useCallback((event: React.PointerEvent<HTMLCanvasElement>) => {\n    const rect = event.currentTarget.getBoundingClientRect()\n    return { x: event.clientX - rect.left, y: event.clientY - rect.top }\n  }, [])\n\n  const scratchLine = useCallback(\n    (from: { x: number; y: number }, to: { x: number; y: number }) => {\n      const canvas = scratchCanvasRef.current\n      const ctx = canvas?.getContext(\"2d\")\n      if (!ctx) return\n      ctx.globalCompositeOperation = \"destination-out\"\n      ctx.lineWidth = brushSize\n      ctx.lineCap = \"round\"\n      ctx.lineJoin = \"round\"\n      ctx.beginPath()\n      ctx.moveTo(from.x, from.y)\n      ctx.lineTo(to.x, to.y)\n      ctx.stroke()\n      ctx.beginPath()\n      ctx.arc(to.x, to.y, brushSize / 2, 0, Math.PI * 2)\n      ctx.fill()\n      ctx.globalCompositeOperation = \"source-over\"\n    },\n    [brushSize],\n  )\n\n  const handlePointerDown = useCallback(\n    (event: React.PointerEvent<HTMLCanvasElement>) => {\n      event.currentTarget.setPointerCapture(event.pointerId)\n      isScratchingRef.current = true\n      const point = getPoint(event)\n      scratchLine(point, point)\n      spawnParticles(point.x, point.y)\n      lastPointRef.current = point\n    },\n    [getPoint, scratchLine, spawnParticles],\n  )\n\n  const handlePointerMove = useCallback(\n    (event: React.PointerEvent<HTMLCanvasElement>) => {\n      if (!isScratchingRef.current) return\n      const point = getPoint(event)\n      scratchLine(lastPointRef.current ?? point, point)\n      lastPointRef.current = point\n\n      moveCountRef.current += 1\n      if (moveCountRef.current % 2 === 0) spawnParticles(point.x, point.y)\n      if (moveCountRef.current % PROGRESS_CHECK_EVERY_N_MOVES === 0) {\n        checkProgress()\n      }\n    },\n    [getPoint, scratchLine, spawnParticles, checkProgress],\n  )\n\n  const handlePointerUp = useCallback(() => {\n    isScratchingRef.current = false\n    lastPointRef.current = null\n    checkProgress()\n  }, [checkProgress])\n\n  const handleKeyDown = useCallback(\n    (event: React.KeyboardEvent<HTMLCanvasElement>) => {\n      if (event.key === \"Enter\" || event.key === \" \") {\n        event.preventDefault()\n        reveal()\n      }\n    },\n    [reveal],\n  )\n\n  return (\n    <div\n      ref={containerRef}\n      className={cn(\n        \"relative w-full select-none overflow-hidden rounded-2xl\",\n        \"border border-neutral-200 dark:border-neutral-800\",\n        \"bg-white dark:bg-neutral-900\",\n        className,\n      )}\n    >\n      <div ref={contentRef}>{children}</div>\n\n      <AnimatePresence>\n        {!isRevealed && (\n          <motion.div\n            key=\"scratch-foil\"\n            className=\"absolute inset-0 z-10\"\n            initial={false}\n            exit={{ opacity: 0 }}\n            transition={\n              shouldReduceMotion\n                ? { duration: 0 }\n                : { duration: 0.4, ease: \"easeOut\" }\n            }\n          >\n            <canvas\n              ref={scratchCanvasRef}\n              role=\"button\"\n              tabIndex={0}\n              aria-label={ariaLabel}\n              onPointerDown={handlePointerDown}\n              onPointerMove={handlePointerMove}\n              onPointerUp={handlePointerUp}\n              onPointerCancel={handlePointerUp}\n              onKeyDown={handleKeyDown}\n              className={cn(\n                \"block h-full w-full cursor-crosshair touch-none\",\n                \"focus-visible:outline-hidden focus-visible:ring-1\",\n                \"focus-visible:ring-neutral-950 dark:focus-visible:ring-neutral-300\",\n              )}\n            />\n            <canvas\n              ref={particleCanvasRef}\n              aria-hidden=\"true\"\n              className=\"pointer-events-none absolute inset-0 h-full w-full\"\n            />\n          </motion.div>\n        )}\n      </AnimatePresence>\n\n      <span className=\"sr-only\" aria-live=\"polite\">\n        {isRevealed ? revealAnnouncement : \"\"}\n      </span>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/spectrumui/scratch-card.tsx"
    }
  ],
  "type": "registry:component"
}
