ScratchToReveal
Canvas scratch-off reveal interaction.
Live demo
01Basic text reveal
Auto-reveals at 70% scratched, with progress bar.
Congratulations!
You've revealed the hidden message!
Lottery card
Solid grey surface, large brush, custom text.
Coupon code
Marketing reveal with copy interaction.
Limited Time Offer
Customisation
Brush shape, brush size, manual reveal.
π
Revealed!
β¬
Blocky!
π
Violet
βΈοΈ
Manual
Implementation
02<script>
import ScratchToReveal from '$lib/components/ScratchToReveal.svelte';
</script>
β
<ScratchToReveal
scratchText="Scratch Here!"
showProgress={true}
revealThreshold={70}
width={400}
height={300}
>
<div class="prize">
<h3>Congratulations!</h3>
<p>You've revealed the hidden message.</p>
</div>
</ScratchToReveal>ScratchToReveal layers an HTML5 canvas above a Svelte snippet. Pointer events sample alpha pixels each frame to track scratched percentage; when it crosses revealThreshold the canvas fades out. Pointer/touch/pen all share a single pointer-event path so mobile is first-class.
Logic explainer
03What Does It Do? (Plain English)
ScratchToReveal puts a grey (or image, or coloured) coating over any content. Drag your finger or mouse across the coating and the pixels under your touch disappear, revealing the content underneath β exactly like a real lottery scratch card. Once you've cleared roughly 70% of the coating, the rest disappears automatically and a onReveal callback fires.
Think of a silver scratch panel on a physical card: drag, scrape, peek, win. The coating is a <canvas>, the content is regular DOM, and the "scraping" is real-time pixel erasure.
How It Works (Pseudo-Code)
state:
isScratching = false
isFullyRevealed = false
scratchPercentage = 0
ctx = canvas 2D context
rafId = pending RAF handle
on mount:
measure container width/height (or use explicit width/height props)
size canvas: physical = logical Γ devicePixelRatio
ctx.scale(dpr, dpr)
draw coating: solid colour OR image, with optional overlay text
events:
on pointerdown(e):
if disabled or isFullyRevealed: return
isScratching = true
canvas.setPointerCapture(e.pointerId) // keep events even if cursor leaves canvas
erase brush at (e.x, e.y) relative to canvas
on pointermove(e):
if not isScratching: return
cancelAnimationFrame(rafId)
rafId = requestAnimationFrame(() => erase brush at (e.x, e.y))
on pointerup(e) / pointercancel(e):
isScratching = false
canvas.releasePointerCapture(e.pointerId)
sample alpha pixels (every 4th) β scratchPercentage
onProgress?.(scratchPercentage)
if autoReveal AND scratchPercentage β₯ revealThreshold:
ctx.clearRect(...) // wipe the rest
isFullyRevealed = true
onReveal?.()
on Skip button click / Space / Enter:
ctx.clearRect(...)
isFullyRevealed = true
onReveal?.()
on Reset button click / R key:
isFullyRevealed = false
redraw coating from scratch
erase brush at (x, y):
ctx.globalCompositeOperation = 'destination-out'
if brushShape === 'circle':
ctx.beginPath(); ctx.arc(x, y, brushSize / 2, 0, 2Ο); ctx.fill()
else:
ctx.fillRect(x β brushSize/2, y β brushSize/2, brushSize, brushSize)The clever bit is globalCompositeOperation = 'destination-out': anything you draw under that mode removes the corresponding pixels from the canvas instead of painting over them. The coating is being erased, not over-painted, so the canvas itself becomes transparent and the DOM behind shows through.
The Core Concept: destination-out Compositing
Canvas 2D supports multiple compositing modes via globalCompositeOperation. The default is 'source-over' β new pixels paint on top of existing ones. 'destination-out' flips the relationship: new pixels punch a hole in what's already there, leaving transparency.
ctx.globalCompositeOperation = 'destination-out';
ctx.beginPath();
ctx.arc(x, y, brushSize / 2, 0, Math.PI * 2);
ctx.fill();
// The circle drawn at (x, y) is now a transparent hole in the coating.
// The DOM beneath the canvas is visible through the hole.Before scratching After scratching
ββββββββββββββββββββββββ ββββββββββββββββββββββββ
ββββββββββββββββββββββββ βββββββββββββββββββββββ
βββββSCRATCH HERE!ββββββ β βββββββββββββββββββββββ
ββββββββββββββββββββββββ βββββPRIZE INSIDEββββββ
ββββββββββββββββββββββββ βββββββββββββββββββββββ
ββββββββββββββββββββββββ ββββββββββββββββββββββββ
Opaque coating Transparent holes show DOM
(canvas pixels) underneathdestination-out keeps the entire image intact except where you draw, which is why partial scratching looks naturally jagged rather than a clean rectangle of removed coating.
Progress Calculation: Pixel Sampling
To decide when to auto-reveal, the component reads back the canvas's pixel buffer and counts transparent pixels. The full pixel array is width Γ height Γ 4 bytes (RGBA per pixel) β for a 600 Γ 800 canvas at 2Γ DPR that's nearly 4 million entries. Reading every byte every frame is too expensive, so the component samples.
function updateProgress():
imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
pixels = imageData.data // [R,G,B,A, R,G,B,A, ...]
sampleRate = 4 // check every 4th pixel
transparentCount = 0
totalSamples = 0
for i = 0; i < pixels.length; i += 4 Γ sampleRate:
totalSamples++
if pixels[i + 3] === 0: // alpha channel
transparentCount++
scratchPercentage = (transparentCount / totalSamples) Γ 100Two performance moves:
- Sampling. Every 4th pixel is good enough β the error margin is well under 1% on real scratch patterns, and the loop is 16Γ faster than the naΓ―ve version.
- Only on
pointerup. Sampling never runs while the user is actively scratching. The cost is paid once, when the gesture ends.
The canvas is created with getContext('2d', { willReadFrequently: true }), which hints to the browser that getImageData will be called and asks for a software-backed buffer rather than a GPU texture (GPU readback is slow). Without the hint, sampling can stutter on first call.
Retina / HiDPI Support
Canvas has two coordinate systems: physical pixels (the actual buffer) and logical pixels (CSS layout). On a Retina display, one CSS pixel maps to four physical pixels, and a naΓ―vely-sized canvas looks blurry.
dpr = window.devicePixelRatio || 1 // 2 on Retina, 3 on some phones
canvas.width = logicalWidth * dpr // physical buffer
canvas.height = logicalHeight * dpr
canvas.style.width = `${logicalWidth}px` // CSS layout
canvas.style.height = `${logicalHeight}px`
ctx.scale(dpr, dpr) // now 1 unit = 1 CSS pixelAfter ctx.scale(dpr, dpr), every drawing call is expressed in CSS pixels β a brush of brushSize: 40 is a 40 Γ 40 CSS-pixel blob, but the underlying buffer paints 80 Γ 80 physical pixels on Retina. Crisp edges, no maths in the rest of the codebase.
Pointer Events: Unified Input
Mouse, touch, and stylus all fire the same Pointer Events, so one set of handlers covers every input type:
onpointerdown
onpointermove
onpointerup
onpointercancel // phone call, browser context menu, OS interruptioncanvas.setPointerCapture(pointerId) is the move that makes scratching feel right. Without it, a user dragging too fast and crossing the canvas boundary would have the events go to whatever they drag over β typically the body β and scratching would stop mid-stroke. Pointer capture redirects all events from that pointer back to the canvas until releasePointerCapture is called or the pointer is lifted.
CSS touch-action: none on the canvas blocks the browser's default scroll/zoom gestures, so a vertical swipe inside a scratch card scratches instead of scrolling the page.
State Flow Diagram
βββββββββββββββββββββββββββ
β IDLE (coating intact) β
β isScratching=false β
β isFullyRevealed=false β
ββββββββββββββ¬βββββββββββββ
β
pointerdown βΌ
βββββββββββββββββββββββββββ
β SCRATCHING β
β isScratching=true β
β pointer captured β
β destination-out β
β erasure each rAF tick β
ββββββββββββββ¬βββββββββββββ
β
pointerup βΌ
βββββββββββββββββββββββββββ
β SAMPLING β
β read pixel buffer β
β β scratchPercentage β
β β onProgress?.() β
ββββββββββββββ¬βββββββββββββ
β
percentage β₯ threshold AND autoReveal
βΌ
βββββββββββββββββββββββββββ
β REVEALED β
β ctx.clearRect() β
β isFullyRevealed=true β
β onReveal?.() β
ββββββββββββββ¬βββββββββββββ
β
Reset / R βΌ
βββββββββββββββββββββββββββ
β redraw coating β
β β IDLE β
βββββββββββββββββββββββββββ
Skip / Space / Enter at any non-revealed state βββΆ direct to REVEALEDProps Reference
| Prop | Type | Default | Description |
|---|---|---|---|
scratchColor |
string |
'#999999' |
Solid colour for the coating. Ignored when scratchImage is set. |
scratchImage |
string |
β | Image URL drawn as the coating. Replaces scratchColor. |
scratchText |
string |
β | Optional overlay text painted on the coating (e.g. "SCRATCH HERE!"). |
scratchTextColor |
string |
'#ffffff' |
Colour of the overlay text. |
scratchTextSize |
string |
'24px' |
CSS font-size of the overlay text. |
revealThreshold |
number |
70 |
Percentage of pixels cleared before auto-reveal fires. |
autoReveal |
boolean |
true |
When false, the user must scratch the entire surface manually. |
brushSize |
number |
40 |
Brush diameter in CSS pixels. |
brushShape |
'circle' | 'square' |
'circle' |
Brush shape β circle is softer, square is faster to clear. |
width |
number | 'auto' |
'auto' |
Canvas width. 'auto' measures the parent on mount. |
height |
number | 'auto' |
'auto' |
Canvas height. 'auto' measures the parent on mount. |
showProgress |
boolean |
false |
Render a progress bar reflecting scratchPercentage. |
progressColor |
string |
'#3b82f6' |
Progress bar fill colour. |
allowReset |
boolean |
true |
Show the Reset button after the reveal. |
resetButtonText |
string |
'Reset' |
Label for the Reset button. |
skipText |
string |
'Skip' |
Label for the Skip button. Pass null to hide it. |
onReveal |
() => void |
β | Fires once when the surface is fully cleared. |
onProgress |
(percentage: number) => void |
β | Fires after each pointerup with the latest scratch percentage. |
disabled |
boolean |
false |
Disables scratching entirely; cursor changes to indicate. |
class |
string |
'' |
Extra classes on the wrapper. |
children |
Snippet |
required | The content revealed beneath the coating. |
Keyboard
| Key | Action |
|---|---|
Space / Enter |
Skip β clear the coating immediately. |
R |
Reset (only when fully revealed and allowReset is true). |
Edge Cases
| Situation | Behaviour |
|---|---|
| User scratches a tiny area then releases | pointerup samples the buffer; scratchPercentage rounds to β1β5%; onProgress fires; coating stays. |
| Pointer leaves the canvas mid-stroke | Pointer capture keeps events flowing to the canvas. The brush continues to follow the cursor / finger correctly. |
User has prefers-reduced-motion: reduce |
The reveal-complete fade is replaced with an instant cut. The scratching itself is unaffected β it's a direct manipulation, not an animation. |
width: 'auto' on a parent with no measured width |
The mount measurement reads 0 and the canvas renders empty. Set an explicit width or ensure the parent has a known size before the component mounts. |
disabled={true} after partial scratching |
Already-erased pixels stay cleared. New pointerdown events are ignored; cursor switches to not-allowed. |
| Rapid scratching at 120 Hz (high-refresh display) | RAF coalesces brush draws to one per frame. The visual is smooth; the underlying buffer doesn't accumulate redundant work. |
| User triggers a phone call mid-scratch | pointercancel fires; isScratching flips to false; pointer capture released. State is consistent β no orphan brush. |
scratchImage URL fails to load |
The canvas falls back to scratchColor (so a partial coating is still drawn). |
Dependencies
- Svelte 5.x β
$state,$effect,$props, snippets, andbind:thisfor the canvas reference. - Zero external dependencies β pure HTML5 Canvas API for compositing, native Pointer Events for input, no animation library.
File Structure
src/lib/components/ScratchToReveal.svelte # implementation
src/lib/components/ScratchToReveal.md # this file
src/lib/components/ScratchToReveal.test.ts # vitest unit tests
src/routes/scratchtoreveal/+page.svelte # demo page
src/lib/types.ts # ScratchToRevealPropsAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
scratchColor | string | '#999999' | Scratch surface colour. |
scratchImage | string | undefined | Optional texture image URL for the surface. |
scratchText | string | undefined | Overlay text drawn on the surface. |
brushSize | number | 40 | Scratch brush radius in pixels. |
brushShape | 'circle' | 'square' | 'circle' | Brush shape. |
revealThreshold | number | 70 | Percentage scratched before auto-reveal. |
autoReveal | boolean | true | Automatically clear the canvas at the threshold. |
showProgress | boolean | false | Show a progress bar. |
onReveal | () => void | undefined | Callback fired once when revealed. |
onProgress | (p: number) => void | undefined | Callback fired with scratch percentage (0β100). |
width / height | string | 'auto' | Container size. 'auto' measures from the children. |
disabled | boolean | false | Block scratch interaction (still renders the overlay). |
progressColor | string | '#3b82f6' | Fill colour for the optional progress bar. |
scratchTextColor / scratchTextSize | string | '#ffffff' / '24px' | Styling for the optional scratchText overlay. |
allowReset / resetButtonText / skipText | boolean / string / string | true / 'Reset' / 'Skip' | Enable and label the reset and skip controls. |