Loyalty Patternsv1.00

Stamp card

Collected progress toward a reward — the coffee-shop punch card, built to the kit's standard. The first non-chance mechanic in the set, which is most of what is interesting about it.

Install

Copy this folder alongside tokens.css.

Behavior contract

  • The card renders a count; it never changes one. collected is server truth, passed in. The component has no increment, no storage and no internal fetch — a stamp appears because your backend says it did, and the only thing this component owns is what that looks like.
  • There is no outcome, and therefore no failure path. The scratch card and the wheel carry a resolving state, an error state and a retry, because they ask a server for a verdict in the middle of an interaction. Nothing here is undecided. That triad turns out to belong to chance mechanics rather than to the kit.
  • Only stamps that ARRIVED animate. The component tracks the previous count and animates the difference. A card that mounts at eight of ten plays nothing — animating up from zero on every page load replays a history the member already lived, and is the most common way this pattern is built wrong.
  • A jump lands in sequence. Three to seven stamps four times, staggered, because a sequence reads as collected where a simultaneous appearance reads as a re-render. The total stagger is bounded so a large catch-up never becomes a cutscene.
  • Focus never moves. A stamp arrives because a purchase cleared or a poll returned — not because the member pressed anything. Moving focus for an event they did not initiate takes them out of whatever they were reading. The change is announced instead.
  • A locked card still renders. Eight of ten is true whether or not the campaign has ended, so renderLocked content appears alongside the grid rather than replacing it. This is the opposite of the wheel, which hides itself when locked — a wheel that cannot turn invites the press that will not work, and a stamp card has no affordance to withdraw.
  • The component does not own redeeming. A full card renders renderReward, and the claim action inside it is a node you supply. Same line the scratch card and the wheel draw at their result.
  • terms is the compliance slot: what the reward is worth, when it expires, and the link to the rules.

Campaign status

The component renders host-provided state; it never computes it. The union is identical to the scratch card's and the wheel's, so a campaign running several mechanics maps its lifecycle once — including two values a stamp card cannot produce on its own, which cost nothing to carry and would cost an integration bug to leave out.

Your campaign status Member sees
Draft, Archived don't mount Nothing
Scheduled not-started An empty card and an opens-soon line
Active available The card
Paused not-eligible The card, dimmed, with your copy
Ended ended The card, dimmed, with your copy
Reward claimed, card spent already-played The full card, dimmed, with your copy
Per-visit or per-day stamp cap hit attempts-exhausted The card, dimmed, come-back-tomorrow copy

Every value in the union applies here, which was not obvious. The last two were first written off as chance-mechanic vocabulary and they are not: already-played is a card whose reward has been claimed — the completed-but-spent state — and attempts-exhausted is a per-visit or per-day stamp cap, one of the most common rules in the category ("one stamp per visit"). A union that looked over-general for this component turned out to be exactly general enough.

There is no disabled prop. The other two components have controls to block; this one has none, and a prop that dims a card without disabling anything communicates nothing. Every real case it might have covered is a status.

Props

Prop Type Notes
total number Slots on the card. 2–24; outside that it is clamped.
collected number How many are filled. Controlled, always. Clamped to total — a server reporting 11 of 10 has a bug, and rendering an eleventh stamp would hide it.
labels { card, progress(c, t), collected(c, t), complete, milestone?(m) } Required; all copy, screen-reader announcements included. milestone is the one optional member — it is only reachable when milestones is set.
renderStamp ({ index, total }) => ReactNode The mark inside a collected slot. Defaults to StampMark. It receives the index, which is what covers a different mark for the tenth slot or per visit — so there is deliberately no separate seam for the final one.
renderEmpty ({ index, total }) => ReactNode The mark inside an uncollected slot. Overrides emptySlot entirely, opacity included.
renderSlot ({ index, filled, total }) => ReactNode Replaces a whole slot. The seam for a shape a radius cannot express, or artwork that differs per position.
renderReward () => ReactNode Shown below the card once it fills. StampReward is the shipped default.
columns number | "auto" Slots per row. Default "auto".
variance number How hand-applied the stamps look, 0–1. Default 0.5.
seed number Shifts the variance sequence, so two cards on one page do not wear identical creases. Default 0.
emptySlot "ghost" | "blank" What an uncollected slot shows. ghost (default) is the mark at low opacity, so the member can see what they are collecting.
numbered boolean Print each slot's position, 1..n. Independent of emptySlot and of whether a slot is collected. Default false.
milestones StampMilestone[] Positions that earn something: [{ at: 5, label: "Free pastry" }]. at counts from 1; one outside the card is ignored. See below.
renderMilestone ({ milestone, index, reached }) => ReactNode Draws inside a milestone slot, above the mark. The default is a ring and nothing else.
animate "press" | "fade" | "none" How an arriving stamp lands. Default press.
celebrate boolean A spark burst when the card fills. Always off under --lk-motion: 0. Default true.
showProgress boolean The progress line under the card. Default true.
surface "card" | "none" Whether the slots sit on card stock. Default card. See below.
slots StampSlotConfig Slot colours and shape: ink, color, filledColor, border, radius, rewardColor, size, markSize, gap. Every value accepts any CSS value including var(--your-token) — these write the matching --lk-* property, so there is still only one theming system.
status "available" | "not-started" | "already-played" | "not-eligible" | "attempts-exhausted" | "ended" Host-provided campaign state.
renderLocked (status) => ReactNode Refusal copy, rendered alongside the card.
header / footer ReactNode Above/below: headline and badge; expiry and where to collect.
terms ReactNode Compliance slot.
onComplete () => void Fires once when the card fills, after the final stamp has landed.
onMilestone (milestone) => void Fires when a milestone slot is reached, once per milestone. A jump that clears several reports each of them, in order.

Any data-* or aria-* prop passes through to the root element, so instrumentation needs no wrapper. Pass aria-label to name the card; it is given a group role automatically.

There is no onCollect. You set collected, so a callback telling you collected changed reports something you already know — the redundant-event test this kit applies to every proposed callback, the same one that rejected onScratchProgress. onComplete survives it on two counts: it fires exactly once at the crossing rather than on every render at full, and it waits for the choreography, which you cannot observe from outside.

The mark

renderStamp takes any node. The kit ships one default — a check, in stamp-mark.tsx — and ships no icon set at all.

That default is deliberately the least interesting mark available. Anything with character imposes a campaign on you: a cup says coffee, a star says tier or rating, a heart says favourite. "One collected" is the only thing a stamp card can assume about itself, and a check is the mark that means exactly that. Replace it by editing that file — it is in your project — or by passing renderStamp.

Leave fill off any path you substitute and it inherits --lk-stamp-ink, which is what lets your icon set theme with the rest of the kit rather than arriving with its own colours baked in.

Collected and uncollected are separate seams. renderStamp fills a collected slot; renderEmpty fills an empty one. The default shows the collected mark at low opacity, which is the acceptable cheap answer and a poor ceiling — what a member is working toward is usually a quieter drawing, not a faded copy of the reward.

How much of the slot the mark fills is slots.markSize, defaulting to 58%. A symbol wants room around it; a rubber stamp wants 100%, and the difference matters more than it sounds. Variance rotates the mark a few degrees, and on a small centred glyph that reads as a glyph sitting askew, because there is no edge for the eye to measure against. A ring running to the slot's edge turns the same three degrees into something you can see.

Milestones

Some slots earn something. milestones says which:

text
milestones={[
  { at: 5,  label: "Free pastry" },
  { at: 10, label: "Free coffee" },
]}

at counts from 1, because that is how a campaign is written and read. Those slots take the reward accent. Reaching one announces labels.milestone and fires onMilestone; a jump that clears several reports every one it passed, in order, so a host minting a coupon per milestone never silently loses the ones in the middle.

This is the component's segments[]. The wheel takes an array describing what each wedge is worth, and until this existed the stamp card took only a count — so a server could say how long a card was but not what it was worth. "Free pastry at 5, free coffee at 10" could only be expressed by branching on the index inside your own render code, which puts a campaign fact in the browser's source instead of in the server's response. That is the boundary the whole kit rests on, so the gap was a contract gap rather than a missing convenience.

It also replaced a highlightLast boolean, which was this idea with the position hardcoded. [{ at: total }] says that and everything else, so the API got smaller by getting more general.

How a milestone slot LOOKS is a ring, and that is deliberately the most the kit will say. It was a border tint first, which is not a decoration — it is a hue, and on a card where the accent already appears elsewhere it reads as a slot that happens to be a different colour rather than one worth more.

A ring is the most emphatic mark that still decides nothing. It follows the slot's own border-radius for free, so it is correct at every shape from circle to square with no per-shape rule, and it carries no iconography, so it cannot imply a campaign the way a star or a gift would — the same reasoning as the default check mark. Restyle it with the --lk-stamp-milestone-* tokens.

Anything more specific is renderMilestone, which draws inside the slot above the mark and receives reached, so a marker can read as a promise before and a prize after:

text
renderMilestone={({ milestone, reached }) =>
  reached ? <GiftFilled /> : <GiftOutline title={milestone.label} />
}

What a mid-card reward does is yours. The component marks the slot, announces it and reports it. It does not render a second reward zone with its own claim state — that would be this component inventing a whole parallel flow, and renderReward stays what it is: what a completed card shows.

Shape

slots.radius sets the slot's corner radius and therefore its shape. Any CSS value works — including a reference to your own design system's radius token, which is usually the right answer, since it makes the slots agree with the rest of your page instead of holding a separate opinion:

text
slots={{ radius: "var(--lk-radius)" }}   /* follows your theme */
slots={{ radius: "0" }}                  /* square            */
slots={{ radius: "30%" }}                /* squircle          */

Circle is the default and needs nothing.

There is no shape enum on the component, for the reason the scratch card has no brush shape: a fixed set of geometric options reads as artefacts rather than tools, and it invites an argument about which three to ship. Name them in your own design system if it helps — the playground does exactly that, offering Circle / Squircle / Rounded / Square as labels over these same four values — but what reaches the component is a radius. A shape a radius cannot express is what renderSlot is for.

Card stock

surface="card" (the default) puts a bordered, padded surface behind the slots. It frames the grid, not the component — the headline, progress line, expiry, terms and reward all sit outside it.

That division is the point, and it is the same one the scratch card draws: .lk-scratch__frame is the panel you scratch rather than chrome around the whole component, with its header above it as a sibling. A punch card is a piece of card with the slots printed on it and the campaign's words around it, so the frame belongs to the grid.

Set surface="none" when the component sits inside your own card or sheet. That is the common production case, and the one where a shipped border becomes a frame inside a frame.

The paint is all tokens, so stock that needs to look different does not need this prop — only stock that should not exist does.

Variance

variance is one number, 0–1, scaling rotation, offset and ink density together, so a single control takes the card from machine-set to rubber-stamped without you balancing three values against each other.

It is a hash of the slot index, not a random number, and that is the only interesting thing about the feature. Math.random() per slot is wrong three times over: the server render and the client render disagree, so React reports a hydration mismatch; every re-render re-rolls, so a stamp visibly jumps when an unrelated prop changes; and the eighth stamp forgets the angle it landed at, which is the opposite of what a physical card does. A hash looks exactly as arbitrary and never moves again.

Variance applies to the ink, never the slot. The wells are printed on the card and stay square with each other; it is the stamp that lands crooked. Rotating the slots instead is the tell that turns a stamped card into a row of wobbly buttons.

Tokens

Token Default Purpose
--lk-stamp-slot --lk-surface Empty slot fill.
--lk-stamp-slot-filled color-mix(--lk-text 6%, --lk-surface) Collected slot fill, so the card reads at a glance before any mark resolves. Mixed from the text colour rather than taken from --lk-surface-raised, which sits three percent from surface in light mode and is invisible when the fill is the only thing carrying the state.
--lk-stamp-border --lk-border Slot outline.
--lk-stamp-border-filled color-mix(--lk-text 22%, --lk-border) Collected slot outline.
--lk-stamp-slot-radius --lk-radius-round Slot shape. See above.
--lk-stamp-slot-size 64px The largest a slot grows to. A cap rather than a size: five equal tracks in a 900px container would otherwise give 170px discs.
--lk-stamp-mark-size 58% How much of the slot the mark occupies. 100% for full-bleed artwork.
--lk-stamp-gap calc(--lk-space * 2) Space between slots.
--lk-stamp-ink --lk-text The mark. Deliberately not accent-driven: an accent can legitimately be white or near-black and would vanish against a matching surface. Set to var(--lk-brand) to opt in.
--lk-stamp-ghost-opacity 0.16 The hint in an uncollected slot.
--lk-stamp-reward --lk-reward Slots carrying a milestone.
--lk-stamp-milestone-ring --lk-stamp-reward The ring around a milestone slot.
--lk-stamp-milestone-ring-width 2px Its thickness.
--lk-stamp-milestone-gap 3px The breathing space between slot and ring.
--lk-stamp-milestone-gap-color --lk-surface What shows in that gap. Set it to your card stock if the card sits on a different fill.
--lk-stamp-number --lk-text-muted The printed position number.
--lk-stamp-number-opacity 0.55 How faint that printing is.
--lk-stamp-shadow none Slot elevation. Flat by default — the outline already separates it, and elevation is your design system's call.
--lk-stamp-surface --lk-surface Card stock behind the grid. surface="card" only.
--lk-stamp-surface-border --lk-border Its outline.
--lk-stamp-surface-radius --lk-radius-lg Its corners.
--lk-stamp-surface-padding calc(--lk-space * 4) Space between the stock's edge and the slots.
--lk-stamp-surface-shadow none Stock elevation. Flat by default, same reason as everything else here.
--lk-stamp-value-color --lk-text The reward value in StampReward.

The stamp card adds nothing to tokens.css. The scratch card has to, because its foil is a painted material needing a separate identity in dark. Every default here resolves to a Tier-1 token that already retints per mode, so there is nothing to declare twice.

Architecture

stamp-engine.ts is framework-free: no React, no DOM, just counts in and column counts, per-slot transforms and stagger delays out. Same hedge as the other engines — a Vue, Svelte or web-component wrapper would be a shell around this file rather than a rewrite.

Layout is a CSS grid whose column count is a custom property, with tracks that shrink rather than overflow and a per-slot maximum that stops them ballooning. Container-driven in both directions, with no breakpoints anywhere.

columns="auto" keeps a card of six or fewer on one line and balances anything larger: eleven becomes 4+4+3, not 5+5+1. A nearly empty last row reads as the layout running out rather than as design.

Accessibility

The card is a progressbar, because that is what it is. A grid of divs is the wrong shape for assistive technology twice over: ten empty boxes are noise, and the one fact that matters — how far along the member is — is stated nowhere. The track carries aria-valuenow, aria-valuemax and an aria-valuetext built from labels.progress, so a reader hears "8 of 10 stamps collected" rather than "8".

The slots themselves are aria-hidden. They are a picture of the value the track already carries, and exposing both means hearing the same thing twice in two different shapes. The visible progress line is hidden for the same reason — it is the same sentence.

Arrivals are announced through a polite live region, and completion replaces that announcement rather than adding to it: one event, one message. A card that mounts already full announces nothing, because the member has already seen it.

Everything else matches the kit's baseline: all copy through labels, reduced motion disabling choreography entirely rather than shortening it, and CSS logical properties throughout so right-to-left campaigns lay out correctly.

There is no gesture here, so WCAG 2.5.1 does not apply — the first component in the kit where that is true, and the reason it has no non-gesture alternative to document.

Content: where things go

Where
The mark in a slot renderStamp (or edit stamp-mark.tsx)
Eyebrow, headline, badge header slot
Expiry, where to collect footer slot
Terms, what the reward is worth terms slot
Progress wording labels.progress
The reward itself renderReward

StampReward (title / value / description / action / secondaryAction) ships as the default completed-card presentation and is yours to replace or edit.

Integration contract

The card renders a count your backend owns. It does not determine eligibility, what earns a stamp, expiry, or reward fulfilment. Your backend should: award stamps server-side, treat the award as idempotent so a double-submitted purchase does not stamp twice, persist the count before returning it, and decide what happens to a completed card — whether it resets, locks, or converts into a coupon.

A full card is not a redeemed one. Completion means the member earned the reward; claiming it is a separate transaction with its own failure modes, and it belongs to your flow rather than to this component.

When to use it / when not to

Use it when progress toward a reward is itself the motivation, and the member should be able to see how much further they have to go. It is the most legible pattern in the kit precisely because everyone has held one.

Prefer the points counter when the unit is a currency the member spends rather than a fixed goal they complete, and tier progress when crossing the line changes their standing rather than handing them a prize. Don't use any of them for a single decided result with suspense in front of it — that is the scratch card or the wheel.

Common mistakes

Animating up from zero on mount; incrementing collected inside the component; moving focus when a stamp arrives; rotating the slots instead of the ink; using Math.random() for variance and getting a hydration mismatch; treating a full card as a redeemed one; and shipping an icon set so adopters pick from your taste instead of using their own.