Docs
Go back
Component by
OV
OblivionPremium 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>
)
}"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-cardDependencies
npm install framer-motion lucide-reactUsage 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>
)
}import ScratchToRevealCard from "@/components/oblivion/scratch-to-reveal-card"
export default function Demo() {
return (
<div className="p-10 flex justify-center">
<ScratchToRevealCard />
</div>
)
}