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:
<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:
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:
.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.textare 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.
onShareResultsays how the attempt ended (shared | copied | dismissed | failed); writing that to your attribution system is yours. termsis 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.