Scratch Card
A canvas foil erased under the pointer, with a coverage threshold that opens the card itself and a Reveal button that does the same from the keyboard.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/scratch-card.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "ScratchCard" component: a canvas foil
laid over its own children and erased under the pointer (lucide-react for the
button icon; no animation or canvas library).
Contract
- Export a forwardRef div extending
Omit<React.HTMLAttributes<HTMLDivElement>, "onProgress"> — the DOM media
handler name is taken over by a progress callback of our own.
- Props: children (the prize; its height is what gives the card a box to
cover); threshold = 0.55 (erased fraction that opens the card, clamped to
at least 0.01); brushSize = 22 (eraser radius in CSS px); coverLabel =
"Scratch to reveal" ("" for a blank foil); foilClassName (merged over
"bg-muted text-muted-foreground"); revealed?/defaultRevealed = false;
onReveal?; onProgress?(0..1); showRevealButton = true; revealLabel =
"Reveal"; disabled = false; disabledReason = "This card is locked.";
onRefuse?(message); label = "Scratch card"; className.
- Controlled means controlled: with `revealed` passed, a scratch reports
through onReveal and then waits for the prop to come back, exactly like a
controlled input. Flipping the prop back to false re-covers the card,
re-arms onReveal and zeroes the progress.
Behavior
- Foil: one canvas absolutely positioned over the children. Fill it with the
probe's background colour, lay ~10px diagonal sheen bars every 28px at
globalAlpha 0.12, then centre coverLabel at 0.85 alpha, sized
clamp(12, min(w,h) * 0.11, 20) px and capped by fillText's maxWidth.
- Scratching: pointerdown captures the pointer and preventDefault()s (that
kills the compatibility mouse event, so dragging cannot select the prize
text underneath). Every move erases the capsule between the previous point
and the new one with globalCompositeOperation = "destination-out", stroked
with lineCap/lineJoin round at lineWidth = brushSize * 2; a zero-length
segment is filled as an arc instead, because whether a browser strokes a
round dot for an empty subpath is a coin flip. Only pointerup and
pointercancel end a stroke — never pointerleave, which would cut a scratch
off at the card's edge. Ignore non-primary pointers and non-left buttons.
- Progress: keep a 32x32 Uint8Array of sample points, allocated once and
zeroed in place on reset. For each swept segment, walk only the grid cells
in its bounding box grown by brushSize, and mark the ones whose centre is
within brushSize of the segment (point-to-segment distance with the
projection parameter clamped to 0..1). progress = marked / 1024, reported
in 1% steps. This is an analytic estimate, not getImageData: no per-move
pixel readback, no readback-tainted canvas, no allocation per frame.
- Threshold: the first sample that pushes progress past `threshold` opens the
card. threshold = 1 is legal and means "only the button can open it",
because the corner cells are practically unreachable.
- onReveal is one-shot per covered life: a ref is read AND written in the same
synchronous turn, so a threshold crossing and a button press landing in the
same tick still notify exactly once. Opening the card yourself through
`revealed` deliberately does not fire it.
- The wipe: once the card is open, erase a disc that grows from the point the
card was opened at (the last scratch, or the centre for a button reveal)
with radius eased(t) * distance-to-the-farthest-corner, easing 1-(1-t)^3
over 420ms, fading the canvas element's opacity only over the last 30% so
it reads as a wipe and not a dissolve. The instant comes from the rAF
callback argument — never a clock read. Under prefers-reduced-motion
(useSyncExternalStore over matchMedia, false server snapshot), or while an
IntersectionObserver says the card is off screen, the foil is hidden at
once: the feature works with the decoration off, and the loop never spins
where nobody can see it.
- The foil is an external system, not React state. React knows one boolean,
`open`; the stage (covered / wiping / gone) lives in a ref, the canvas is
always mounted and hidden imperatively when the wipe finishes. That is why
a 420ms animation costs zero re-renders, and why continuous pointer events
— which batch — still read a stage every transition writes synchronously.
- Keyboard and ARIA: the root is role="group" with aria-label. A Reveal
button sits on the foil; Tab reaches it, Enter/Space opens the card, and it
is the whole keyboard path — the gesture is never the only way in. The
canvas itself is aria-hidden (it is painted pixels), so coverLabel is
repeated in an sr-only node wired to the root through aria-describedby,
otherwise the card announces as an unexplained button. The prize wrapper is
`inert` while covered, so a link inside it cannot be tabbed to through a
foil that hides it.
- Focus handover: the Reveal button unmounts in the commit that opens the
card. Read document.activeElement synchronously in the handler, and if the
button owned focus, hand it to the prize wrapper (tabIndex={-1}) in a
dependency-free effect after the commit. Re-covering is the harder half:
`inert` blurs whatever it swallows during the commit that applies it, so an
effect asking the document afterwards is always told <body>. Keep the answer
in a ref fed by focus/blur capture handlers on the prize wrapper — a real
move out names its successor in relatedTarget, the blur that inert forces
names nobody — and on re-cover hand focus to the Reveal button, or to the
root as a last resort, when that record says the prize had it and nothing
else has claimed it since. Focus never lands on the document body. A
pointer reveal moves nothing: it must not yank focus from elsewhere.
- Locked: `disabled` is aria-disabled plus handler guards, never the native
attribute — a locked card still answers the keyboard with a reason. Both a
refused scratch and a refused press say disabledReason through a polite
aria-atomic live region and hand the same sentence to onRefuse. The
announcement clears itself after 4s, which is what lets the same sentence
be announced twice in a row.
- Resize and theme: the size is read off the canvas (clientWidth/Height), not
off the observed box, so padding on the root cannot shift the coordinate
space. Every stroke is recorded as a normalised [x0,y0,x1,y1] quad in a
flat number array, so a resize — which wipes the backing store and every
context field — repaints the foil and replays the path at the new size. A
MutationObserver on documentElement (class / style / data-theme) repaints
on a theme flip, once immediately and once after 400ms because surfaces
with transition-colors report an intermediate colour meanwhile.
- Cleanup: cancel the animation frame, clear the announcement timer and the
theme settle timer, and disconnect the resize and intersection observers on
unmount and on every dependency change.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground on the root, border,
bg-background/85 + hover:bg-accent on the button, ring for focus-visible.
The canvas gets its two colours from getComputedStyle on a zero-size probe
span carrying "bg-muted text-muted-foreground" merged with foilClassName —
background becomes the foil, colour becomes the sheen and the label, and
the font family comes off the same probe. A background that resolves to
transparent (a gradient class sets background-image, not background-color)
would paint a foil you can see straight through, so it falls back to the
root's own surface. There is not one colour literal in the drawing code;
the only string is an opaque mask for destination-out, which reads alpha
and never hue.
- Backing store: canvas.width/height = CSS size * min(devicePixelRatio, 2),
with a single setTransform(dpr,0,0,dpr,0,0) so all drawing code is written
in CSS pixels. The cap keeps a 3x phone from paying for pixels no one can
tell apart.
- touch-none on the canvas so a touch scratch does not scroll the page;
cursor-crosshair while scratchable, cursor-not-allowed while locked.
- cn() merges the consumer's className onto the root; every transition is
paired with motion-reduce:transition-none.
Customization levers
- Foil look: foilClassName is the whole re-skin (try bg-primary
text-primary-foreground, or a muted bg-secondary) — it has to resolve to a
solid background-color, since a gradient class is background-image and the
canvas cannot read it. STRIPE_WIDTH / STRIPE_GAP / the 0.12 sheen alpha
decide how metallic it reads; drop the stripe loop entirely for a flat
foil, or swap it for a dot pattern.
- Feel: brushSize 12 reads as a coin edge, 30 as a thumb. threshold 0.3 opens
eagerly, 0.7 makes people work for it, 1 leaves the button as the only way.
- Wipe: SETTLE_MS and the 1-(1-t)^3 easing set the drama; SETTLE_FADE decides
how much of it is a fade. Drop the disc and fade the canvas alone for a
quieter reveal.
- Grid: GRID 32 costs ~1024 cheap tests per stroke; raise it for a finer
threshold on very large cards, lower it for very small ones.
- Chrome: showRevealButton={false} moves the keyboard path outside the card —
only do that with a real control of your own wired to `revealed`. coverLabel
= "" gives a blank foil, and the sr-only description disappears with it.Concepts
- Destination-out erasing — the foil is not faded, it is subtracted: strokes drawn with
globalCompositeOperation = "destination-out"punch holes in the canvas, so whatever sits underneath shows through with no second layer to keep in sync. - Coverage grid, not pixel readback — how much is gone is answered by marking a 32×32 array of sample points against each swept capsule, which is a few hundred distance tests per stroke;
getImageDataon every move would be orders of magnitude more work for the same 1% resolution. - Gesture with a keyboard twin — every scratch has a button that reaches the same state, because a reveal that only exists as a drag is a reveal a keyboard user, a screen reader user and a reduced-motion user simply cannot have.
- One-shot reveal — the notify-once flag is read and written in the same synchronous turn, so a threshold crossing and a button press landing in the same tick still produce exactly one
onReveal; the same flag re-arms when the card is re-covered. - Foil as an external system — React holds one boolean and the wipe holds everything else in refs, the canvas and a frame loop, so a 420ms animation costs zero re-renders and batched pointer events never read a stale phase.
- Replayable strokes — each segment is stored in 0–1 space, so a resize or a theme flip can rebuild the foil from scratch at the new size or the new tokens and put every scratch back exactly where it was.
Swipe Cards
A two-verdict card deck — the top card tilts under the pointer, stamps its verdict as it nears the threshold, commits on distance or flick, and answers to arrow keys, buttons and an undo stack just the same.
Equalizer
A multi-band graphic equalizer — rotated native faders, a Catmull-Rom response curve through the handles, presets that glide, and a typed gain map out.