A scratch-off erase effect for React that reveals hidden content as you drag, with progress tracking and pre-erased areas.
This page is under construction. Please d'ont erase this content.
This page is under construction. Please d'ont erase this content.
<Erase
className="h-full w-full"
initialErase={points}
key={key}
onProgress={handleProgress}
>
<ErasePreview className="flex h-full w-full flex-col items-center justify-center gap-4 bg-muted">
<h2 className="font-bold font-serif text-[120px] text-transparent leading-23 tracking-hero [-webkit-text-stroke:2px_var(--foreground)]">
404
</h2>
<span className="font-bold font-serif text-transparent text-xl tracking-hero [-webkit-text-stroke:1px_var(--foreground)]">
Page Not Found {(ratio * 100).toFixed(0)}%
</span>
</ErasePreview>
<EraseContent className="flex h-full w-full flex-col items-center justify-center gap-4">
<h2 className="font-bold font-serif text-[120px] text-foreground leading-23 tracking-hero">
404
</h2>
<span className="font-bold font-serif text-foreground text-xl tracking-hero">
Page Not Found {(ratio * 100).toFixed(0)}%
</span>
</EraseContent>
</Erase>Copy and paste the following code into your project.
"use client";
import React, {
createContext,
type MouseEvent,
type ReactNode,
type TouchEvent,
useCallback,
useContext,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
interface EraseContextValue {
maskId: string;
}
interface EraseMark {
points: readonly { x: number; y: number }[];
}
interface Stroke {
d: string;
id: string;
kind: "drawn" | "initial";
points: readonly { x: number; y: number }[];
}
const EMPTY_MARKS: readonly EraseMark[] = [];
type ContainerPointerEvent =
| MouseEvent<HTMLDivElement>
| TouchEvent<HTMLDivElement>;
interface EraseProgress {
erasedPoints: readonly EraseMark[];
ratio: number;
}
const EraseContext = createContext<EraseContextValue | null>(null);
const SAMPLE_SCALE = 0.25;
const RATIO_DECIMALS = 1000;
function getRelativePoint(
event: ContainerPointerEvent,
container: HTMLDivElement
) {
const rect = container.getBoundingClientRect();
const point = "touches" in event ? event.touches.item(0) : event;
if (!point) {
return null;
}
return {
x: point.clientX - rect.left,
y: point.clientY - rect.top,
};
}
function markToPath(
mark: EraseMark,
width: number,
height: number
): string | null {
const [first, ...rest] = mark.points;
if (!first) {
return null;
}
let path = `M ${first.x * width} ${first.y * height}`;
for (const point of rest) {
path = `${path} L ${point.x * width} ${point.y * height}`;
}
if (rest.length === 0) {
return `${path} L ${first.x * width} ${first.y * height}`;
}
return path;
}
function marksToStrokes(
marks: readonly EraseMark[],
width: number,
height: number
): Stroke[] {
const strokes: Stroke[] = [];
for (const [index, mark] of marks.entries()) {
const path = markToPath(mark, width, height);
if (!path) {
continue;
}
strokes.push({
d: path,
id: `initial-${index}`,
kind: "initial",
points: mark.points,
});
}
return strokes;
}
function layoutDrawnStrokes(
previous: readonly Stroke[],
width: number,
height: number
): { changed: boolean; strokes: Stroke[] } {
const strokes: Stroke[] = [];
let changed = false;
for (const stroke of previous) {
if (stroke.kind !== "drawn") {
continue;
}
const nextPath = markToPath(stroke, width, height);
if (!nextPath || nextPath === stroke.d) {
strokes.push(stroke);
continue;
}
changed = true;
strokes.push({ ...stroke, d: nextPath });
}
return { changed, strokes };
}
interface CoverageCache {
brushSize: number;
height: number;
paths: readonly string[];
width: number;
}
function measureErasedRatio(
strokes: readonly string[],
width: number,
height: number,
brushSize: number,
canvas: HTMLCanvasElement,
cache: CoverageCache | null
): { cache: CoverageCache | null; ratio: number } {
if (width <= 0 || height <= 0 || strokes.length === 0) {
return { cache: null, ratio: 0 };
}
const scaledWidth = Math.max(1, Math.floor(width * SAMPLE_SCALE));
const scaledHeight = Math.max(1, Math.floor(height * SAMPLE_SCALE));
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) {
return { cache: null, ratio: 0 };
}
const canExtend =
cache !== null &&
cache.width === scaledWidth &&
cache.height === scaledHeight &&
cache.brushSize === brushSize &&
cache.paths.length <= strokes.length &&
cache.paths.every((path, index) => path === strokes[index]);
const startIndex = canExtend ? cache.paths.length : 0;
if (!canExtend) {
canvas.width = scaledWidth;
canvas.height = scaledHeight;
}
context.setTransform(SAMPLE_SCALE, 0, 0, SAMPLE_SCALE, 0, 0);
context.lineCap = "round";
context.lineJoin = "round";
context.lineWidth = brushSize;
context.strokeStyle = "#000";
for (let index = startIndex; index < strokes.length; index += 1) {
const stroke = strokes[index];
if (stroke) {
context.stroke(new Path2D(stroke));
}
}
const { data } = context.getImageData(0, 0, scaledWidth, scaledHeight);
let covered = 0;
for (let index = 3; index < data.length; index += 4) {
if (data[index] > 0) {
covered += 1;
}
}
return {
cache: {
brushSize,
height: scaledHeight,
paths: strokes,
width: scaledWidth,
},
ratio: covered / (scaledWidth * scaledHeight),
};
}
function Erase({
brushSize = 40,
children,
className,
initialErase = EMPTY_MARKS,
onEraseProgress,
...props
}: {
brushSize?: number;
children?: ReactNode;
className?: string;
initialErase?: readonly EraseMark[];
onEraseProgress?: (progress: EraseProgress) => void;
} & React.ComponentProps<"div">) {
const [isDrawing, setIsDrawing] = useState(false);
const [paths, setPaths] = useState<Stroke[]>([]);
const [currentPath, setCurrentPath] = useState<string | null>(null);
const containerRef = useRef<HTMLDivElement | null>(null);
const [containerNode, setContainerNode] = useState<HTMLDivElement | null>(
null
);
const strokeCount = useRef(0);
const currentPointsRef = useRef<readonly { x: number; y: number }[] | null>(
null
);
const onProgressRef = useRef(onEraseProgress);
const lastProgressRef = useRef<string | null>(null);
const sampleCanvasRef = useRef<HTMLCanvasElement | null>(null);
const coverageCacheRef = useRef<CoverageCache | null>(null);
const lastReportedMarksRef = useRef<readonly EraseMark[] | null>(null);
const externalEraseKeyRef = useRef<string | null>(null);
const maskId = useId().replaceAll(":", "");
const hasProgress = onEraseProgress !== undefined;
const initialEraseRef = useRef(initialErase);
const isReportedEcho = initialErase === lastReportedMarksRef.current;
if (!isReportedEcho) {
externalEraseKeyRef.current = initialErase
.map((mark) =>
mark.points.map((point) => `${point.x}:${point.y}`).join(",")
)
.join("|");
}
const initialEraseKey = externalEraseKeyRef.current ?? "";
initialEraseRef.current = initialErase;
onProgressRef.current = onEraseProgress;
useEffect(() => {
const report = onProgressRef.current;
const container = containerRef.current;
if (!(hasProgress && report && container)) {
return;
}
const frame = requestAnimationFrame(() => {
// Detached sample: never mounted, so it stays out of the a11y tree and the document.
const canvas =
sampleCanvasRef.current ?? document.createElement("canvas");
sampleCanvasRef.current = canvas;
const rect = container.getBoundingClientRect();
const committed = paths.map((path) => path.d);
const erasedPoints: EraseMark[] = paths.map((path) => ({
points: path.points,
}));
const livePoints = currentPointsRef.current;
if (livePoints?.length) {
erasedPoints.push({ points: livePoints });
}
const measured = measureErasedRatio(
committed,
rect.width,
rect.height,
brushSize,
canvas,
coverageCacheRef.current
);
if (currentPath) {
const liveMeasured = measureErasedRatio(
[...committed, currentPath],
rect.width,
rect.height,
brushSize,
canvas,
measured.cache
);
coverageCacheRef.current = measured.cache;
measured.ratio = liveMeasured.ratio;
} else {
coverageCacheRef.current = measured.cache;
}
const rounded =
Math.round(measured.ratio * RATIO_DECIMALS) / RATIO_DECIMALS;
const pointTotal = erasedPoints.reduce(
(sum, mark) => sum + mark.points.length,
0
);
const tail = erasedPoints.at(-1)?.points.at(-1);
const progressKey = `${rounded}|${pointTotal}|${tail?.x ?? ""}|${tail?.y ?? ""}`;
if (progressKey === lastProgressRef.current) {
return;
}
lastProgressRef.current = progressKey;
lastReportedMarksRef.current = erasedPoints;
report({ erasedPoints, ratio: rounded });
});
return () => {
cancelAnimationFrame(frame);
};
}, [brushSize, currentPath, hasProgress, paths]);
const setContainer = useCallback((node: HTMLDivElement | null) => {
containerRef.current = node;
setContainerNode(node);
}, []);
useLayoutEffect(() => {
if (!containerNode) {
return;
}
const applyInitialErase = () => {
const rect = containerNode.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) {
return;
}
if (initialEraseRef.current === lastReportedMarksRef.current) {
setPaths((previous) => {
let changed = false;
const next = previous.map((stroke) => {
const nextPath = markToPath(stroke, rect.width, rect.height);
if (!nextPath || nextPath === stroke.d) {
return stroke;
}
changed = true;
return { ...stroke, d: nextPath };
});
return changed ? next : previous;
});
return;
}
const marks =
initialEraseKey.length === 0 ? EMPTY_MARKS : initialEraseRef.current;
const nextInitial = marksToStrokes(marks, rect.width, rect.height);
const activePoints = currentPointsRef.current;
if (activePoints?.length) {
const activePath = markToPath(
{ points: activePoints },
rect.width,
rect.height
);
setCurrentPath((previous) =>
previous === activePath ? previous : activePath
);
}
setPaths((previous) => {
const previousInitial = previous.filter(
(stroke) => stroke.kind === "initial"
);
const initialUnchanged =
previousInitial.length === nextInitial.length &&
nextInitial.every((stroke, index) => {
const currentStroke = previousInitial[index];
return (
currentStroke?.d === stroke.d && currentStroke.id === stroke.id
);
});
const drawn = layoutDrawnStrokes(previous, rect.width, rect.height);
if (initialUnchanged && !drawn.changed) {
return previous;
}
return [...nextInitial, ...drawn.strokes];
});
};
applyInitialErase();
const observer = new ResizeObserver(applyInitialErase);
observer.observe(containerNode);
return () => {
observer.disconnect();
};
}, [containerNode, initialEraseKey]);
const readNormalizedPoint = useCallback((event: ContainerPointerEvent) => {
const container = event.currentTarget;
if (!(container instanceof HTMLDivElement)) {
return null;
}
const point = getRelativePoint(event, container);
const rect = container.getBoundingClientRect();
if (!(point && rect.width > 0 && rect.height > 0)) {
return null;
}
return {
height: rect.height,
point: { x: point.x / rect.width, y: point.y / rect.height },
width: rect.width,
};
}, []);
const handleStart = useCallback(
(event: ContainerPointerEvent) => {
const sample = readNormalizedPoint(event);
if (!sample) {
return;
}
const points = [sample.point];
currentPointsRef.current = points;
setIsDrawing(true);
setCurrentPath(markToPath({ points }, sample.width, sample.height));
},
[readNormalizedPoint]
);
const handleMove = useCallback(
(event: ContainerPointerEvent) => {
if (!isDrawing) {
return;
}
const sample = readNormalizedPoint(event);
const previous = currentPointsRef.current;
if (!(sample && previous)) {
return;
}
const points = [...previous, sample.point];
currentPointsRef.current = points;
setCurrentPath(markToPath({ points }, sample.width, sample.height));
},
[isDrawing, readNormalizedPoint]
);
const handleEnd = useCallback(() => {
const points = currentPointsRef.current;
if (!(isDrawing && points?.length)) {
currentPointsRef.current = null;
return;
}
const rect = containerRef.current?.getBoundingClientRect();
const path =
rect && rect.width > 0 && rect.height > 0
? markToPath({ points }, rect.width, rect.height)
: null;
currentPointsRef.current = null;
setCurrentPath(null);
setIsDrawing(false);
if (!path) {
return;
}
const id = String(strokeCount.current);
strokeCount.current += 1;
setPaths((previous) => [
...previous,
{ d: path, id, kind: "drawn", points },
]);
}, [isDrawing]);
return (
<EraseContext.Provider value={{ maskId }}>
{/* biome-ignore lint/a11y/noNoninteractiveElementInteractions: It's okay */}
<div
className={cn(
"relative touch-none select-none overflow-hidden",
className
)}
onMouseDown={handleStart}
onMouseLeave={handleEnd}
onMouseMove={handleMove}
onMouseUp={handleEnd}
onTouchEnd={handleEnd}
onTouchMove={handleMove}
onTouchStart={handleStart}
ref={setContainer}
role="application"
{...props}
>
<svg
aria-hidden="true"
className="pointer-events-none absolute inset-0 h-full w-full"
>
<defs>
<mask
height="100%"
id={maskId}
maskContentUnits="userSpaceOnUse"
maskUnits="userSpaceOnUse"
width="100%"
x="0"
y="0"
>
<rect fill="white" height="100%" width="100%" x="0" y="0" />
{paths.map((path) => (
<path
d={path.d}
fill="none"
key={path.id}
stroke="black"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={brushSize}
/>
))}
{currentPath ? (
<path
d={currentPath}
fill="none"
stroke="black"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={brushSize}
/>
) : null}
</mask>
</defs>
</svg>
{children}
</div>
</EraseContext.Provider>
);
}
function EraseContent({ className, ...props }: React.ComponentProps<"div">) {
return <div className={cn("absolute inset-0 z-0", className)} {...props} />;
}
function ErasePreview({ className, ...props }: React.ComponentProps<"div">) {
const context = useContext(EraseContext);
if (!context) {
throw new Error(
"ErasePreview deve essere inserito dentro un EraseContainer"
);
}
return (
<div
className={cn("relative z-10 h-full w-full cursor-pointer", className)}
style={{
mask: `url(#${context.maskId})`,
WebkitMask: `url(#${context.maskId})`,
}}
{...props}
/>
);
}
export type { EraseMark, EraseProgress };
export { Erase, EraseContent, ErasePreview };import { Erase, EraseContent, ErasePreview } from "@/components/erase";<Erase className="h-[400px] w-full">
<ErasePreview className="bg-muted">
{/* The layer the user erases */}
<span>Scratch me</span>
</ErasePreview>
<EraseContent>
{/* The hidden layer revealed underneath */}
<span>Surprise!</span>
</EraseContent>
</Erase>Pass onEraseProgress to receive the erased ratio (0-1) and the normalized erasedPoints marks. The ratio is measured on a downscaled canvas, so it stays cheap even while dragging.
const [ratio, setRatio] = useState(0);
const [erasedPoints, setErasedPoints] = useState<readonly EraseMark[]>([]);
// Ratio is emitted with format 0.226, that you can then convert as you need, usually to 22.6%
<Erase onEraseProgress={({ ratio, erasedPoints }) => {
setRatio(ratio);
setErasedPoints(erasedPoints);
}}>
{/* ... */}
</Erase>Use initialErase to start with areas already erased. Points are normalized (0-1) relative to the container, so strokes scale with the container size. Feeding back the erasedPoints from onProgress lets you persist and restore the erased state.
const marks = [
{
points: [
{ x: 0.2, y: 0.5 },
{ x: 0.5, y: 0.4 },
{ x: 0.8, y: 0.6 },
],
},
];
<Erase initialErase={marks}>
{/* ... */}
</Erase>"use client";
import { useCallback, useEffect, useState } from "react";
import {
Erase,
EraseContent,
type EraseMark,
ErasePreview,
type EraseProgress,
} from "@/components/erase";
const initialPoints = [
{
points: [
{ x: 0.281, y: 0.591 },
{ x: 0.373, y: 0.645 },
{ x: 0.5, y: 0.565 },
{ x: 0.596, y: 0.551 },
{ x: 0.727, y: 0.553 },
// ... normalized points (0-1) describing a pre-erased stroke
],
},
];
function EraseDemo() {
const [key, setKey] = useState(0);
const [ratio, setRatio] = useState(0);
const [points, setPoints] = useState<readonly EraseMark[]>(initialPoints);
const handlePlay = useCallback(() => {
setKey((previous) => previous + 1);
}, []);
const handleProgress = useCallback(
({ ratio: nextRatio, erasedPoints }: EraseProgress) => {
setRatio(nextRatio);
setPoints(erasedPoints);
},
[]
);
useEffect(() => {
setPoints(initialPoints);
}, [key]);
return (
<div className="relative h-[450px] w-full">
<button onClick={handlePlay}>Reset</button>
<Erase
className="h-full w-full"
initialErase={points}
key={key}
onProgress={handleProgress}
>
<ErasePreview className="flex h-full w-full flex-col items-center justify-center gap-4 bg-muted">
<h2 className="font-bold font-serif text-[120px] text-transparent leading-23 tracking-hero [-webkit-text-stroke:2px_var(--foreground)]">
404
</h2>
<span className="font-bold font-serif text-transparent text-xl tracking-hero [-webkit-text-stroke:1px_var(--foreground)]">
Page Not Found {(ratio * 100).toFixed(0)}%
</span>
</ErasePreview>
<EraseContent className="flex h-full w-full flex-col items-center justify-center gap-4">
<h2 className="font-bold font-serif text-[120px] text-foreground leading-23 tracking-hero">
404
</h2>
<span className="font-bold font-serif text-foreground text-xl tracking-hero">
Page Not Found {(ratio * 100).toFixed(0)}%
</span>
</EraseContent>
</Erase>
</div>
);
}
export { EraseDemo };The root container that tracks pointer/touch dragging and renders the erase mask. Accepts all standard div props, plus the following:
| Prop | Type | Default | Description |
|---|---|---|---|
brushSize | number | 40 | Diameter of the erase brush in pixels |
initialErase | readonly EraseMark[] | [] | Pre-erased marks, with points normalized (0-1) relative to the container |
onEraseProgress | (progress: EraseProgress) => void | - | Called when the erased area changes. Receives ratio (0-1) and erasedPoints |
The top layer that gets erased while dragging. Accepts all standard div props. Must be rendered inside Erase.
The bottom layer revealed as the preview is erased. Accepts all standard div props.
interface EraseMark {
points: readonly { x: number; y: number }[];
}
interface EraseProgress {
erasedPoints: readonly EraseMark[];
ratio: number;
}