# Referral

> A referral pass that fills as friends join: their faces fly up into its notches, and the last one turns the track into Claim.

Most referral screens are a link, a copy button and a sentence about a reward that never seems closer. Here the reward is a lit pass with a notch for every friend it needs. Invite by email (paste a whole list; each address becomes a chip) and Send goes Sending → Sent as they slide into the list as Invited. When one of them joins, their face lifts out of their row and flies up into the next notch, which lights with their initials while "Invited" morphs to "Joined" and the count rolls. The last friend lands, the notches close in on the middle and open into the Claim button. Your link sits inset in the pass with Copy. The programme is yours: the friends and their status come from your database, and onInvite and onClaim do the work.

## Install

```bash
npx shadcn@latest add https://hairlineui.com/r/referral.json
```

Needs React 19, Tailwind CSS v4 and shadcn/ui theme variables (any style, light and dark). Icons from lucide-react.

- npm: `lucide-react`
- Hairline UI components it needs (the CLI installs them too): `copy-button`, `text-morph`, `number-roll`, `status-button`, `tag-input`
- Docs and live preview: https://hairlineui.com/ui/referral
- Registry item (source): https://hairlineui.com/r/referral.json

## Usage

```tsx
import { Referral } from "@/components/referral";

<Referral
  link={user.referralLink}
  friends={friends}
  goal={3}
  reward="a free month of Pro"
  offer="Friends get 20% off their first year."
  onInvite={(emails) => api.invite(emails)}
  onClaim={() => api.claimReward()}
/>
```

## With your own API

Keep invites in your database and refresh the list after each call; a friend joining is just their status turning "joined" in the data you pass, from a refetch, a poll or a socket.

```tsx
"use client";
import { useEffect, useState } from "react";
import { Referral, type ReferralFriend } from "@/components/referral";

async function post(path: string, body?: unknown) {
  const res = await fetch(path, { method: "POST", body: JSON.stringify(body ?? {}) });
  if (!res.ok) throw new Error((await res.json()).message);
}

export function InviteFriends({ link }: { link: string }) {
  const [friends, setFriends] = useState<ReferralFriend[]>([]);
  const load = () => fetch("/api/referrals").then((r) => r.json()).then(setFriends);

  // Check now and then, so a friend who joins lands on the pass while you watch.
  useEffect(() => {
    load();
    const timer = setInterval(load, 15000);
    return () => clearInterval(timer);
  }, []);

  return (
    <Referral
      link={link}
      friends={friends}
      goal={3}
      reward="a free month of Pro"
      onInvite={async (emails) => {
        await post("/api/referrals", { emails });
        await load();
      }}
      onClaim={() => post("/api/referrals/claim")}
    />
  );
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `link` | `string` | – | The person’s invite link, shown inset with Copy. |
| `friends` | `ReferralFriend[]` | – | Newest first: { id, email, name?, status: "invited" \| "joined" }. A status turning joined is what flies a face to the pass. |
| `goal` | `number` | – | Friends needed for the reward; one notch each. |
| `reward / offer` | `string` | – | What they get (“a free month of Pro”) and what friends get, under the link. |
| `onInvite` | `(emails: string[]) => Promise<unknown>` | – | Send the invites and add them to friends; throw an Error to keep the chips and show why. |
| `onClaim` | `() => Promise<unknown>` | – | Give the reward once the goal is reached. |
| `claimed` | `boolean` | – | Already claimed (from your data); otherwise it follows onClaim. |

## States

- **pass**: The lit pass: “2 more friends and you get a free month”, a track of notches (lit ones carry initials, joined by lit hairlines), and the link inset with Copy.
- **invite**: Addresses become chips in the tag input; invalid or already invited ones are marked. Send invite(s) → Sending → Sent, and the new friends slide into the list as Invited.
- **joined**: A friend’s face lifts from their row in an arc (820ms, rising and growing then settling to the notch’s size); the notch lights as it lands, “Invited” morphs to “Joined” with a check folding in, and the count rolls.
- **unlocked**: At the goal the notches slide toward the middle and fade while the Claim button opens out of the centre (a 560ms clip), and the heading morphs to “You unlocked it”.
- **claimed**: Claim → Claiming → Claimed with a drawn check; the heading says Reward claimed.
- **error**: If onInvite throws, the button shakes into Try again, the chips stay and your message replaces the hint.

## What it handles

A referral page is usually a link, an email form, a list and a progress bar wired up separately. This block keeps them as one surface whose reward visibly fills, against your own referral table.

- **A pass with a notch per friend**: goal sets the notches; each joined friend fills one with their initials, in the order they joined.
- **Joining you can see**: When a friend’s status turns joined, their face flies from their row to their notch, which lights as it lands.
- **Invites as chips**: Paste a list or type addresses; wrong ones and people already invited are caught before sending.
- **Unlock in place**: At the goal the notches close in and become Claim; onClaim goes Claiming → Claimed.
- **Your link, one tap**: The link sits inset in the pass with a Copy button that confirms in place.
- **Your database decides**: friends and their status are props; onInvite and onClaim throw your message and it shows where it happened.

## Replaces

a share link field, an email invite form, an invited list, a referral progress bar, a claim button, a success toast

## Where it fits

- **SaaS referrals**: “Give a month, get a month”, with the people you invited and who has joined.
- **Waitlists**: Invite friends to move up the list or unlock early access.
- **Team growth**: Invite colleagues to unlock a plan limit or a feature.
- **Stores and apps**: A credit for every friend’s first order, claimed in place.

## Notes

- The email field is the Tag input: paste lists, Backspace to edit the last chip, errors read with each chip.
- Friends who join and invites that are sent are announced from a status region; the count is read as a whole number.
- The flying face is a copy laid over the block and removed when it lands; the real notch is what stays, so nothing moves under a reader’s focus.
- The notches are presentation; the heading says how many friends are left, and Claim is a real button that only takes focus once unlocked.
- Its light comes from your theme: the pass mixes --primary-foreground into --primary, friends take tints from --chart-1 to --chart-5, and the link’s inset is a 1px edge. No drop shadows, glow or blur.
- Built from Tag input, Copy button, Number roll, Status button and Text morph, which the CLI installs with it. No animation library; with reduced motion notches light in place and nothing flies.

License: MIT (https://hairlineui.com/license#components)
