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.
expiresAtdrives a countdown and the<time>element, and nothing else. Whether a coupon can be used isstatus, 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.
actiontakes 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
codeGroupingprop that choppedSAVE20intoSAVE-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. termsis 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.textColorin 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.