Scratch card
Scratch-to-reveal over a server-decided outcome. The kit's hero mechanic and the strictest expression of its rules.
Install
Copy this folder alongside tokens.css.
Behavior contract
- The component never decides the result. Pass
outcome(already decided) orgetOutcome()(called once, when interaction starts; network latency hides inside the scratching). If prize logic can be computed from the client bundle, the implementation is wrong. This is the kit's signature rule. - A non-gesture path is structural, but the kit's button is only
a default. In
manualmode the reveal control renders (replace it with your own design system's button viarenderRevealAction); inautomode there is no second control at all: the panel itself is a real<button>, so one tap or Enter does everything. Why it cannot simply be removed in manual mode: scratching is a path-based gesture, and WCAG 2.5.1 (Level A) requires an equivalent single-pointer path. That serves tremor, dexterity and trackpad users as much as keyboard and screen-reader ones. - Prize content is
aria-hiddenuntil revealed; the result is announced via a polite live region and focus moves to the prize — without scrolling. The component never moves the reader's viewport: a reveal can be programmatic (restored state, an autoplaying demo), and a card that yanks the page is worse than one that does not scroll. The user who just scratched already has the panel on screen. Scroll fromonRevealCompleteif you want it. The painted hint follows the document's text direction, so RTL campaigns render correctly. Passaria-labelto name the card; it is given a group role automatically. - Swapping in your own button is one prop:
renderRevealAction={({ reveal, disabled }) => <Button onClick={reveal} disabled={disabled}>Reveal</Button>}. Your design system, your label, the kit's accessibility guarantees. - Scratching past
threshold(default 65% erased) fades the remaining foil; the fade duration is the CSS token, withtransitionenddriving the state so CSS stays the single source of truth. - A short vibration fires on reveal where supported, only when motion is enabled. That means Android; iOS has no vibration API and is silently unaffected.
termsis the compliance slot: odds disclosure belongs here, at eye level, not in a footer.
Campaign status
The component renders host-provided state; it never computes it. Map your admin lifecycle onto the member-facing union:
| Your campaign | status |
Member sees |
|---|---|---|
| Draft, Archived | don't mount | Nothing |
| Scheduled | not-started |
Teaser / opens-soon |
| Active | available |
A scratchable card |
| Paused | not-eligible |
Temporarily unavailable |
| Ended | ended |
Campaign closed |
| Attempts used | attempts-exhausted |
Come back tomorrow |
| Played already | already-played |
Refusal (no prior result) |
Use revealed + outcome instead when the server returns the member's
prior result rather than a refusal.
Props
| Prop | Type | Notes |
|---|---|---|
renderOutcome |
(o: T) => ReactNode |
Content under the foil. |
outcome |
T extends { kind: "win" | "lose" } |
Pre-decided result. |
getOutcome |
() => Promise<T> |
Fetched at first interaction. |
labels |
{ hint, revealButton, announce(o), resolving, error, retry } |
Required; all copy. Phrase resolving as checking, never calculating — the server already decided. |
status |
"available" | "not-started" | "already-played" | "not-eligible" | "attempts-exhausted" | "ended" |
Host-provided campaign state; locked statuses render via renderLocked, nothing scratchable. The component never computes these. |
renderLocked |
(status) => ReactNode |
Content for locked statuses. |
renderRevealAction |
({ reveal, disabled }) => ReactNode |
Your button instead of the kit's. Manual mode only. |
renderError |
(error, { retry }) => ReactNode |
Replaces the default failure UI. retry() re-invokes getOutcome with the same request identity. |
onError |
(error) => void |
The original rejection from getOutcome. |
foil |
ScratchFoilConfig |
Coating: color, sheen, textColor, radius, image, opacity, hideHint, paint. Every value accepts any CSS value including var(--your-token) — these props write the matching --lk-* property, so there is still only one theming system. |
brush |
ScratchBrushConfig |
size (fraction of the shorter side), stamp (image whose alpha erases — the escape hatch for a non-round brush), cursor (any CSS cursor value, keyword or url(...)). The brush is always round: geometric shapes read as artefacts rather than tools. |
header / footer |
ReactNode |
Above/below the panel: headline and badge; remaining attempts and expiry. Content that should NOT erode belongs here, not on the foil. |
id / className |
string |
DOM identity for anchors, tests, instrumentation. |
mode |
"manual" | "auto" |
Manual pointer scratching (default), or a one-tap animated self-scratch (serpentine sweep, then fade) for flows where the gesture is friction rather than delight. |
threshold |
number |
Erased fraction completing the reveal, in both modes: manual scratching triggers the fade past it, and the auto sweep stops at it. Default 0.65. |
peekMode |
"split" | "linked" |
How peekAt relates to threshold. split (default): an absolute coverage value, independent. linked: a FRACTION of threshold, so the peek point tracks the reveal point — tune the reveal and the card's feel stays put. |
peekAt |
number |
Coverage (0..1) at which the prize starts showing through the holes. 0 (default) is the physical-card feel; a middle value hides it until the scratching has earned it; anything at or above threshold means it is never glimpsed early. |
celebrate |
boolean |
Wins pop with a spark burst in the reward accent; losses settle quietly. False keeps both quiet; --lk-motion: 0 always does. Default true. |
revealed |
boolean |
Render already revealed (restored state). |
disabled |
boolean |
Blocks scratching and the button, and shows it: the panel dims to --lk-disabled-opacity and the cursor becomes not-allowed. Matched to the spin wheel from the same Tier-1 token, so two mechanics in one campaign cannot disagree about what unavailable looks like. |
terms |
ReactNode |
Compliance slot. |
onScratchStart |
() => void |
First scratch only. |
onRevealComplete |
(o: T) => void |
After the reveal finishes. |
Tokens
| Token | Default | Purpose |
|---|---|---|
--lk-scratch-foil |
#c7c7cf |
Foil base color. |
--lk-scratch-foil-sheen |
#ebebf2 |
Foil highlight. |
--lk-scratch-foil-text |
#6b6b74 |
The painted hint. |
--lk-scratch-radius |
--lk-radius-lg |
Frame radius. |
--lk-scratch-shadow |
none |
Panel elevation. Flat by default — the border and surface already separate the panel, and elevation is your design system's call. Opt in with var(--lk-shadow-raised). |
--lk-scratch-reveal-ms |
calc(600ms * --lk-motion) |
Foil fade. |
--lk-scratch-value-color |
--lk-text |
The prize value. Text is deliberately NOT accent-driven: an accent can be white or near-black and would vanish against a matching surface. Set this to var(--lk-reward) to opt in. |
--lk-scratch-media-color |
--lk-text |
Icon or artwork above the result. |
--lk-scratch-badge-color / -border |
--lk-text-muted / --lk-border |
The header badge. |
--lk-scratch-cursor |
crosshair (pointer in auto) |
Cursor over the panel. Pointer-only: touch has no cursor. Image cursors want ~32px (128px cap), a hotspot, and a keyword fallback after the comma. |
The foil is canvas-painted from the two color tokens (canvas cannot parse CSS gradient strings), so theming the foil means overriding colors.
Architecture
scratch-engine.ts is framework-free: no React, no DOM, just
sizes and strokes in, coverage out. Two consequences worth knowing.
Coverage is tracked with an occupancy grid, not by sampling canvas
pixels. Overlapping strokes would double-count under naive area
accumulation, and getImageData throws on a canvas tainted by a
cross-origin image. The analytical approach is what makes campaign
artwork on the foil safe to use.
Keeping the engine pure is also the hedge for non-React usage: a Vue, Svelte, or web-component wrapper would be a small shell around the same file rather than a rewrite. Today the kit ships React components with a framework-agnostic token layer; it does not ship framework-agnostic components.
The foil: one base, then the hint
- Base — the colour gradient (
color+sheen), orimageif you pass one. Artwork overrides the tint: the image IS the base, not a layer over it, so setting both is not meaningful. paint()— the escape hatch, run over the base. Textures, patterns, watermarks and anything else the config cannot express live here; the kit does not ship apatternfield, because one canvas callback covers every variant of that idea.- Hint —
labels.hint, painted last unlesshideHint.
Contrast is yours to set. With dark artwork the default hint
colour will be too low-contrast; pass foil.textColor (or
hideHint: true if the artwork already says "scratch here"). The
component cannot inspect an image's luminance, so it does not guess.
Content: what erodes, what persists
The only question worth asking about card content is whether it should survive the scratch.
| Where | Erodes? | |
|---|---|---|
| Instruction line | labels.hint, painted on the foil |
Yes |
| Campaign artwork, texture, watermark | foil.image / pattern / paint |
Yes |
| Eyebrow, headline, badge | header slot |
No |
| Remaining attempts, expiry, secondary actions | footer slot |
No |
| Odds disclosure, terms link | terms slot |
No |
| The reward itself | renderOutcome |
n/a — appears on reveal |
Two default sub-components ship for the slots so the card looks right
from one line, and both are yours to replace or edit:
ScratchHeader (eyebrow / title / description / badge) and
ScratchResult (title / value / description / media / action /
secondaryAction).
Code-bearing rewards compose a coupon. ScratchResult has no code
field on purpose: a voucher code needs copy-to-clipboard, an expiry, its
own terms and a redeemed state, which is a component rather than a field.
Render yours inside renderOutcome:
renderOutcome={(o) =>
o.kind === "win"
? <YourCoupon code={o.code} title={o.title} />
: <ScratchResult kind="lose" title="No win this time" />
}The kit ships exactly that component: render a
coupon card inside renderOutcome for a
code-bearing reward — copy control, expiry, cover and spent state
included. The slot still takes any node, so composing your own remains
equally legal.
The two thresholds
The card has two coverage gates, and they do different jobs:
| What it controls | |
|---|---|
peekAt |
When the reward starts showing through the scratched holes. |
threshold |
When the remaining foil clears and the reveal fires. |
With peekMode: "linked", peekAt is read as a share of
threshold. peekAt: 0.5 with threshold: 0.8 peeks at 40%, and
dropping the threshold to 0.6 moves the peek to 30% on its own. That is the
mode you want while tuning difficulty; split is the mode you want
when the two points are genuinely unrelated.
Set peekAt: 0 for a physical card (the prize leaks into view as you
go). Set it near threshold when the outcome is legible at a glance
and you want the reveal to land as one moment rather than being
spoiled by a three-percent scratch. Set it in between for a card that
withholds the result until the effort has earned it, then lets the
last stretch play out with the prize partly visible.
States
covered → scratching → resolving → revealed, with two branches:
resolving → error → (retry) → resolving, and any locked status
short-circuiting the whole card. While resolving, a shimmer shows
through the scratched holes (withheld under reduced motion) and
labels.resolving is announced, so the card never sits silently stuck.
A getOutcome that never settles is still the adopter's to timeout.
revealed + outcome vs. status="already-played": use the former
when the server returns the member's prior result, the latter when it
returns only the refusal.
Integration contract
The scratch card reveals a server-provided outcome. It does not
determine eligibility, probability, inventory, attempts, or reward
fulfilment. Your backend should: assign outcomes server-side, treat
the outcome request as idempotent (retry re-calls getOutcome with
the same identity), persist the result before returning it, return
the same result on retries where appropriate, enforce its own
timeouts inside getOutcome, and keep probability logic out of the
browser entirely.
Any data-* or aria-* prop passes through to the root element, so
instrumentation needs no wrapper.
When to use it / when not to
Use for instant-win moments where suspense is the point: a decided
result the user uncovers. Don't use it when the user must compare or
deliberate: that is a choice, not a reveal. And don't fake it. A
scratch card over a client-side Math.random() is a compliance
incident waiting for a curious DevTools user.
Common mistakes
Deciding outcomes client-side; making the gesture the only path to the
prize; forgetting touch-action: none (the first mobile scratch
scrolls the page instead); announcing "you won" before the outcome
actually resolved.