Referral Progress
A referral card: copy your invite link, see friends who joined on a milestone track, and what the next reward unlocks.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/referral-progress.json'use client'
import { useState } from 'react'
import './referral-progress.css'
export interface ReferralProgressProps {
link: string
friends?: { name: string; joined: boolean }[]
tiers?: { at: number; reward: string }[]
fill?: 'dark' | 'light'
className?: string
}
/** Invite link with milestone rewards. */
export function ReferralProgress({ link, friends = [], tiers = [{ at: 1, reward: '1 month free' }, { at: 3, reward: '$30 credit' }, { at: 5, reward: 'Pro for a year' }], fill = 'dark', className }: ReferralProgressProps) {
const [copied, setCopied] = useState(false)
const n = friends.filter((f) => f.joined).length
const max = tiers[tiers.length - 1].at
const next = tiers.find((t) => t.at > n)
return (
<div className={['sd-ref', className].filter(Boolean).join(' ')} data-fill={fill}>
<h3>Invite friends, get rewards</h3>
<p>They get 20% off their first year. You unlock the rewards below.</p>
<div className="sd-ref__link">
<span>{link.replace(/^https?:\/\//, '')}</span>
<button type="button" className="sd-ref__cp" onClick={() => { navigator.clipboard?.writeText(link).catch(() => {}); setCopied(true); setTimeout(() => setCopied(false), 1500) }}>{copied ? 'Copied' : 'Copy'}</button>
</div>
<span className="sr" role="status">{copied ? 'Link copied' : ''}</span>
<div className="sd-ref__track" role="progressbar" aria-label="Friends joined" aria-valuemin={0} aria-valuemax={max} aria-valuenow={n}>
<div className="sd-ref__rail"><i style={{ width: `${(Math.min(n, max) / max) * 100}%` }} /></div>
{tiers.map((t) => <div key={t.at} className="sd-ref__m" style={{ left: `${(t.at / max) * 100}%` }} data-on={n >= t.at || undefined}><b>{n >= t.at ? '✓' : t.at}</b><span>{t.reward}</span></div>)}
</div>
<p className="sd-ref__next" aria-live="polite">{next ? <><b>{next.at - n} more {next.at - n === 1 ? 'friend' : 'friends'}</b> to unlock {next.reward}.</> : <b>Every reward unlocked. Thank you!</b>}</p>
<div className="sd-ref__fr" aria-label="Invites">
{friends.map((f) => <span key={f.name} className="sd-ref__f" data-on={f.joined || undefined}><i aria-hidden>{f.name[0]}</i>{f.name}<small>{f.joined ? 'joined' : 'invited'}</small></span>)}
</div>
</div>
)
}.sd-ref { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-ref[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-ref :where(button) { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-ref :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-ref .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-ref { width: min(100%, 330px); padding: 18px; border-radius: 22px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-ref h3 { margin: 0 0 4px; font-size: 17px; letter-spacing: -0.01em; }
.sd-ref > p { margin: 0 0 14px; font-size: 13px; color: var(--mute); line-height: 1.45; }
.sd-ref__link { display: flex; align-items: center; gap: 6px; padding: 4px 4px 4px 12px; border-radius: 12px; background: var(--soft); }
.sd-ref__link span { flex: 1; min-width: 0; font: 12.5px/1.2 ui-monospace, monospace; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sd-ref__cp { padding: 8px 12px; border-radius: 9px; background: var(--strong) !important; color: var(--on) !important; font-size: 12.5px; font-weight: 600; min-width: 64px; }
.sd-ref__track { position: relative; height: 54px; margin: 22px 34px 6px 12px; }
.sd-ref__rail { position: absolute; left: 0; right: 0; top: 10px; height: 4px; border-radius: 99px; background: var(--soft); }
.sd-ref__rail i { position: absolute; left: 0; top: 0; bottom: 0; border-radius: 99px; background: var(--fg); transition: width 500ms cubic-bezier(0.3, 1.2, 0.4, 1); }
.sd-ref__m { position: absolute; top: 0; transform: translateX(-50%); display: flex; flex-direction: column; align-items: center; gap: 6px; width: 70px; text-align: center; }
.sd-ref__m b { display: grid; place-items: center; width: 24px; height: 24px; border-radius: 99px; background: var(--bg); box-shadow: inset 0 0 0 2px var(--line); font-size: 11px; transition: background-color 200ms, box-shadow 200ms; }
.sd-ref__m[data-on] b { background: var(--strong); color: var(--on); box-shadow: none; }
.sd-ref__m span { font-size: 11px; color: var(--mute); line-height: 1.25; }
.sd-ref__m[data-on] span { color: var(--fg); }
.sd-ref__next { margin: 10px 0 14px; padding: 10px 12px; border-radius: 12px; box-shadow: inset 0 0 0 1px var(--line); font-size: 13px; }
.sd-ref__fr { display: flex; gap: 6px; flex-wrap: wrap; }
.sd-ref__f { display: flex; align-items: center; gap: 6px; padding: 4px 10px 4px 4px; border-radius: 99px; box-shadow: inset 0 0 0 1px var(--line); font-size: 12px; }
.sd-ref__f i { display: grid; place-items: center; width: 22px; height: 22px; border-radius: 99px; box-shadow: inset 0 0 0 1.5px var(--mute); font-style: normal; font-size: 10px; font-weight: 700; }
.sd-ref__f[data-on] i { background: var(--strong); color: var(--on); box-shadow: none; }
.sd-ref__f small { color: var(--mute); font-size: 11px; }
@media (prefers-reduced-motion: reduce) { .sd-ref *, .sd-ref *::before, .sd-ref *::after { animation: none !important; transition: none !important; } }Overview
The link copies with one tap. Milestones sit on a track (1 friend, 3 friends, 5 friends) and fill as invites convert, each with its reward.
Pending invites show as hollow, joined as filled. The next reward is spelled out with how many more friends it needs.
When to use
- Growth loops, account pages, post-purchase screens.
When not to use
- When rewards depend on rules you can’t show plainly.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/referral-progress.json$
npx shadcn@latest add https://sundaeui.dev/r/referral-progress.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/referral-progress.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/referral-progress.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy referral-progress.tsx and referral-progress.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { ReferralProgress } from '@/components/sundae/referral-progress'
<ReferralProgress />Props
| Prop | Type | Default |
|---|---|---|
link*Invite URL. | string | — |
friendsInvites. | { name: string; joined: boolean }[] | — |
tiersMilestones. | { at: number; reward: string }[] | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The track is a progressbar with the count of joined friends.
- Copy confirms with a status message.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-ref; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
FAQ
- Is Referral Progress free?
- Yes. Referral Progress is free for personal and commercial projects, with full source on this page.
- How do I install Referral Progress?
- Run npx shadcn@latest add https://sundaeui.dev/r/referral-progress.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Referral Progress support keyboard and screen readers?
- The track is a progressbar with the count of joined friends. Copy confirms with a status message.
- Does Referral Progress respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All components →Share Sheet
Components · RefinedRSVP Card
Components · RefinedToast Stack
Components · Refined- Pro
Sheet Drawer
Components · Refined Avatar Stack
Components · Refined- Pro
Team Members
Components · Understated