Oblivion
Oblivion
Docs
Go back
Component by
OV
Oblivion

Premium Reward

$500

CRWN500
TSX
scratch-to-reveal-card
"use client"

import React, { useRef, useState, useEffect } from "react"
import { motion } from "framer-motion"
import { cn } from "@/lib/utils"
// Ensure you have lucide-react installed
import { Crown } from "lucide-react"

export const componentMeta = {
  name: "Scratch To Reveal",
  description: "An ultra-premium Black & Gold scratch card with realistic foil texture, ticket punches, and buttery smooth pointer tracking.",
  props: {
    width: { type: "number", description: "Width of the card", default: "280" },
    height: { type: "number", description: "Height of the card", default: "380" },
    brushSize: { type: "number", description: "Size of the scratch brush", default: "40" },
    revealThreshold: { type: "number", description: "Percentage of area to scratch before auto-revealing", default: "50" }
  }
}

interface ScratchToRevealProps {
  width?: number;
  height?: number;
  brushSize?: number;
  revealThreshold?: number;
  className?: string;
  onReveal?: () => void;
}

export default function ScratchToReveal({
  width = 280,
  height = 380,
  brushSize = 40,
  revealThreshold = 50,
  className,
  onReveal,
}: ScratchToRevealProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null)
  const [isRevealed, setIsRevealed] = useState(false)
  const [copied, setCopied] = useState(false)
  
  // Track last pointer position for continuous smooth line drawing
  const lastPos = useRef<{ x: number, y: number } | null>(null)
  // Cache canvas rect to avoid layout thrashing on every pointer move
  const cachedRect = useRef<DOMRect | null>(null)

  useEffect(() => {
    const canvas = canvasRef.current
    if (!canvas) return
    const ctx = canvas.getContext("2d", { willReadFrequently: true })
    if (!ctx) return

    // Draw Premium Gold Foil Background
    const gradient = ctx.createLinearGradient(0, 0, canvas.width, canvas.height)
    gradient.addColorStop(0, "#E5C158")
    gradient.addColorStop(0.3, "#FFF5C3")
    gradient.addColorStop(0.5, "#D4AF37")
    gradient.addColorStop(0.7, "#FFD700")
    gradient.addColorStop(1, "#8B6508")
    ctx.fillStyle = gradient
    ctx.fillRect(0, 0, canvas.width, canvas.height)

    // Add noise texture for realistic foil grit
    ctx.fillStyle = "rgba(255,255,255,0.15)"
    for(let i = 0; i < canvas.width; i += 3) {
      for(let j = 0; j < canvas.height; j += 3) {
        if(Math.random() > 0.5) ctx.fillRect(i, j, 1.5, 1.5)
      }
    }

    // Add diagonal shimmering lines to simulate holographic reflection
    ctx.lineWidth = 1
    ctx.strokeStyle = "rgba(255,255,255,0.2)"
    for (let i = -canvas.height; i < canvas.width; i += 15) {
      ctx.beginPath()
      ctx.moveTo(i, 0)
      ctx.lineTo(i + canvas.height, canvas.height)
      ctx.stroke()
    }

    // Centered Text: "SCRATCH ME"
    ctx.font = "800 28px sans-serif"
    ctx.textAlign = "center"
    ctx.textBaseline = "middle"
    
    // Inset shadow for text
    ctx.fillStyle = "rgba(100, 70, 0, 0.4)"
    ctx.fillText("SCRATCH ME", canvas.width / 2, canvas.height / 2 + 2)
    
    // Main text
    ctx.fillStyle = "#FFFFFF"
    ctx.shadowColor = "rgba(0,0,0,0.1)"
    ctx.shadowBlur = 4
    ctx.fillText("SCRATCH ME", canvas.width / 2, canvas.height / 2)
    ctx.shadowBlur = 0 // Reset

  }, [width, height])

  const checkReveal = () => {
    if (isRevealed) return
    const canvas = canvasRef.current
    if (!canvas) return
    const ctx = canvas.getContext("2d", { willReadFrequently: true })
    if (!ctx) return

    const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
    const pixels = imageData.data
    let transparentPixels = 0

    // Use a strided loop to drastically improve performance (check 1 in every 16 pixels)
    let sampledPixels = 0
    for (let i = 3; i < pixels.length; i += 16) {
      if (pixels[i] < 128) transparentPixels++
      sampledPixels++
    }

    const percentage = (transparentPixels / sampledPixels) * 100
    if (percentage > revealThreshold) {
      setIsRevealed(true)
      onReveal?.()
    }
  }

  const getCoordinates = (e: React.PointerEvent) => {
    if (!cachedRect.current && canvasRef.current) {
        cachedRect.current = canvasRef.current.getBoundingClientRect()
    }
    const rect = cachedRect.current!
    const x = e.clientX - rect.left
    const y = e.clientY - rect.top
    return { x, y }
  }

  const scratch = (e: React.PointerEvent) => {
    if (isRevealed || !lastPos.current) return
    
    const canvas = canvasRef.current
    if (!canvas) return
    const ctx = canvas.getContext("2d", { willReadFrequently: true })
    if (!ctx) return

    const { x, y } = getCoordinates(e)

    ctx.globalCompositeOperation = "destination-out"
    ctx.strokeStyle = "#000"
    ctx.lineWidth = brushSize
    ctx.lineCap = "round"
    ctx.lineJoin = "round"
    ctx.beginPath()
    ctx.moveTo(lastPos.current.x, lastPos.current.y)
    ctx.lineTo(x, y)
    ctx.stroke()
    
    lastPos.current = { x, y }
  }

  const handlePointerDown = (e: React.PointerEvent) => {
    if (canvasRef.current) {
      cachedRect.current = canvasRef.current.getBoundingClientRect()
      canvasRef.current.setPointerCapture(e.pointerId)
    }

    lastPos.current = getCoordinates(e)
    
    const canvas = canvasRef.current
    if (!canvas) return
    const ctx = canvas.getContext("2d", { willReadFrequently: true })
    if (!ctx) return
    const { x, y } = lastPos.current
    ctx.globalCompositeOperation = "destination-out"
    ctx.fillStyle = "#000"
    ctx.beginPath()
    ctx.arc(x, y, brushSize / 2, 0, Math.PI * 2)
    ctx.fill()
  }

  const handlePointerMove = (e: React.PointerEvent) => {
    if (!lastPos.current || isRevealed) return
    scratch(e)
  }

  const handlePointerUp = (e: React.PointerEvent) => {
    if (canvasRef.current) {
      canvasRef.current.releasePointerCapture(e.pointerId)
    }
    if (!lastPos.current) return
    lastPos.current = null
    checkReveal()
  }

  const copyCode = () => {
    navigator.clipboard.writeText("CRWN500")
    setCopied(true)
    setTimeout(() => setCopied(false), 2000)
  }

  const ticketMask = "radial-gradient(circle at 0px 50%, transparent 16px, black 17px), radial-gradient(circle at 100% 50%, transparent 16px, black 17px)"

  return (
    <div 
      className={cn("relative group select-none", className)}
      style={{ 
        width, height,
        filter: "drop-shadow(0px 10px 30px rgba(0,0,0,0.15))"
      }}
    >
      <div 
        className="absolute inset-0 bg-zinc-950 rounded-[2rem] overflow-hidden"
        style={{
          WebkitMaskImage: ticketMask,
          WebkitMaskSize: "51% 100%",
          WebkitMaskRepeat: "no-repeat",
          WebkitMaskPosition: "0 0, 100% 0",
          maskImage: ticketMask,
          maskSize: "51% 100%",
          maskRepeat: "no-repeat",
          maskPosition: "0 0, 100% 0",
        }}
      >
        {/* Luxury Priority Reward Background */}
        <div className="absolute inset-0 bg-zinc-950 flex flex-col items-center justify-center p-6 text-center overflow-hidden">
          {/* Subtle Ambient Gold Glow */}
          <div className="absolute top-0 inset-x-0 h-40 bg-yellow-500/10 blur-3xl rounded-full" />
          
          <motion.div 
            initial={{ scale: 0.9, opacity: 0 }}
            animate={{ scale: isRevealed ? 1 : 0.9, opacity: isRevealed ? 1 : 0 }}
            transition={{ type: "spring", stiffness: 200, damping: 20 }}
            className="flex flex-col items-center w-full z-10"
          >
            <div className="w-16 h-16 bg-gradient-to-tr from-yellow-600 to-yellow-300 rounded-full flex items-center justify-center mb-6 shadow-xl shadow-yellow-500/20">
              <Crown className="w-8 h-8 text-black fill-black" />
            </div>
            
            <h3 className="text-zinc-500 font-bold uppercase tracking-[0.2em] text-[10px] mb-1">Premium Reward</h3>
            <h2 className="text-white font-serif text-5xl font-light tracking-tight mb-8 flex items-baseline gap-1">
              <span className="text-yellow-500 font-sans text-3xl font-bold">$</span>500
            </h2>
            
            <div className="bg-zinc-900/80 border border-zinc-800 rounded-xl p-2.5 flex items-center justify-between w-full relative z-20 backdrop-blur-md">
              <span className="font-mono font-bold text-yellow-500 tracking-widest pl-3 text-sm">CRWN500</span>
              <button 
                onClick={copyCode}
                className="bg-white text-zinc-950 px-4 py-2 text-[10px] font-black uppercase tracking-widest rounded-md hover:bg-yellow-400 active:scale-95 transition-all"
              >
                {copied ? "Copied" : "Copy"}
              </button>
            </div>
          </motion.div>
        </div>

        {/* Scratchable Gold Foil Layer */}
        <motion.canvas
          ref={canvasRef}
          width={width}
          height={height}
          className={cn(
            "absolute inset-0 z-10 cursor-crosshair transition-all duration-700 ease-out touch-none",
            isRevealed ? "opacity-0 scale-105 pointer-events-none" : "opacity-100 scale-100"
          )}
          style={{ touchAction: 'none' }}
          onPointerDown={handlePointerDown}
          onPointerMove={handlePointerMove}
          onPointerUp={handlePointerUp}
          onPointerCancel={handlePointerUp}
        />
      </div>
    </div>
  )
}
Save to favoritesCopy to Figma
React

Scratch To Reveal

An ultra-premium Black & Gold scratch card with realistic foil texture, ticket punches, and buttery smooth pointer tracking.

Installation

$ npx @cosmoo/oblivion add scratch-to-reveal-card
Dependenciesnpm install framer-motion lucide-react

Usage Example

tsx
import ScratchToRevealCard from "@/components/oblivion/scratch-to-reveal-card"

export default function Demo() {
  return (
    <div className="p-10 flex justify-center">
      <ScratchToRevealCard />
    </div>
  )
}