Loyalty Patternsv1.00

Referral widget

The share surface, and both sides of the bargain. Advocacy is the highest commercial value per pixel in the set, and this component's job is to make the ask honest and the sharing effortless — in that order.

Install

Copy this folder alongside tokens.css.

The fallback has a fallback

The coupon card found the kit's third kind of failure: a browser refusing a capability, with nothing to retry. This component is that failure kind's second instance, and the one that proves the taxonomy generalises — because here the chain is three deep:

Step Fails when Degrades to
navigator.share Absent on most desktop browsers and some webviews; can error mid-flight Copy the link
Copy the link Insecure context, denied permission, a webview with no clipboard Select it yourself
Select it yourself It cannot fail, which is the point

The chain is built backwards from that last property: the link is always visible, always selectable text, because the bottom of a fallback chain cannot be hidden. Each control labels itself honestly for the affordance it actually is — "Share" where a sheet exists, "Copy link" where it does not — via labels.share and labels.copyLink.

Dismissal is not failure. navigator.share rejects with an AbortError when the member closes the sheet without choosing a target. That is a decision — "no thanks" — and the component swallows it silently, reporting "dismissed" through onShareResult. Announcing an error there gaslights the member, and attribution that logs it as one will spend a sprint chasing a bug that does not exist.

Both sides of the bargain

incentive takes two slots — what the member gets, what the friend gets — rendered side by side at equal weight.

Two slots rather than one, because a referral pitch that hides one side reads as a trick: the member wonders what the friend is walking into, and the friend, forwarded the link, wonders what the sender got for it. Symmetry is the honesty, and the equal columns are deliberate — rendering "you get" larger than "they get" is the visual version of the same trick.

Card stock

surface="card" (the default) puts a bordered, padded surface behind the body — incentive, link and funnel — while header, footer, terms and the locked notice stay outside it. The stamp card's division, for the stamp card's reason: the frame belongs to the mechanic, never to the component.

Set surface="none" when the widget sits inside your own card or sheet — a shipped border there becomes a frame inside a frame. The paint is all tokens (--lk-referral-surface*), so stock that needs to look different does not need the prop; only stock that should not exist does.

A locked widget with no incentive and no funnel renders no box at all: an empty bordered region reads as a loading failure.

Counts, never identities

The funnel — "3 invited, 2 joined, 1 qualified" — takes numbers with the server's names for them, up to four stages.

It does not take a list of referred friends, and this is a privacy position, not a layout one: names of people who accepted an invitation are their PII on someone else's screen — in screenshots, in support calls, over shoulders — and no campaign needs names to motivate. If your backend can only send identities, aggregate before the browser.

Milestone rewards compose; they are not built in. "Five qualified referrals earns a free month" is the stamp card's job:

tsx
<StampCard total={5} collected={qualified} labels={labels} />

Feed it the qualified count and the two components do together what neither should do alone — the kit's first composition recipe.

Scannable, in whichever format your members scan

renderScannable renders the link for in-person referral — a card on a counter, a poster, a phone held up to a phone. It is one seam and it takes either format, or anything else scannable:

tsx
renderScannable={(link) => <YourQRCode value={link} />}
renderScannable={(link) => <YourBarcode value={link} />}

That the seam is format-agnostic is stated rather than implied, because a name like renderBarcode would hide the fact that QR — the format most loyalty apps actually use — is equally supported. Naming a seam after one of its formats is how a capability goes unnoticed.

The kit ships no encoder, deliberately. A correct QR or Code 128 implementation is a specialised library with error-correction levels and quiet-zone rules, and a hand-rolled one produces something that looks scannable and is not — which fails at a till, in front of a member, in a way the browser cannot report.

A barcode wants more width and less height than a QR. The container caps its width at --lk-referral-scannable-size, so raise that token for a barcode rather than fighting the component:

css
.your-referral { --lk-referral-scannable-size: 320px; }

The scannable is an addition to the fallback chain, never a step in it: the text link stays visible regardless, because a camera is not a fallback for a clipboard.

Behavior contract

  • The link is minted server-side, per member, always. The link IS the attribution: whoever controls its shape controls who gets credited, and a client that assembles one from parts is a client that can assemble someone else's. In practice that rules out four specific things: building the link from a member id or code, appending or rewriting its query parameters, guessing it from a pattern, and caching one member's link where another session can read it. String concatenation anywhere near this component means the endpoint is missing a field.
  • Only the display is shortened. The visible form strips the protocol and elides a long middle, because people read these aloud. What is shared, copied and exposed to assistive technology is always the full link.
  • share.title / share.text are campaign content, not UI copy — they travel with the link into the sheet, which is why they are a prop rather than a label.
  • The component reports; it never records. onShareResult says how the attempt ended (shared | copied | dismissed | failed); writing that to your attribution system is yours.
  • terms is the compliance slot: qualifying conditions, reward limits, when rewards land.

Props

Prop Type Notes
link string The member's referral link. Server-minted.
labels { widget, linkLabel, share, copyLink, copied, copyFailed, youGet?, friendGets? } Required; all copy. The last two are optional on the milestone-label rule — only reachable when incentive is set.
incentive { you?, friend? } Both sides of the bargain, as content slots.
share { title?, text? } The native sheet's payload.
shareMode "auto" | "copy-only" Default auto. copy-only skips the sheet even where it exists — one consistent affordance, or a webview whose sheet is broken in ways the component cannot detect.
surface "card" | "none" Whether the body sits on card stock. Default card. See below.
stages ReferralStage[] The funnel: { id, label, count }, up to four. Counts, never identities.
renderScannable (link) => ReactNode The link, scannable: QR, barcode or anything else. The kit ships no encoder — the coupon card's seam and reasons. See above.
renderStage ({ stage, index }) => ReactNode Replaces one funnel row. Counts only, whatever you render.
renderShareAction ({ act, method, copied }) => ReactNode Replaces the control. method says which affordance the component resolved to, so yours can label itself honestly.
panels ReferralStyleConfig linkColor, incentiveColor, border, radius, countColor. Every value accepts any CSS value including var(--your-token).
status the kit's six Host-provided campaign state.
renderLocked (status) => ReactNode Renders alongside — the funnel is a record of referrals already made.
header / footer ReactNode The pitch; when rewards land.
terms ReactNode Compliance slot.
onShareResult (result) => void shared | copied | dismissed | failed.

Any data-* or aria-* prop passes through to the root element.

Campaign status

Your campaign status Member sees
Running available The live widget
Programme closed ended Funnel and incentive, dimmed, with your copy
Not enrolled not-eligible The widget, dimmed, with your copy
Referral reward already earned already-played The record, with your copy
Invite cap reached attempts-exhausted The record, with your come-back-later copy
Opens with the campaign not-started The widget, with an opens-on line

A locked widget keeps its funnel — referrals already made are a record — and drops the share block, because the one thing a closed campaign must not do is keep handing out the link.

Tokens

Token Default Purpose
--lk-referral-link-surface --lk-surface-raised The link's panel.
--lk-referral-incentive-surface --lk-surface The two offer panels.
--lk-referral-border --lk-border Panel outlines and funnel dividers.
--lk-referral-radius --lk-radius Panel corners.
--lk-referral-count-color --lk-text The funnel counts. Not accent-driven by default.
--lk-referral-surface --lk-surface Card stock behind the body. surface="card" only.
--lk-referral-surface-border --lk-border Its outline.
--lk-referral-surface-radius --lk-radius-lg Its corners.
--lk-referral-surface-padding calc(--lk-space * 4) Space between the stock's edge and the body.
--lk-referral-surface-shadow none Stock elevation. Flat by default.
--lk-referral-scannable-size 200px The largest the scannable grows to. Raise it for a barcode, which needs width.
--lk-font-mono system monospace The link's face.

The widget adds nothing to tokens.css.

Accessibility

The share control is a real button with the kit's focus treatment, and its label tells the truth about what it does on this browser. Copy results are announced through a polite live region; the success clears itself, the failure stays while the member selects by hand — the coupon card's bargain.

The visible link is elided; its accessible name is the full link. Funnel stages are plain text in a real list — a count and its label are already words, so there is nothing to hide and re-speak.

There is no gesture, so WCAG 2.5.1 does not apply.

When to use it / when not to

Use it wherever a member can bring another: the incentive slots carry the bargain, the funnel shows it working, and the stamp card composition covers milestone rewards.

Don't use it for influencer-style codes with public reach — that is a coupon card whose code happens to identify a person. This component is member-to-friend, where the share sheet is the natural verb.

Common mistakes

Assembling the link client-side; sharing the elided display form instead of the full link; announcing an error when the member dismissed the sheet; listing referred friends by name; hiding the link behind the share button; hand-rolling a QR or barcode encoder; treating the scannable as a step in the fallback chain rather than an addition to it; and recording attribution in the component instead of behind onShareResult.