🤯
Installation
pnpm add framer-motion
pnpm add framer-motion
Copy and paste the following code into your project.
components/eldoraui/scratchtoreveal.tsx
import { cn } from "@/lib/utils";
import React, { useRef, useEffect, useState } from "react";
import { motion, useAnimation } from "framer-motion";
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;
}
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={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 ScratchToReveal;
Update the import paths to match your project setup.
Props
Prop | Type | Default | Description |
---|---|---|---|
className | string | The class name to apply to the component. | |
width | number | Width of the scratch container. | |
height | number | Height of the scratch container. | |
minScratchPercentage | number | 50 | Minimum percentage of scratched area to be considered as completed (Value between 0 and 100 ). |
children | node | The content to display in the marquee. | |
onCompete | function | Callback function called when scratch is completed |