Patterns Medium
Scratch to Reveal
Interactive scratch card effect where users drag to erase an overlay and reveal hidden content underneath.
Open in Lab
MCP
css javascript vue svelte
Targets: TS JS HTML React Vue Svelte
Code
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
:root {
--accent: #818cf8;
--accent-glow: rgba(129, 140, 248, 0.3);
--surface: rgba(255, 255, 255, 0.04);
--border: rgba(255, 255, 255, 0.08);
}
body {
font-family: system-ui, -apple-system, sans-serif;
background: #0a0a0a;
color: #e2e8f0;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 2rem;
gap: 3rem;
}
h1 {
font-size: clamp(2rem, 5vw, 3.5rem);
font-weight: 800;
letter-spacing: -0.03em;
background: linear-gradient(135deg, #e0e7ff 0%, #818cf8 50%, #6366f1 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
text-align: center;
}
.subtitle {
text-align: center;
color: rgba(148, 163, 184, 0.8);
font-size: 1.125rem;
margin-top: -1.5rem;
}
/* Scratch Card Container */
.scratch-card {
position: relative;
width: 380px;
height: 240px;
border-radius: 20px;
overflow: hidden;
cursor: crosshair;
user-select: none;
-webkit-user-select: none;
touch-action: none;
box-shadow: 0 0 0 1px var(--border), 0 20px 60px -15px rgba(0, 0, 0, 0.5);
}
.scratch-card canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 2;
border-radius: 20px;
}
.scratch-card.revealed canvas {
opacity: 0;
transition: opacity 0.6s ease;
pointer-events: none;
}
/* Hidden content underneath */
.scratch-content {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
padding: 2rem;
background: linear-gradient(135deg, #1e1b4b 0%, #312e81 50%, #3730a3 100%);
z-index: 1;
}
.scratch-content .prize-icon {
font-size: 3rem;
line-height: 1;
}
.scratch-content .prize-text {
font-size: 1.5rem;
font-weight: 800;
color: #e0e7ff;
text-align: center;
}
.scratch-content .prize-sub {
font-size: 0.9rem;
color: rgba(196, 181, 253, 0.8);
text-align: center;
}
.scratch-content .prize-code {
background: rgba(255, 255, 255, 0.1);
border: 1px dashed rgba(255, 255, 255, 0.3);
border-radius: 8px;
padding: 0.5rem 1.5rem;
font-family: "SF Mono", "Fira Code", monospace;
font-size: 1.25rem;
font-weight: 700;
color: #fbbf24;
letter-spacing: 0.15em;
}
/* Overlay texture hint */
.scratch-overlay-text {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.5rem;
z-index: 3;
pointer-events: none;
opacity: 1;
transition: opacity 0.3s ease;
}
.scratch-card.scratching .scratch-overlay-text {
opacity: 0;
}
.scratch-overlay-text .hint-icon {
font-size: 2rem;
color: rgba(255, 255, 255, 0.5);
}
.scratch-overlay-text .hint-text {
font-size: 0.85rem;
color: rgba(255, 255, 255, 0.4);
font-weight: 500;
}
/* Progress bar */
.progress-bar {
width: 380px;
height: 4px;
background: var(--surface);
border-radius: 2px;
overflow: hidden;
margin-top: -1.5rem;
}
.progress-bar-fill {
height: 100%;
background: var(--accent);
border-radius: 2px;
width: 0%;
transition: width 0.1s ease;
}
.progress-label {
width: 380px;
display: flex;
justify-content: space-between;
font-size: 0.75rem;
color: rgba(148, 163, 184, 0.5);
margin-top: 0.25rem;
}
/* Cards row */
.cards-row {
display: flex;
gap: 2rem;
flex-wrap: wrap;
justify-content: center;
}
/* Reset button */
.reset-btn {
background: var(--surface);
border: 1px solid var(--border);
color: rgba(148, 163, 184, 0.8);
padding: 0.6rem 1.5rem;
border-radius: 10px;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
font-family: inherit;
}
.reset-btn:hover {
background: rgba(255, 255, 255, 0.08);
color: #f1f5f9;
border-color: rgba(255, 255, 255, 0.15);
}// Scratch to Reveal — canvas-based scratch card effect
(function () {
"use strict";
const BRUSH_SIZE = 40;
const REVEAL_THRESHOLD = 0.55; // 55% scratched triggers full reveal
const OVERLAY_COLOR = "#1a1a2e";
function initScratchCard(container) {
const canvas = container.querySelector("canvas");
if (!canvas) return;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) return;
let isDrawing = false;
let revealed = false;
function resize() {
const rect = container.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
ctx.scale(dpr, dpr);
fillOverlay();
}
function fillOverlay() {
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = OVERLAY_COLOR;
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Add subtle pattern/texture
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = "rgba(255, 255, 255, 0.03)";
for (let x = 0; x < canvas.width; x += 4) {
for (let y = 0; y < canvas.height; y += 4) {
if (Math.random() > 0.5) {
ctx.fillRect(x, y, 2, 2);
}
}
}
}
function getPos(e) {
const rect = canvas.getBoundingClientRect();
const touch = e.touches ? e.touches[0] : e;
return {
x: touch.clientX - rect.left,
y: touch.clientY - rect.top,
};
}
function scratch(pos) {
ctx.globalCompositeOperation = "destination-out";
ctx.beginPath();
ctx.arc(pos.x, pos.y, BRUSH_SIZE / 2, 0, Math.PI * 2);
ctx.fill();
}
function scratchLine(from, to) {
ctx.globalCompositeOperation = "destination-out";
ctx.lineWidth = BRUSH_SIZE;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(to.x, to.y);
ctx.stroke();
}
function getScratchPercentage() {
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const pixels = imageData.data;
let transparent = 0;
const total = pixels.length / 4;
for (let i = 3; i < pixels.length; i += 4) {
if (pixels[i] === 0) transparent++;
}
return transparent / total;
}
function updateProgress(pct) {
const progressFill = container.parentElement.querySelector(".progress-bar-fill");
const progressLabel = container.parentElement.querySelector(".progress-pct");
if (progressFill) {
progressFill.style.width = `${Math.min(pct * 100, 100)}%`;
}
if (progressLabel) {
progressLabel.textContent = `${Math.round(Math.min(pct * 100, 100))}%`;
}
}
function checkReveal() {
const pct = getScratchPercentage();
updateProgress(pct);
if (pct >= REVEAL_THRESHOLD && !revealed) {
revealed = true;
container.classList.add("revealed");
updateProgress(1);
}
}
let lastPos = null;
function onStart(e) {
e.preventDefault();
if (revealed) return;
isDrawing = true;
container.classList.add("scratching");
lastPos = getPos(e);
scratch(lastPos);
}
function onMove(e) {
e.preventDefault();
if (!isDrawing || revealed) return;
const pos = getPos(e);
scratchLine(lastPos, pos);
lastPos = pos;
}
function onEnd() {
if (!isDrawing) return;
isDrawing = false;
lastPos = null;
checkReveal();
}
// Mouse events
canvas.addEventListener("mousedown", onStart);
canvas.addEventListener("mousemove", onMove);
canvas.addEventListener("mouseup", onEnd);
canvas.addEventListener("mouseleave", onEnd);
// Touch events
canvas.addEventListener("touchstart", onStart, { passive: false });
canvas.addEventListener("touchmove", onMove, { passive: false });
canvas.addEventListener("touchend", onEnd);
canvas.addEventListener("touchcancel", onEnd);
// Reset function
container._reset = function () {
revealed = false;
container.classList.remove("revealed", "scratching");
resize();
updateProgress(0);
};
resize();
window.addEventListener("resize", () => {
if (!revealed) resize();
});
}
function init() {
const cards = document.querySelectorAll(".scratch-card");
cards.forEach(initScratchCard);
// Reset buttons
document.querySelectorAll("[data-reset-scratch]").forEach((btn) => {
btn.addEventListener("click", () => {
const targetId = btn.dataset.resetScratch;
const card = document.getElementById(targetId);
if (card && card._reset) card._reset();
});
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Scratch to Reveal</title>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<h1>Scratch to Reveal</h1>
<p class="subtitle">Drag across the card to reveal what's hidden</p>
<div class="cards-row">
<div>
<div class="scratch-card" id="scratch-1">
<div class="scratch-content">
<span class="prize-icon">🎉</span>
<span class="prize-text">You Won!</span>
<span class="prize-code">STEAL2026</span>
<span class="prize-sub">Use this code for 40% off</span>
</div>
<canvas></canvas>
<div class="scratch-overlay-text">
<span class="hint-icon">✍</span>
<span class="hint-text">Scratch here</span>
</div>
</div>
<div class="progress-bar">
<div class="progress-bar-fill"></div>
</div>
<div class="progress-label">
<span>Scratched</span>
<span class="progress-pct">0%</span>
</div>
</div>
<div>
<div class="scratch-card" id="scratch-2" style="width: 380px; height: 240px;">
<div class="scratch-content" style="background: linear-gradient(135deg, #064e3b 0%, #065f46 50%, #047857 100%);">
<span class="prize-icon">⭐</span>
<span class="prize-text">Secret Message</span>
<span class="prize-sub" style="color: rgba(167, 243, 208, 0.8); font-size: 1rem; max-width: 260px; line-height: 1.6;">
The best UI components are the ones you steal and make your own.
</span>
</div>
<canvas></canvas>
<div class="scratch-overlay-text">
<span class="hint-icon">✍</span>
<span class="hint-text">Scratch here</span>
</div>
</div>
<div class="progress-bar">
<div class="progress-bar-fill"></div>
</div>
<div class="progress-label">
<span>Scratched</span>
<span class="progress-pct">0%</span>
</div>
</div>
</div>
<div style="display: flex; gap: 1rem;">
<button class="reset-btn" data-reset-scratch="scratch-1">Reset Card 1</button>
<button class="reset-btn" data-reset-scratch="scratch-2">Reset Card 2</button>
</div>
<script src="script.js"></script>
</body>
</html>import {
useEffect,
useRef,
useState,
useCallback,
type CSSProperties,
type ReactNode,
} from "react";
interface ScratchToRevealProps {
children: ReactNode;
width?: number;
height?: number;
overlayColor?: string;
brushSize?: number;
revealThreshold?: number;
onReveal?: () => void;
className?: string;
style?: CSSProperties;
}
export function ScratchToReveal({
children,
width = 380,
height = 240,
overlayColor = "#1a1a2e",
brushSize = 40,
revealThreshold = 0.55,
onReveal,
className = "",
style = {},
}: ScratchToRevealProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const isDrawingRef = useRef(false);
const lastPosRef = useRef<{ x: number; y: number } | null>(null);
const [revealed, setRevealed] = useState(false);
const [scratching, setScratching] = useState(false);
const fillOverlay = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
ctx.scale(dpr, dpr);
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = overlayColor;
ctx.fillRect(0, 0, width, height);
// Subtle noise texture
ctx.fillStyle = "rgba(255, 255, 255, 0.03)";
for (let x = 0; x < width; x += 4) {
for (let y = 0; y < height; y += 4) {
if (Math.random() > 0.5) {
ctx.fillRect(x, y, 2, 2);
}
}
}
}, [width, height, overlayColor]);
useEffect(() => {
if (!revealed) fillOverlay();
}, [fillOverlay, revealed]);
const getPos = (e: React.MouseEvent | React.TouchEvent | MouseEvent | TouchEvent) => {
const canvas = canvasRef.current;
if (!canvas) return { x: 0, y: 0 };
const rect = canvas.getBoundingClientRect();
const touch = "touches" in e ? (e as TouchEvent).touches[0] : (e as MouseEvent);
return {
x: touch.clientX - rect.left,
y: touch.clientY - rect.top,
};
};
const scratch = useCallback(
(pos: { x: number; y: number }) => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) return;
ctx.globalCompositeOperation = "destination-out";
ctx.beginPath();
ctx.arc(pos.x, pos.y, brushSize / 2, 0, Math.PI * 2);
ctx.fill();
},
[brushSize]
);
const scratchLine = useCallback(
(from: { x: number; y: number }, to: { x: number; y: number }) => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) return;
ctx.globalCompositeOperation = "destination-out";
ctx.lineWidth = brushSize;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(to.x, to.y);
ctx.stroke();
},
[brushSize]
);
const getScratchPercentage = useCallback(() => {
const canvas = canvasRef.current;
if (!canvas) return 0;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) return 0;
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const pixels = imageData.data;
let transparent = 0;
const total = pixels.length / 4;
for (let i = 3; i < pixels.length; i += 4) {
if (pixels[i] === 0) transparent++;
}
return transparent / total;
}, []);
const checkReveal = useCallback(() => {
const pct = getScratchPercentage();
if (pct >= revealThreshold && !revealed) {
setRevealed(true);
onReveal?.();
}
}, [getScratchPercentage, revealThreshold, revealed, onReveal]);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const onStart = (e: MouseEvent | TouchEvent) => {
e.preventDefault();
if (revealed) return;
isDrawingRef.current = true;
setScratching(true);
const pos = getPos(e);
lastPosRef.current = pos;
scratch(pos);
};
const onMove = (e: MouseEvent | TouchEvent) => {
e.preventDefault();
if (!isDrawingRef.current || revealed) return;
const pos = getPos(e);
if (lastPosRef.current) {
scratchLine(lastPosRef.current, pos);
}
lastPosRef.current = pos;
};
const onEnd = () => {
if (!isDrawingRef.current) return;
isDrawingRef.current = false;
lastPosRef.current = null;
checkReveal();
};
canvas.addEventListener("mousedown", onStart);
canvas.addEventListener("mousemove", onMove);
canvas.addEventListener("mouseup", onEnd);
canvas.addEventListener("mouseleave", onEnd);
canvas.addEventListener("touchstart", onStart, { passive: false });
canvas.addEventListener("touchmove", onMove, { passive: false });
canvas.addEventListener("touchend", onEnd);
canvas.addEventListener("touchcancel", onEnd);
return () => {
canvas.removeEventListener("mousedown", onStart);
canvas.removeEventListener("mousemove", onMove);
canvas.removeEventListener("mouseup", onEnd);
canvas.removeEventListener("mouseleave", onEnd);
canvas.removeEventListener("touchstart", onStart);
canvas.removeEventListener("touchmove", onMove);
canvas.removeEventListener("touchend", onEnd);
canvas.removeEventListener("touchcancel", onEnd);
};
}, [revealed, scratch, scratchLine, checkReveal]);
const reset = () => {
setRevealed(false);
setScratching(false);
setTimeout(fillOverlay, 10);
};
const containerStyle: CSSProperties = {
position: "relative",
width,
height,
borderRadius: 20,
overflow: "hidden",
cursor: "crosshair",
userSelect: "none",
WebkitUserSelect: "none",
touchAction: "none",
boxShadow: "0 0 0 1px rgba(255,255,255,0.08), 0 20px 60px -15px rgba(0,0,0,0.5)",
...style,
};
const canvasStyle: CSSProperties = {
position: "absolute",
inset: 0,
width: "100%",
height: "100%",
zIndex: 2,
borderRadius: 20,
opacity: revealed ? 0 : 1,
transition: "opacity 0.6s ease",
pointerEvents: revealed ? "none" : "auto",
};
return (
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem", alignItems: "center" }}>
<div ref={containerRef} className={className} style={containerStyle}>
<div style={{ position: "absolute", inset: 0, zIndex: 1 }}>{children}</div>
<canvas ref={canvasRef} style={canvasStyle} />
{!scratching && !revealed && (
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: "0.5rem",
zIndex: 3,
pointerEvents: "none",
}}
>
<span style={{ fontSize: "2rem", color: "rgba(255,255,255,0.5)" }}>{"\u270D"}</span>
<span style={{ fontSize: "0.85rem", color: "rgba(255,255,255,0.4)", fontWeight: 500 }}>
Scratch here
</span>
</div>
)}
</div>
{revealed && (
<button
onClick={reset}
style={{
background: "rgba(255,255,255,0.04)",
border: "1px solid rgba(255,255,255,0.08)",
color: "rgba(148,163,184,0.8)",
padding: "0.6rem 1.5rem",
borderRadius: 10,
fontSize: "0.875rem",
fontWeight: 500,
cursor: "pointer",
fontFamily: "inherit",
marginTop: "0.5rem",
}}
>
Reset
</button>
)}
</div>
);
}
// Demo usage
export default function ScratchToRevealDemo() {
return (
<div
style={{
background: "#0a0a0a",
minHeight: "100vh",
fontFamily: "system-ui, -apple-system, sans-serif",
color: "#e2e8f0",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
padding: "2rem",
gap: "3rem",
}}
>
<h1
style={{
fontSize: "clamp(2rem, 5vw, 3.5rem)",
fontWeight: 800,
letterSpacing: "-0.03em",
background: "linear-gradient(135deg, #e0e7ff 0%, #818cf8 50%, #6366f1 100%)",
WebkitBackgroundClip: "text",
WebkitTextFillColor: "transparent",
backgroundClip: "text",
textAlign: "center",
}}
>
Scratch to Reveal
</h1>
<p
style={{
textAlign: "center",
color: "rgba(148,163,184,0.8)",
fontSize: "1.125rem",
marginTop: "-1.5rem",
}}
>
Drag across the card to reveal what's hidden
</p>
<div style={{ display: "flex", gap: "2rem", flexWrap: "wrap", justifyContent: "center" }}>
<ScratchToReveal onReveal={() => console.log("Card 1 revealed!")}>
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: "1rem",
padding: "2rem",
background: "linear-gradient(135deg, #1e1b4b 0%, #312e81 50%, #3730a3 100%)",
}}
>
<span style={{ fontSize: "3rem", lineHeight: 1 }}>{"\uD83C\uDF89"}</span>
<span style={{ fontSize: "1.5rem", fontWeight: 800, color: "#e0e7ff" }}>You Won!</span>
<span
style={{
background: "rgba(255,255,255,0.1)",
border: "1px dashed rgba(255,255,255,0.3)",
borderRadius: 8,
padding: "0.5rem 1.5rem",
fontFamily: "'SF Mono', 'Fira Code', monospace",
fontSize: "1.25rem",
fontWeight: 700,
color: "#fbbf24",
letterSpacing: "0.15em",
}}
>
STEAL2026
</span>
<span style={{ fontSize: "0.9rem", color: "rgba(196,181,253,0.8)" }}>
Use this code for 40% off
</span>
</div>
</ScratchToReveal>
<ScratchToReveal overlayColor="#1a2e1a" onReveal={() => console.log("Card 2 revealed!")}>
<div
style={{
position: "absolute",
inset: 0,
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: "1rem",
padding: "2rem",
background: "linear-gradient(135deg, #064e3b 0%, #065f46 50%, #047857 100%)",
}}
>
<span style={{ fontSize: "3rem", lineHeight: 1 }}>{"\u2B50"}</span>
<span style={{ fontSize: "1.5rem", fontWeight: 800, color: "#e0e7ff" }}>
Secret Message
</span>
<span
style={{
fontSize: "1rem",
color: "rgba(167,243,208,0.8)",
maxWidth: 260,
lineHeight: 1.6,
textAlign: "center",
}}
>
The best UI components are the ones you steal and make your own.
</span>
</div>
</ScratchToReveal>
</div>
</div>
);
}<script setup>
import { ref, onMounted, onUnmounted } from "vue";
const W = 380;
const H = 240;
const BRUSH = 40;
const THRESHOLD = 0.55;
const canvas1 = ref(null);
const canvas2 = ref(null);
const state1 = ref({ revealed: false, scratching: false, pct: 0 });
const state2 = ref({ revealed: false, scratching: false, pct: 0 });
const draw1 = { isDrawing: false, lastPos: null };
const draw2 = { isDrawing: false, lastPos: null };
let cleanups = [];
function fillOverlay(canvas, color) {
if (!canvas) return;
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
canvas.width = W * dpr;
canvas.height = H * dpr;
ctx.scale(dpr, dpr);
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = color;
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = "rgba(255,255,255,0.03)";
for (let x = 0; x < W; x += 4)
for (let y = 0; y < H; y += 4) if (Math.random() > 0.5) ctx.fillRect(x, y, 2, 2);
}
function setupCard(canvas, state, drawRef, overlayColor) {
if (!canvas) return;
fillOverlay(canvas, overlayColor);
const getPos = (e) => {
const r = canvas.getBoundingClientRect();
const t = e.touches ? e.touches[0] : e;
return { x: t.clientX - r.left, y: t.clientY - r.top };
};
const scratchAt = (pos) => {
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) return;
ctx.globalCompositeOperation = "destination-out";
ctx.beginPath();
ctx.arc(pos.x, pos.y, BRUSH / 2, 0, Math.PI * 2);
ctx.fill();
};
const scratchLine = (from, to) => {
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) return;
ctx.globalCompositeOperation = "destination-out";
ctx.lineWidth = BRUSH;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(to.x, to.y);
ctx.stroke();
};
const getPct = () => {
const ctx = canvas.getContext("2d", { willReadFrequently: true });
if (!ctx) return 0;
const d = ctx.getImageData(0, 0, canvas.width, canvas.height).data;
let t = 0;
const total = d.length / 4;
for (let j = 3; j < d.length; j += 4) if (d[j] === 0) t++;
return t / total;
};
const onStart = (e) => {
e.preventDefault();
if (state.value.revealed) return;
drawRef.isDrawing = true;
state.value.scratching = true;
const p = getPos(e);
drawRef.lastPos = p;
scratchAt(p);
};
const onMove = (e) => {
e.preventDefault();
if (!drawRef.isDrawing || state.value.revealed) return;
const p = getPos(e);
if (drawRef.lastPos) scratchLine(drawRef.lastPos, p);
drawRef.lastPos = p;
};
const onEnd = () => {
if (!drawRef.isDrawing) return;
drawRef.isDrawing = false;
drawRef.lastPos = null;
const p = getPct();
state.value.pct = p;
if (p >= THRESHOLD && !state.value.revealed) state.value.revealed = true;
};
canvas.addEventListener("mousedown", onStart);
canvas.addEventListener("mousemove", onMove);
canvas.addEventListener("mouseup", onEnd);
canvas.addEventListener("mouseleave", onEnd);
canvas.addEventListener("touchstart", onStart, { passive: false });
canvas.addEventListener("touchmove", onMove, { passive: false });
canvas.addEventListener("touchend", onEnd);
canvas.addEventListener("touchcancel", onEnd);
cleanups.push(() => {
canvas.removeEventListener("mousedown", onStart);
canvas.removeEventListener("mousemove", onMove);
canvas.removeEventListener("mouseup", onEnd);
canvas.removeEventListener("mouseleave", onEnd);
canvas.removeEventListener("touchstart", onStart);
canvas.removeEventListener("touchmove", onMove);
canvas.removeEventListener("touchend", onEnd);
canvas.removeEventListener("touchcancel", onEnd);
});
}
function reset1() {
state1.value = { revealed: false, scratching: false, pct: 0 };
draw1.isDrawing = false;
draw1.lastPos = null;
setTimeout(() => fillOverlay(canvas1.value, "#1a1a2e"), 10);
}
function reset2() {
state2.value = { revealed: false, scratching: false, pct: 0 };
draw2.isDrawing = false;
draw2.lastPos = null;
setTimeout(() => fillOverlay(canvas2.value, "#0a2f1f"), 10);
}
onMounted(() => {
setupCard(canvas1.value, state1, draw1, "#1a1a2e");
setupCard(canvas2.value, state2, draw2, "#0a2f1f");
});
onUnmounted(() => cleanups.forEach((fn) => fn()));
</script>
<template>
<div style="min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; background: #0a0a0a; font-family: system-ui, -apple-system, sans-serif; color: #e2e8f0; padding: 2rem; gap: 2rem;">
<h1 style="font-size: clamp(2rem, 5vw, 3.5rem); font-weight: 800; letter-spacing: -0.03em; background: linear-gradient(135deg, #e0e7ff 0%, #818cf8 50%, #6366f1 100%); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; text-align: center; line-height: 1.2;">Scratch to Reveal</h1>
<p style="color: rgba(148,163,184,0.8); font-size: 1.125rem; margin-top: -0.5rem;">Drag across the card to reveal what's hidden</p>
<div style="display: flex; gap: 2rem; flex-wrap: wrap; justify-content: center;">
<!-- Card 1 -->
<div style="display: flex; flex-direction: column; gap: 0.5rem; align-items: center;">
<div style="position: relative; width: 380px; height: 240px; border-radius: 20px; overflow: hidden; cursor: crosshair; user-select: none; -webkit-user-select: none; touch-action: none; box-shadow: 0 0 0 1px rgba(255,255,255,0.08), 0 20px 60px -15px rgba(0,0,0,0.5);">
<div style="position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem; padding: 2rem; background: linear-gradient(135deg, #1e1b4b 0%, #312e81 50%, #3730a3 100%); z-index: 1;">
<span style="font-size: 3rem; line-height: 1;">🎉</span>
<span style="font-size: 1.5rem; font-weight: 800; color: #e0e7ff; text-align: center;">You Won!</span>
<span style="background: rgba(255,255,255,0.1); border: 1px dashed rgba(255,255,255,0.3); border-radius: 8px; padding: 0.5rem 1.5rem; font-family: 'SF Mono', 'Fira Code', monospace; font-size: 1.25rem; font-weight: 700; color: #fbbf24; letter-spacing: 0.15em;">STEAL2026</span>
<span style="font-size: 0.9rem; color: rgba(196,181,253,0.8); text-align: center;">Use this code for 40% off</span>
</div>
<canvas ref="canvas1" :style="{ position: 'absolute', inset: '0', width: '100%', height: '100%', zIndex: 2, borderRadius: '20px', opacity: state1.revealed ? 0 : 1, transition: 'opacity 0.6s ease', pointerEvents: state1.revealed ? 'none' : 'auto' }"></canvas>
<div v-if="!state1.scratching && !state1.revealed" style="position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.5rem; z-index: 3; pointer-events: none;">
<span style="font-size: 2rem; color: rgba(255,255,255,0.5);">✍</span>
<span style="font-size: 0.85rem; color: rgba(255,255,255,0.4); font-weight: 500;">Scratch here</span>
</div>
</div>
<div style="width: 380px; height: 4px; background: rgba(255,255,255,0.04); border-radius: 2px; overflow: hidden;">
<div :style="{ height: '100%', background: '#818cf8', borderRadius: '2px', width: Math.round(state1.pct * 100) + '%', transition: 'width 0.1s ease' }"></div>
</div>
<div style="width: 380px; display: flex; justify-content: space-between; font-size: 0.75rem; color: rgba(148,163,184,0.5);">
<span>Scratched</span><span>{{ Math.round(state1.pct * 100) }}%</span>
</div>
</div>
<!-- Card 2 -->
<div style="display: flex; flex-direction: column; gap: 0.5rem; align-items: center;">
<div style="position: relative; width: 380px; height: 240px; border-radius: 20px; overflow: hidden; cursor: crosshair; user-select: none; -webkit-user-select: none; touch-action: none; box-shadow: 0 0 0 1px rgba(255,255,255,0.08), 0 20px 60px -15px rgba(0,0,0,0.5);">
<div style="position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 1rem; padding: 2rem; background: linear-gradient(135deg, #064e3b 0%, #065f46 50%, #047857 100%); z-index: 1;">
<span style="font-size: 3rem; line-height: 1;">⭐</span>
<span style="font-size: 1.5rem; font-weight: 800; color: #e0e7ff; text-align: center;">Secret Message</span>
<span style="font-size: 0.9rem; color: rgba(167,243,208,0.8); text-align: center; max-width: 260px; line-height: 1.6;">The best UI components are the ones you steal and make your own.</span>
</div>
<canvas ref="canvas2" :style="{ position: 'absolute', inset: '0', width: '100%', height: '100%', zIndex: 2, borderRadius: '20px', opacity: state2.revealed ? 0 : 1, transition: 'opacity 0.6s ease', pointerEvents: state2.revealed ? 'none' : 'auto' }"></canvas>
<div v-if="!state2.scratching && !state2.revealed" style="position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.5rem; z-index: 3; pointer-events: none;">
<span style="font-size: 2rem; color: rgba(255,255,255,0.5);">✍</span>
<span style="font-size: 0.85rem; color: rgba(255,255,255,0.4); font-weight: 500;">Scratch here</span>
</div>
</div>
<div style="width: 380px; height: 4px; background: rgba(255,255,255,0.04); border-radius: 2px; overflow: hidden;">
<div :style="{ height: '100%', background: '#818cf8', borderRadius: '2px', width: Math.round(state2.pct * 100) + '%', transition: 'width 0.1s ease' }"></div>
</div>
<div style="width: 380px; display: flex; justify-content: space-between; font-size: 0.75rem; color: rgba(148,163,184,0.5);">
<span>Scratched</span><span>{{ Math.round(state2.pct * 100) }}%</span>
</div>
</div>
</div>
<div style="display: flex; gap: 1rem;">
<button @click="reset1" style="background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); color: rgba(148,163,184,0.8); padding: 0.6rem 1.5rem; border-radius: 10px; font-size: 0.875rem; font-weight: 500; cursor: pointer; font-family: inherit;">Reset Card 1</button>
<button @click="reset2" style="background: rgba(255,255,255,0.04); border: 1px solid rgba(255,255,255,0.08); color: rgba(148,163,184,0.8); padding: 0.6rem 1.5rem; border-radius: 10px; font-size: 0.875rem; font-weight: 500; cursor: pointer; font-family: inherit;">Reset Card 2</button>
</div>
</div>
</template><script>
import { onMount } from "svelte";
const cards = [
{
id: "card1",
overlayColor: "#1a1a2e",
content: {
icon: "\u{1F389}",
title: "You Won!",
code: "STEAL2026",
sub: "Use this code for 40% off",
bg: "linear-gradient(135deg, #1e1b4b 0%, #312e81 50%, #3730a3 100%)",
subColor: "rgba(196,181,253,0.8)",
},
},
{
id: "card2",
overlayColor: "#0a2f1f",
content: {
icon: "\u2B50",
title: "Secret Message",
code: null,
sub: "The best UI components are the ones you steal and make your own.",
bg: "linear-gradient(135deg, #064e3b 0%, #065f46 50%, #047857 100%)",
subColor: "rgba(167,243,208,0.8)",
},
},
];
let canvasEls = [];
let states = cards.map(() => ({ revealed: false, scratching: false, pct: 0 }));
let drawingState = cards.map(() => ({ isDrawing: false, lastPos: null }));
const W = 380,
H = 240,
BRUSH = 40,
THRESHOLD = 0.55;
function fillOverlay(i) {
const c = canvasEls[i];
if (!c) return;
const ctx = c.getContext("2d", { willReadFrequently: true });
if (!ctx) return;
const dpr = window.devicePixelRatio || 1;
c.width = W * dpr;
c.height = H * dpr;
ctx.scale(dpr, dpr);
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = cards[i].overlayColor;
ctx.fillRect(0, 0, W, H);
ctx.fillStyle = "rgba(255,255,255,0.03)";
for (let x = 0; x < W; x += 4)
for (let y = 0; y < H; y += 4) if (Math.random() > 0.5) ctx.fillRect(x, y, 2, 2);
}
function getPos(e, i) {
const c = canvasEls[i];
if (!c) return { x: 0, y: 0 };
const r = c.getBoundingClientRect();
const t = e.touches ? e.touches[0] : e;
return { x: t.clientX - r.left, y: t.clientY - r.top };
}
function scratchAt(i, pos) {
const c = canvasEls[i];
if (!c) return;
const ctx = c.getContext("2d", { willReadFrequently: true });
if (!ctx) return;
ctx.globalCompositeOperation = "destination-out";
ctx.beginPath();
ctx.arc(pos.x, pos.y, BRUSH / 2, 0, Math.PI * 2);
ctx.fill();
}
function scratchLine(i, from, to) {
const c = canvasEls[i];
if (!c) return;
const ctx = c.getContext("2d", { willReadFrequently: true });
if (!ctx) return;
ctx.globalCompositeOperation = "destination-out";
ctx.lineWidth = BRUSH;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.beginPath();
ctx.moveTo(from.x, from.y);
ctx.lineTo(to.x, to.y);
ctx.stroke();
}
function getPct(i) {
const c = canvasEls[i];
if (!c) return 0;
const ctx = c.getContext("2d", { willReadFrequently: true });
if (!ctx) return 0;
const d = ctx.getImageData(0, 0, c.width, c.height).data;
let t = 0;
const total = d.length / 4;
for (let j = 3; j < d.length; j += 4) if (d[j] === 0) t++;
return t / total;
}
function reset(i) {
states[i] = { revealed: false, scratching: false, pct: 0 };
states = states;
drawingState[i] = { isDrawing: false, lastPos: null };
setTimeout(() => fillOverlay(i), 10);
}
onMount(() => {
cards.forEach((_, i) => fillOverlay(i));
const handlers = cards.map((_, i) => {
const c = canvasEls[i];
if (!c) return () => {};
const onStart = (e) => {
e.preventDefault();
if (states[i].revealed) return;
drawingState[i].isDrawing = true;
states[i].scratching = true;
states = states;
const p = getPos(e, i);
drawingState[i].lastPos = p;
scratchAt(i, p);
};
const onMove = (e) => {
e.preventDefault();
if (!drawingState[i].isDrawing || states[i].revealed) return;
const p = getPos(e, i);
if (drawingState[i].lastPos) scratchLine(i, drawingState[i].lastPos, p);
drawingState[i].lastPos = p;
};
const onEnd = () => {
if (!drawingState[i].isDrawing) return;
drawingState[i].isDrawing = false;
drawingState[i].lastPos = null;
const p = getPct(i);
states[i].pct = p;
if (p >= THRESHOLD && !states[i].revealed) states[i].revealed = true;
states = states;
};
c.addEventListener("mousedown", onStart);
c.addEventListener("mousemove", onMove);
c.addEventListener("mouseup", onEnd);
c.addEventListener("mouseleave", onEnd);
c.addEventListener("touchstart", onStart, { passive: false });
c.addEventListener("touchmove", onMove, { passive: false });
c.addEventListener("touchend", onEnd);
c.addEventListener("touchcancel", onEnd);
return () => {
c.removeEventListener("mousedown", onStart);
c.removeEventListener("mousemove", onMove);
c.removeEventListener("mouseup", onEnd);
c.removeEventListener("mouseleave", onEnd);
c.removeEventListener("touchstart", onStart);
c.removeEventListener("touchmove", onMove);
c.removeEventListener("touchend", onEnd);
c.removeEventListener("touchcancel", onEnd);
};
});
return () => handlers.forEach((fn) => fn());
});
</script>
<div style="min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#0a0a0a;font-family:system-ui,-apple-system,sans-serif;color:#e2e8f0;padding:2rem;gap:2rem;">
<h1 style="font-size:clamp(2rem,5vw,3.5rem);font-weight:800;letter-spacing:-0.03em;background:linear-gradient(135deg,#e0e7ff 0%,#818cf8 50%,#6366f1 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text;text-align:center;line-height:1.2;">Scratch to Reveal</h1>
<p style="color:rgba(148,163,184,0.8);font-size:1.125rem;margin-top:-0.5rem;">Drag across the card to reveal what's hidden</p>
<div style="display:flex;gap:2rem;flex-wrap:wrap;justify-content:center;">
{#each cards as card, i}
<div style="display:flex;flex-direction:column;gap:0.5rem;align-items:center;">
<div style="position:relative;width:{W}px;height:{H}px;border-radius:20px;overflow:hidden;cursor:crosshair;user-select:none;-webkit-user-select:none;touch-action:none;box-shadow:0 0 0 1px rgba(255,255,255,0.08),0 20px 60px -15px rgba(0,0,0,0.5);">
<div style="position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1rem;padding:2rem;background:{card.content.bg};z-index:1;">
<span style="font-size:3rem;line-height:1;">{card.content.icon}</span>
<span style="font-size:1.5rem;font-weight:800;color:#e0e7ff;text-align:center;">{card.content.title}</span>
{#if card.content.code}
<span style="background:rgba(255,255,255,0.1);border:1px dashed rgba(255,255,255,0.3);border-radius:8px;padding:0.5rem 1.5rem;font-family:'SF Mono','Fira Code',monospace;font-size:1.25rem;font-weight:700;color:#fbbf24;letter-spacing:0.15em;">{card.content.code}</span>
{/if}
<span style="font-size:0.9rem;color:{card.content.subColor};text-align:center;max-width:260px;line-height:1.6;">{card.content.sub}</span>
</div>
<canvas bind:this={canvasEls[i]} style="position:absolute;inset:0;width:100%;height:100%;z-index:2;border-radius:20px;opacity:{states[i].revealed ? 0 : 1};transition:opacity 0.6s ease;pointer-events:{states[i].revealed ? 'none' : 'auto'};"></canvas>
{#if !states[i].scratching && !states[i].revealed}
<div style="position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:0.5rem;z-index:3;pointer-events:none;">
<span style="font-size:2rem;color:rgba(255,255,255,0.5);">{"\u270D"}</span>
<span style="font-size:0.85rem;color:rgba(255,255,255,0.4);font-weight:500;">Scratch here</span>
</div>
{/if}
</div>
<div style="width:{W}px;height:4px;background:rgba(255,255,255,0.04);border-radius:2px;overflow:hidden;">
<div style="height:100%;background:#818cf8;border-radius:2px;width:{Math.round(states[i].pct * 100)}%;transition:width 0.1s ease;"></div>
</div>
<div style="width:{W}px;display:flex;justify-content:space-between;font-size:0.75rem;color:rgba(148,163,184,0.5);">
<span>Scratched</span><span>{Math.round(states[i].pct * 100)}%</span>
</div>
</div>
{/each}
</div>
<div style="display:flex;gap:1rem;">
{#each cards as _, i}
<button on:click={() => reset(i)} style="background:rgba(255,255,255,0.04);border:1px solid rgba(255,255,255,0.08);color:rgba(148,163,184,0.8);padding:0.6rem 1.5rem;border-radius:10px;font-size:0.875rem;font-weight:500;cursor:pointer;font-family:inherit;">Reset Card {i + 1}</button>
{/each}
</div>
</div>Scratch to Reveal
An interactive scratch card effect using HTML Canvas. Users drag their mouse or finger across a canvas overlay to erase it, gradually revealing hidden content underneath.
How it works
- A
<canvas>element is positioned over the hidden content - The canvas is filled with a solid overlay color
- On mouse/touch drag,
globalCompositeOperation: 'destination-out'erases the overlay - Drawing uses round line caps for smooth, natural-looking scratches
- When a percentage threshold is reached, the full content is revealed
Customization
- Set
overlayColorto change the scratch surface color - Adjust
brushSizefor wider or narrower scratch strokes - Configure
revealThreshold(0-1) for when the full auto-reveal triggers - Add custom content as children/underneath the canvas
When to use it
- Promotional reveals (discount codes, prizes)
- Gamification elements
- Interactive onboarding steps
- Hidden content teasers