Loyalty Patternsv1.00

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) or getOutcome() (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 manual mode the reveal control renders (replace it with your own design system's button via renderRevealAction); in auto mode 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-hidden until 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 from onRevealComplete if you want it. The painted hint follows the document's text direction, so RTL campaigns render correctly. Pass aria-label to 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, with transitionend driving 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.
  • terms is 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

  1. Base — the colour gradient (color + sheen), or image if you pass one. Artwork overrides the tint: the image IS the base, not a layer over it, so setting both is not meaningful.
  2. 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 a pattern field, because one canvas callback covers every variant of that idea.
  3. Hintlabels.hint, painted last unless hideHint.

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:

tsx
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.