Scratch To Reveal

The ScratchToReveal component creates an interactive scratch-off effect with customizable dimensions and animations, revealing hidden content beneath.


Usage

Code

import React, { useEffect, useRef, useState } from 'react'
import { motion, useAnimation } from 'framer-motion'
import { cnBase } from 'tailwind-variants'
export interface ScratchToRevealProps {
children: React.ReactNode
width: number
height: number
minScratchPercentage?: number // Minimum percentage of scratched area to be considered as completed (Value between 0 and 100)
className?: string
onComplete?: () => void
}
export const ScratchToReveal: React.FC<ScratchToRevealProps> = ({
width,
height,
minScratchPercentage = 50,
onComplete,
children,
className,
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null)
const [isScratching, setIsScratching] = useState(false)
const [isComplete, setIsComplete] = useState(false) // New state to track completion
const controls = useAnimation()
useEffect(() => {
const canvas = canvasRef.current
const ctx = canvas?.getContext('2d')
if (canvas && ctx) {
ctx.fillStyle = '#ccc'
ctx.fillRect(0, 0, canvas.width, canvas.height)
// Optionally add a background image or gradient
const gradient = ctx.createLinearGradient(
0,
0,
canvas.width,
canvas.height,
)
gradient.addColorStop(0, '#A97CF8')
gradient.addColorStop(0.5, '#F38CB8')
gradient.addColorStop(1, '#FDCC92')
ctx.fillStyle = gradient
ctx.fillRect(0, 0, canvas.width, canvas.height)
}
}, [])
useEffect(() => {
const handleDocumentMouseMove = (event: MouseEvent) => {
if (!isScratching)
return
scratch(event.clientX, event.clientY)
}
const handleDocumentTouchMove = (event: TouchEvent) => {
if (!isScratching)
return
const touch = event.touches[0]
scratch(touch.clientX, touch.clientY)
}
const handleDocumentMouseUp = () => {
setIsScratching(false)
checkCompletion()
}
const handleDocumentTouchEnd = () => {
setIsScratching(false)
checkCompletion()
}
document.addEventListener('mousedown', handleDocumentMouseMove)
document.addEventListener('mousemove', handleDocumentMouseMove)
document.addEventListener('touchstart', handleDocumentTouchMove)
document.addEventListener('touchmove', handleDocumentTouchMove)
document.addEventListener('mouseup', handleDocumentMouseUp)
document.addEventListener('touchend', handleDocumentTouchEnd)
document.addEventListener('touchcancel', handleDocumentTouchEnd)
return () => {
document.removeEventListener('mousedown', handleDocumentMouseMove)
document.removeEventListener('mousemove', handleDocumentMouseMove)
document.removeEventListener('touchstart', handleDocumentTouchMove)
document.removeEventListener('touchmove', handleDocumentTouchMove)
document.removeEventListener('mouseup', handleDocumentMouseUp)
document.removeEventListener('touchend', handleDocumentTouchEnd)
document.removeEventListener('touchcancel', handleDocumentTouchEnd)
}
}, [isScratching])
const handleMouseDown = () => setIsScratching(true)
const handleTouchStart = () => setIsScratching(true)
const scratch = (clientX: number, clientY: number) => {
const canvas = canvasRef.current
const ctx = canvas?.getContext('2d')
if (canvas && ctx) {
const rect = canvas.getBoundingClientRect()
const x = clientX - rect.left + 16 // offset to position the scratched circle with cursor
const y = clientY - rect.top + 16
ctx.globalCompositeOperation = 'destination-out'
ctx.beginPath()
ctx.arc(x, y, 30, 0, Math.PI * 2)
ctx.fill()
}
}
const checkCompletion = () => {
if (isComplete)
return // Check if already completed
const canvas = canvasRef.current
const ctx = canvas?.getContext('2d')
if (canvas && ctx) {
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
const pixels = imageData.data
const totalPixels = pixels.length / 4
let clearPixels = 0
for (let i = 3; i < pixels.length; i += 4) {
if (pixels[i] === 0)
clearPixels++
}
const percentage = (clearPixels / totalPixels) * 100
if (percentage >= minScratchPercentage) {
setIsComplete(true) // Set complete flag
ctx.clearRect(0, 0, canvas.width, canvas.height) // Clear the canvas to reveal everything
startAnimation()
if (onComplete)
onComplete()
}
}
}
const startAnimation = () => {
controls.start({
scale: [1, 1.5, 1],
rotate: [0, 10, -10, 10, -10, 0],
transition: { duration: 0.5 },
})
}
return (
<motion.div
className={cnBase('relative select-none', className)}
style={{
width,
height,
cursor:
'url(\'data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIzMiIgaGVpZ2h0PSIzMiIgdmlld0JveD0iMCAwIDMyIDMyIj4KICA8Y2lyY2xlIGN4PSIxNiIgY3k9IjE2IiByPSIxNSIgc3R5bGU9ImZpbGw6I2ZmZjtzdHJva2U6IzAwMDtzdHJva2Utd2lkdGg6MXB4OyIgLz4KPC9zdmc+\'), auto',
}}
animate={controls}
>
<canvas
ref={canvasRef}
width={width}
height={height}
className="absolute left-0 top-0"
onMouseDown={handleMouseDown}
onTouchStart={handleTouchStart}
>
</canvas>
{children}
</motion.div>
)
}

Props

AttributeTypeDescriptionDefault
classNamestringThe class name to be applied to the component.undefined
childrenReactNodeThe content to be displayed inside.undefined
widthnumberWidth of the scratch container.undefined
heightnumberHeight of the scratch container.undefined
minScratchPercentagenumberMinimum percentage of scratched area to be considered as completed (Value between 0 and 100).50
onCompetefunctionCallback function called when scratch is completedundefined