Docs
Scratch To Reveal
The ScratchToReveal component creates an interactive scratch-off effect with customizable dimensions and animations, revealing hidden content beneath.
Installation
Install dependencies
npm install framer-motion lucide-react
Run the following command
It will create a new file scratch-to-reveal.tsx
inside the components/mage-ui/card
directory.
mkdir -p components/mage-ui/card && touch components/mage-ui/card/scratch-to-reveal.tsx
Paste the code
Open the newly created file and paste the following code:
"use client";
import { cn } from "@/lib/utils";
import { motion, useAnimation } from "framer-motion";
import React, { useEffect, useRef, useState } from "react";
interface ScratchToRevealProps {
children: React.ReactNode;
width: number;
height: number;
minScratchPercentage?: number;
className?: string;
onComplete?: () => void;
gradientColors?: [string, string, string];
}
export const ScratchToReveal: React.FC<ScratchToRevealProps> = ({
width,
height,
minScratchPercentage = 50,
onComplete,
children,
className,
gradientColors = ["#A97CF8", "#F38CB8", "#FDCC92"],
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isScratching, setIsScratching] = useState(false);
const [isComplete, setIsComplete] = useState(false);
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);
const gradient = ctx.createLinearGradient(
0,
0,
canvas.width,
canvas.height,
);
gradient.addColorStop(0, gradientColors[0]);
gradient.addColorStop(0.5, gradientColors[1]);
gradient.addColorStop(1, gradientColors[2]);
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, canvas.width, canvas.height);
}
}, [gradientColors]);
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;
const y = clientY - rect.top + 16;
ctx.globalCompositeOperation = "destination-out";
ctx.beginPath();
ctx.arc(x, y, 30, 0, Math.PI * 2);
ctx.fill();
}
};
const startAnimation = async () => {
await controls.start({
scale: [1, 1.5, 1],
rotate: [0, 10, -10, 10, -10, 0],
transition: { duration: 0.5 },
});
// Call onComplete after animation finishes
if (onComplete) {
onComplete();
}
};
const checkCompletion = () => {
if (isComplete) return;
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);
ctx.clearRect(0, 0, canvas.width, canvas.height);
startAnimation();
}
}
};
return (
<motion.div
className={cn("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>
);
};
export function ScratchToRevealDemo() {
return (
<ScratchToReveal
width={250}
height={250}
minScratchPercentage={70}
className="flex items-center justify-center overflow-hidden rounded-2xl border-2 bg-gray-100"
gradientColors={["#A97CF8", "#F38CB8", "#FDCC92"]}
>
<p className="text-9xl">😎</p>
</ScratchToReveal>
);
}