Loyalty Patternsv1.00

Coupon card

A code-bearing reward, and what happens to it. The thing the other three components hand off to — a scratch card that reveals a discount code, a wheel that lands on one, a stamp card that fills up, all end here.

Install

Copy this folder alongside tokens.css.

Behavior contract

  • The coupon is an input. Code, value, expiry and status all arrive as props. Nothing is decided here, and there is no request to make.
  • The clock is not the authority. expiresAt drives a countdown and the <time> element, and nothing else. Whether a coupon can be used is status, from your server. A component that decides expiry from the device clock hands a member with a wrong phone clock a reward they cannot spend — or one they should not be able to. The countdown is a label; the status is the verdict.
  • A spent coupon still renders. Redeemed, expired or withdrawn, the ticket stays on screen and stops looking live. Same argument as the stamp card's locked record: the member still holds this, and erasing it destroys the evidence along with the offer.
  • The component never redeems. action takes a node and you put your own button in it, exactly as the other three never own claiming.
  • The code is shown exactly as the server sent it. There is no client-side formatter, and there was one — a codeGrouping prop that chopped SAVE20 into SAVE-20. It was redundant, because the backend already formats codes the way it wants them read, and quietly dangerous, because the string on screen was then not the string that existed and the component had to remember to copy the real one.
  • A coupon that cannot be used does not show its code. Not struck through, not greyed: absent, along with the copy control and the scannable. A code is a bearer instrument, so a redeemed or withdrawn coupon leaving a readable one on screen invites an attempt that fails at a till in front of the member. What a spent coupon owes them is the offer it was and the reason it is not — which is renderLocked.
  • terms is the compliance slot: exclusions, minimum spend, the link to the full rules.

The third kind of failure

The kit now has three, and this component introduced the last one.

Asks Can fail because Response
Scratch card, spin wheel A server, for a verdict Network, backend Retryable error, same request identity
Stamp card Nothing No failure path exists
Coupon card The browser, for the clipboard Insecure context, permission, a webview with no clipboard Show the code and say so

There is no retry here, because there is no request to repeat. The honest response to a refused clipboard is to stop hiding the code and let the member read it, which is exactly what happens: the failure uncovers the code, says what to do next, and leaves the message up while they type.

Phrase labels.copyFailed as an instruction, not a diagnosis. The member's next move is to read the code that is now on screen; "Couldn't copy — select the code above" beats any sentence containing the word clipboard.

Campaign status

The component renders host-provided state; it never computes it. The union is identical to the rest of the kit, and every member maps:

Your campaign status Member sees
Issued and live available A usable coupon
Redeemed already-played The ticket, spent, no code
Multi-use, uses spent attempts-exhausted The ticket, spent, with your copy
Issued ahead of its window not-started The ticket, with an opens-on line
Not usable on this account not-eligible The ticket, with your copy
Campaign closed ended The ticket, spent, with your copy

Expiry is not in this table on purpose. An expired coupon is ended (or whatever your backend calls it) — a decision your server makes and sends. The countdown reaching zero changes how the card reads, never what it permits.

Props

Prop Type Notes
code string The redemption code. The one thing this component exists to deliver.
labels { coupon, codeLabel, copy, copied, copyFailed, revealCode, expiry(r, expiresAt) } Required; all copy, screen-reader announcements included. expiry gets null for r before the clock is known — during server rendering and the first client paint — so return an absolute phrasing then and a relative one after. The raw date comes alongside precisely so that case is answerable.
value ReactNode The headline offer: "20% off".
title ReactNode What it applies to.
description ReactNode Supporting line: minimum spend, channel.
logo ReactNode The merchant's mark, at the top of the face. Kept small — the offer is the hero. Whose mark it is (issuer, partner, referrer) is yours to decide; it is a node, so all three are the same prop.
action ReactNode Primary action inside the face. Yours, always.
renderFace () => ReactNode Replaces the whole face. CouponFace is the shipped default.
renderScannable (code) => ReactNode A barcode, QR or Aztec, shown in the stub. The kit ships no encoder — see below.
reveal "immediate" | "tap" tap hides the code behind a cover. Default immediate.
cover CouponCoverConfig color, image, textColor — sugar over the --lk-coupon-cover* tokens. Same shape as the scratch card's foil.
renderCover () => ReactNode Branding inside the cover. Content only — the cover is itself the button.
copyable boolean The copy control. Default true.
renderCopyAction ({ copy, copied }) => ReactNode Your control instead of the kit's.
expiresAt string ISO 8601. Drives the countdown and <time>. Display only.
stub "bottom" | "top" | "end" Where the stub sits. Purely positional. Default bottom. See below.
perforated boolean Whether the tear is a perforation with notches. Default true; false gives a plain divider, for backgrounds the painted notches cannot match.
status "available" | "not-started" | "already-played" | "not-eligible" | "attempts-exhausted" | "ended" Host-provided campaign state.
renderLocked (status) => ReactNode Content for locked statuses, rendered alongside the ticket.
ticket CouponTicketConfig color, image, border, radius, perforation, codeColor, valueColor, notchColor, textColor, actionColor. Every value accepts any CSS value including var(--your-token).
header / footer ReactNode Above/below the ticket.
terms ReactNode Compliance slot.
onCopyResult (ok: boolean) => void Fires after a copy attempt, with whether the clipboard accepted it.
onReveal () => void Fires the first time a hidden code is uncovered. Treat it as intent to use.

Any data-* or aria-* prop passes through to the root element. title does not — the DOM has its own and it is a string, so the passthrough gives up a native tooltip to keep title meaning the coupon's title. onCopy is likewise left alone: React already has one meaning "the user copied a selection", which is a genuinely useful thing to attach to a coupon, so this component's callback is onCopyResult.

Barcodes, QR and anything else scannable

renderScannable receives the code and renders into the stub. The kit ships no encoder and never will. Encoding is a correctness problem with consequences at the till — a wrong checksum is a coupon that does not scan — and it belongs to a library that specialises in it, chosen and versioned by you. What the kit does is reserve the right place for one.

Whatever you render there, keep the code as text too. A scanner that will not read the screen, a till that only takes typed codes, and a member using a screen reader are all served by the text and none of them by the image.

The cover

reveal="tap" puts a cover over the code until the member asks for it.

The cover is the button. Not a small control sitting on a branded panel — the panel itself. That is the same bargain the scratch card strikes in auto mode, and it exists so a member never faces a designed surface with a second little button on top of it saying press me instead. Whatever renderCover supplies is therefore content, not a control: a logo, a headline, a pattern. Nothing interactive.

It is shaped like the scratch card's foil on purpose — cover.color, cover.image, cover.textColor — because it is the same job, and the same job should not have two vocabularies. image takes any background-image value, so a gradient is as legal as a url().

Anything scannable is hidden with the code. A barcode is the code in another alphabet, and a scanner does not care which of the two it reads.

Hiding is not security. The code is in the DOM either way and anyone minded to take it will. It buys two real things: a list of coupons does not leak every code to a shoulder-surfer or a screenshot, and the tap is an event — onReveal — that your backend can treat as intent to use.

A refused clipboard uncovers the code regardless. A member who cannot copy and cannot read is a member who cannot use their reward.

The ticket shape

A ticket has two parts: the face carries the offer, the stub carries the code. stub says where the stub goes — and that is all it says.

stub Reads as
bottom (default) The conventional ticket: offer first, code beneath.
top Code first, offer beneath. The right order once a member already knows what the coupon is for and has opened it to read the code at a till.
end Side by side, for a wide card or a dense row.

There is deliberately no none value here, and the reason is worth stating so nobody adds one: a value that only removed the perforation would still render a stub, in the same place, with the same contents — leaving two positions that differ by a dashed line and nothing else. Whether the tear is drawn is a different question from where the stub sits, and it is a different prop: perforated.

stub="top" reorders the markup, rather than reversing it in CSS. column-reverse would have been one line and would have left the code reading above the offer on screen and below it in the accessibility tree. That is WCAG 1.3.2; the practical version is that a keyboard user reaching a copy button they cannot see yet is a bug nobody reports and everybody feels.

The tear line lands where the content actually divides, so nothing goes stale when the stub gains a barcode. The notches at its ends are painted, not cut, which is a real trade-off: cutting them with a mask would need the perforation's position baked into the mask as an offset, and that offset breaks the moment the stub changes height. Painting needs no position — but it does need to know what is behind the ticket.

The painted circles carry no outline of their own, and that is what makes them read as holes. With one they looked like rings stuck on top of the card, because the half sitting outside the ticket was a visible outlined shape floating on the background. Without it, each circle does the right thing in all three places it covers: a hole over the ticket surface, a bite out of the ticket's border, and nothing at all outside.

So --lk-coupon-notch-color defaults to --lk-surface, which is right whenever the coupon sits on the page. Set it to whatever the ticket actually sits on, or use perforated={false} if that is a gradient or an image.

Brand artwork on the ticket

ticket.image fills the whole ticket — face and stub together — with any background-image value, so a gradient is as legal as a url(). It is the scratch card's foil.image and the cover's image at ticket scale, and it is how a coupon carries a campaign's own artwork rather than a flat brand colour.

Three things travel with it:

  • The artwork composites over the fill, not instead of it. A transparent PNG lands on the brand's own stock, and the colour is what shows if the image fails to load.
  • Set ticket.textColor in the same breath. The component cannot inspect an image to know whether the type on top of it is legible — the same boundary the accessibility page draws around the scratch card's hint. Artwork worth putting on a ticket is usually dark enough to swallow the default type, so the playground's Ticket art toggle sets both, and the generated snippet prints both.
  • Consider perforated={false}. The notches are painted rather than cut and have to match what sits behind the ticket, which artwork does not change — but a ticket busy enough to carry a photograph rarely wants two circles punched out of it either.

The code keeps its own panel, and that panel is derived from the code's own text colour rather than from a page surface — a 10% wash of currentColor. That is a fix ticket.image found on its first run: the code inherits the ticket's text colour (so a tinted stock carries it), but the panel used to default to --lk-surface-raised, which does not follow a tint. Light text on dark artwork therefore landed near-white code on a near-white panel. Deriving the panel from the text is legible in both directions with nothing extra to remember: near-black over white gives the light grey panel it always had, near-white over artwork gives a translucent lift.

Tokens

Token Default Purpose
--lk-coupon-surface --lk-surface Ticket fill.
--lk-coupon-image none Artwork across the whole ticket. Any background-image value, composited over the fill.
--lk-coupon-border --lk-border Ticket outline.
--lk-coupon-radius --lk-radius-lg Ticket corners.
--lk-coupon-perforation --lk-border The tear line.
--lk-coupon-notch 18px Notch diameter.
--lk-coupon-notch-color --lk-surface What shows through a notch. See above.
--lk-coupon-scannable-size 260px The largest a barcode or QR grows to.
--lk-coupon-code-surface a 10% wash of the code's own colour The code's own panel. Derived from the text rather than from a page surface, so it stays legible on a tinted or arted ticket in both directions — see below.
--lk-coupon-text inherits Text on the ticket. Any tint that darkens the stock must set this.
--lk-coupon-code-text inherits The code itself.
--lk-coupon-value-color inherits The offer value. Deliberately not accent-driven: an accent can be white or near-black and would vanish against a matching surface.
--lk-coupon-cover --lk-surface-raised The cover's fill.
--lk-coupon-cover-image none Artwork across the cover. Any background-image value.
--lk-coupon-cover-text --lk-text-muted Text and marks on the cover.
--lk-coupon-cover-height 88px Its minimum height — room for a logo.
--lk-coupon-action --lk-action The copy control. Set it with any ticket tint or artwork: the theme corrects the action colour against the page surface, which a tinted ticket is not.
--lk-coupon-shadow none Ticket elevation. Flat by default.
--lk-font-mono system monospace The code's face.

The coupon card adds nothing to tokens.css. Every default resolves to a Tier-1 token that already retints per mode.

Accessibility

The code is text, selectable, never an image. Its accessible name spells the characters apart, because a screen reader reads SAVE20 as a word — useless to someone keying it into a till, and ambiguous between O and 0, I and 1. The visible string keeps its grouping; only the spoken one is spaced.

The visible code is monospace and generously tracked for the same reason: the most likely thing a member does with a code that will not copy is read it off the screen and type it somewhere else.

Copy results are announced through a polite live region. The success message clears itself; the failure does not, because a member typing a code out needs the instruction to stay put while they do it.

Expiry renders as <time dateTime>, so the machine-readable date survives translation, truncation and a screen reader's own date formatting.

The countdown does not live in a live region. A number that changes every minute inside one would interrupt a reader every minute; the value is there to be read on demand, and the urgency that matters is carried by status and by your own copy.

Integration contract

Your backend should: issue codes server-side and never let the browser generate one, decide expiry and eligibility and send them as status, treat redemption as idempotent so a double-tap cannot spend a coupon twice, and keep the code out of any client-side log or analytics payload.

A coupon in the DOM is a coupon the member has. If a code must not be readable until some condition is met, do not send it — reveal="tap" is a presentation choice, not an access control.

When to use it / when not to

Use it for a reward that has to travel: a code the member types at checkout, shows at a till, or scans. Pair it with any of the chance mechanics via renderOutcome, or with the stamp card via renderReward.

Prefer the reward catalog when the member is choosing between offers rather than holding one. Don't use it for a reward with no artifact — points that land in a balance need a points counter, not a coupon with nothing to type.

Common mistakes

Deciding expiry from the device clock; copying the formatted code instead of the raw one; treating reveal="tap" as access control; shipping a barcode image without the text; putting the countdown in a live region; putting artwork on the ticket without the text colour to survive it; reversing the stub in CSS so the reading order stops matching the screen; and erasing a redeemed coupon instead of marking it spent.