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.
collectedis 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
renderLockedcontent 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. termsis 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:
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:
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:
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.