Loyalty Patternsv1.00

Loyalty and gamification components for React.

An open-source component library for the surfaces a loyalty member actually touches. You copy the folders you need and own the code outright, so there is no abstraction to fight and no version to bump. Outcomes stay on your server, every gesture has an accessible equivalent, and the whole set themes through CSS custom properties, so it drops into the design system you already have.

Summer rewards

Scratch & win

2x points
1 in 2 cards win. See terms.

Reward mechanics a compliance review survives

The card above is wired to a mock server that takes network-shaped time to answer. Drag across it. What you cannot see from the outside is the part that matters: nothing in the browser knows the result until the server says so, and that holds for every chance mechanic in the set.

Wiring it up is three props. Your endpoint decides the outcome, you render the reward, you supply the copy.

Example
<ScratchCard
  getOutcome={() => fetch("/api/draw", { method: "POST" }).then(r => r.json())}
  renderOutcome={(o) =>
    o.kind === "win" ? <Prize reward={o.reward} /> : <NoWin />
  }
  labels={labels}
/>

Not on React? Have an AI build it

Copy-in distribution has a hard edge: a folder of .tsx is worth nothing to a Vue, Svelte, SwiftUI or React Native team. So every component also emits a prompt — a complete written specification you hand to Claude, Cursor, Copilot or whatever you already use, which then builds the component in your stack.

It is not a description of the component. It carries the behaviour contract, the state machine, the accessibility requirements and the compliance rules, each with the reasoning attached so an agent knows which parts it may not trade away — and it separates the framework-independent invariants from the React notes it should adapt rather than copy. It ends in acceptance checks the agent is told to run before claiming it works.

The component is the reference implementation; the specification is what travels. Both are generated from whatever you set in the playground.

The generated prompt, opening
Build a scratch-card component in MY stack, to this specification. Do not port React code — implement these invariants with my platform's own idioms.

## The reference implementation

A working React implementation of this component exists, is documented, and is
running live. Use it to resolve anything ambiguous below and to check your
behaviour against — not as code to translate line by line:

- Source: `registry/scratch-card/` in https://github.com/mrcl-st/loyalty-patterns
- Documented behaviour, props, tokens and states: https://mrcl.st/loyalty-patterns/docs/components/scratch-card
- Running, with exactly the settings in this prompt applied: https://mrcl.st/loyalty-patterns/playground/scratch-card

Where the reference and this specification disagree, the specification wins. It
is the contract; the reference is one platform's expression of it.

## Invariants (must all hold, whatever the platform)

Behaviour:

- Reveal when 65% of the panel has been erased. Below that the card stays covered.
- The prize is visible through the holes from the first stroke, like a physical card.
- Brush diameter is 14% of the panel's shorter side, round.
- The user scratches with pointer or touch.
- A win reveals with a brief pop and a spark burst in the reward accent; a loss settles quietly. Both are suppressed when reduced motion is requested.
Scratch card — portable specification
Build a scratch-card component in MY stack, to this specification. Do not port React code — implement these invariants with my platform's own idioms.

## The reference implementation

A working React implementation of this component exists, is documented, and is
running live. Use it to resolve anything ambiguous below and to check your
behaviour against — not as code to translate line by line:

- Source: `registry/scratch-card/` in https://github.com/mrcl-st/loyalty-patterns
- Documented behaviour, props, tokens and states: https://mrcl.st/loyalty-patterns/docs/components/scratch-card
- Running, with exactly the settings in this prompt applied: https://mrcl.st/loyalty-patterns/playground/scratch-card

Where the reference and this specification disagree, the specification wins. It
is the contract; the reference is one platform's expression of it.

## Invariants (must all hold, whatever the platform)

Behaviour:

- Reveal when 65% of the panel has been erased. Below that the card stays covered.
- The prize is visible through the holes from the first stroke, like a physical card.
- Brush diameter is 14% of the panel's shorter side, round.
- The user scratches with pointer or touch.
- A win reveals with a brief pop and a spark burst in the reward accent; a loss settles quietly. Both are suppressed when reduced motion is requested.

Contract:

- The component must NEVER decide the outcome. It receives one, or requests one, and only choreographs the reveal. Prize logic in the client bundle is a compliance incident that a curious DevTools user will find.
- The outcome request must be idempotent, and a retry must reuse the same request identity. Persist the assigned result server-side before returning it.
- Never expose probabilities, inventory, eligibility rules or attempt counts to the browser. The client renders refusals it is handed; it does not compute them.
- Campaign state arrives as an input: available, not-started, already-played, not-eligible, attempts-exhausted, ended. Locked states render their own copy and nothing is playable.
- A failed outcome request is a first-class state, not a crash: show a retryable error, and never leave the component stuck waiting.

Accessibility:

- A non-gesture path to the result is mandatory, not a nicety. Scratching is a path-based gesture, and WCAG 2.5.1 (Level A) requires an equivalent that works with a single pointer and no path. That serves people with tremor or limited dexterity as much as keyboard and screen-reader users.
- The prize must be absent from the accessibility tree until it is decided, announced through a live region when revealed, and focus must move to it — without scrolling the page.
- All copy arrives from outside the component. Hardcode no strings, including screen-reader announcements.
- Honour reduced-motion by disabling choreography entirely, not by shortening it.
- On touch, the interactive surface must not scroll the page (in web terms: touch-action: none).
- Canvas does not inherit text direction; any text painted on the surface must follow the document's direction or right-to-left campaigns render backwards.

Theming:
- Every colour, radius, spacing unit, font and duration comes from a token the host application sets. Nothing visual is hardcoded.
- One theming mechanism only. On the web that means CSS custom properties; on other platforms use the equivalent single source, and say which you chose.
- Defaults must not impose taste: no elevation, no brand colour choices, nothing the host cannot override.

Content boundaries:
- Anything drawn on the scratch surface erodes as the user scratches (the instruction line, artwork, a watermark).
- Anything above or below the panel persists: headline, badge, remaining attempts, expiry, terms.
- The reward's own presentation is supplied by the host, not invented by the component.

## Reference implementation notes (React/web specific — adapt, do not copy)

- The surface is an HTML canvas; erasing uses destination-out compositing.
- Coverage is measured with an occupancy grid rather than by sampling pixels: overlapping strokes would double-count, and reading pixels back throws once a cross-origin image has tainted the canvas — which would break the threshold the moment someone uses campaign artwork.
- The reveal's fade duration lives in a CSS token, with the transition end driving the state change so the stylesheet stays the single source of timing.

## Acceptance checks (run these before telling me it works)

- Scratching 35% does NOT reveal; scratching past 65% does.
- A keyboard-only user can reach the result without any gesture.
- A pointer user who cannot drag can reach the result with a single tap or click.
- While covered, the prize text is not present in the accessibility tree.
- On reveal, the result is announced once and focus lands on it.
- With reduced motion requested, nothing animates and the result appears immediately.
- A vertical drag on the surface does not scroll the page on a touch device.
- With the document set to right-to-left, text painted on the surface reads correctly.
- No random number generator appears anywhere in the outcome path.

State where your platform forced a departure from the invariants, and why.

Questions

What is this?

An open-source set of React components for the customer-facing side of loyalty and gamification programs: scratch cards, spin wheels, points counters, stamp cards, tier progress and referral widgets. It covers the surfaces a member touches, not the admin tooling behind them.

Is it a design system?

No, and it assumes you already have one. The components carry no visual opinion of their own beyond sensible defaults, and they read only CSS custom properties, so they inherit your colours, radii, typography and spacing rather than competing with them.

What can I not change?

Three things, and they are the point rather than a limitation: outcomes are decided by your server, every gesture has an equivalent that needs no gesture, and all copy arrives through props. Everything else is yours — colour, typography, radius, motion, the reward's presentation, the failure text, the markup around the panel. Those three are structural because each is a way to ship a real problem quietly: a client-side draw is a compliance incident, a gesture-only reward is one nobody with a tremor can claim, and a hardcoded string is a component that cannot be translated.

How are outcomes decided?

By your server, always. You pass either an outcome that was already decided or a getOutcome function the component calls once when the member starts interacting, so the network round-trip hides inside the gesture. No probability logic ships to the client, because a reward mechanic running on a client-side random number is a compliance problem waiting for someone to open DevTools. A failed request is a first-class state with a retry rather than a card stuck waiting.

Is it accessible?

Accessibility is structural rather than optional. Scratching, spinning and dragging are path-based gestures, and WCAG 2.5.1 Level A requires a single-pointer equivalent, so every gesture-driven component ships one and will not let you remove it — which serves people with tremor or limited dexterity as much as keyboard and screen-reader users. Results announce through a live region using your own copy, prize content stays hidden from screen readers until it is revealed, focus moves to the result, and all animation collapses under prefers-reduced-motion rather than merely shortening.

How do I theme it to my brand?

Set CSS custom properties, which is the only styling mechanism the components read. Roughly thirty semantic tokens cover the whole kit, and each component exposes its own tokens that fall back to those. MUI, shadcn, Chakra, Base UI and plain CSS all bridge in with a mapping file of about twenty lines pointing --lk-* names at the tokens you already have, so your design system stays the source of truth — and a multi-brand campaign becomes a token swap rather than a branch per brand.

Which frameworks are supported?

The components are React. The theming layer is framework-agnostic CSS, and each component keeps its logic in a plain TypeScript engine with no framework dependency, so a Vue, Svelte or web-component wrapper would be a small shell rather than a rewrite. For anything further afield, every component also emits a portable specification you can hand to a coding agent in your own stack.

What is the portable specification?

A prompt carrying the component's behaviour contract, state machine, accessibility requirements and compliance rules, with the reasoning attached to each one, and the framework-independent invariants separated from the React-specific notes you should adapt rather than copy. Copy-in distribution only helps React developers; the specification is the part that travels to SwiftUI, Flutter or anything else.

How do I install it?

There is no package. Clone the repository and copy the token file plus the component folders you want into your project, which makes them yours to edit. React 18 or later is the only runtime dependency, and the stylesheets are plain CSS, so no build plugin or CSS-in-JS runtime is required.

What does it cost, and what is the licence?

Nothing, under the MIT licence. You can use it commercially, modify it and ship it without attribution requirements beyond the licence text.

Which components are available?

The docs list every component that exists, and the playground runs each one live with its behaviour settings exposed. Both are generated from the registry, so they are always current. A component is added when it meets the standard the others are held to, rather than to fill out a roadmap.

Marcel Stuliglowa

Built by Marcel Stuliglowa, a lead product designer working on enterprise loyalty and gamification platforms.

Take a component

Copy a folder and edit it, or copy the specification and have it built in your own stack. Both start in the same place.