Waitlist
A viral waitlist block: validated email capture, a joined panel that reports your place and derives the rest of the maths, a referral link worth a stated number of places, and a leaderboard teaser that pins your own row.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/waitlist.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "Waitlist" block with zod and
lucide-react, on the shadcn Button and Input primitives; cn() (clsx +
tailwind-merge) for classes, no other dependency. It is the block a pre-launch
page is built around: take an address, and pay the visitor back immediately
with a place in line and a link that moves them up a stated number of places.
Its one rule is that it never invents a number — the place is reported by the
host, everything else is arithmetic on top of it.
Contract
- Props: onJoin (required), defaultEntry?, heading?, description?, defaultEmail?,
emailLabel?, placeholder?, submitLabel?, pendingLabel? ("Joining…"),
retryLabel? ("Try again"), emptyMessage?, invalidMessage?, failureMessage?,
joinedTitle?, returningTitle?, joinedDescription?, closed? (false),
closedMessage?, skipPerReferral? (5), referralTitle?, referralLabel?,
onCopyReferral?, leaders? ([]), leaderboardSize? (3, clamped 1-10),
leaderboardTitle?, leaderboardHref?, locale? ("en-US"), footnote?, className.
forwardRef<HTMLElement>, extends Omit<React.HTMLAttributes<HTMLElement>,
"onSubmit" | "children">, rest spread on the root <section>.
- onJoin: (email: string) => Promise<WaitlistEntry | void>. It receives the
TRIMMED address and owns the network call; the block fetches nothing itself.
Resolve with an entry to show a place, resolve with nothing for a bare
confirmation, reject to show a retryable error.
- WaitlistEntry = { position?: number | null; total?: number | null; email?;
referralUrl?; referrals?: number; returning?: boolean; message? }. Every field
is optional because every one of them is the host's to report. position is
1-based; total is the list size; returning true means the address was already
on the list, which is still a success; message replaces the confirmation line
verbatim.
- WaitlistLeader = { id; name; referrals; you? }. Ranks are NOT passed in — the
block computes them, so two callers cannot disagree about who is second.
- defaultEntry renders joined on the first paint (a returning visitor whose
place the server already knows). Uncontrolled initial value only: later
changes are ignored, so the panel cannot be yanked out from under a reader.
- Export waitlistEmailSchema (z.email()) so the host runs the identical check
server-side, and export the two pure functions below so a confirmation email
or a nav badge reuses the maths instead of growing a second opinion.
Behavior — the maths (waitlistStanding(entry, skipPerReferral))
- Returns null when no position was reported: no place, no position block. The
confirmation and the referral link still render.
- position = max(1, round(reported)) — a place is whole and there is no zeroth
place in a queue.
- total = max(round(reported), position). A list smaller than your own place is
inconsistent data, not a negative queue, so widen it rather than print "-3
people behind you". A missing / null / non-finite total stays null and every
figure that needed one is DROPPED, never guessed.
- ahead = position - 1. behind = total - position, or null.
- frontProgress = total === null ? null : total <= 1 ? 100 :
((total - position) / (total - 1)) * 100. Position 1 fills the bar, last in
line empties it, and a one-person list short-circuits instead of dividing by
zero.
- climbed = referrals × skipPerReferral — the places the credited referrals were
worth, which is exactly the claim the referral row makes out loud.
- projected = skipPerReferral === 0 ? null : max(1, position - skipPerReferral),
where one more referral lands you. At the front (projected === position) the
sentence stops projecting and says you are already there.
- skipPerReferral is a PROMISE: set it to 0 when the backend does not reorder
the queue, and every place-claim disappears while the link stays copyable.
Behavior — the leaderboard teaser (waitlistLeaderboard(leaders))
- Sort by referrals descending, ties keeping their original order, so a
re-render cannot shuffle two people who are level.
- Competition ranking (1, 2, 2, 4): equal counts share a rank and the next rank
skips the tie. Dense ranking would quietly promote everyone below a tie.
- Show the first `leaderboardSize` rows. If the row flagged you: true did not
make the cut, pin it below an ellipsis marker so the teaser always contains
you and never duplicates you.
- Gap line: rank 1 says the lead is yours to defend; otherwise take the row
directly above and print max(1, above.referrals - yours + 1) more referrals to
pass them — being level still needs one more — and, when skipPerReferral > 0,
what that is worth in places. No "you" row means no gap line: the block does
not invent a standing for a visitor it was not told about.
Behavior — the submit machine
- States: idle → submitting → joined | error, plus a field-level invalid flash
that never leaves idle.
- A real <form onSubmit> with noValidate, not a button with an onClick: that is
the only way Enter in the field submits, and it keeps the flow working with
the mouse unplugged. type="email" is kept for the mobile keyboard only — zod
is the validator, because the browser's rule accepts a@b (no TLD) and every
mail provider bounces it. maxLength 254 (RFC 5321).
- Blank field and malformed address are separate messages. Both keep the caret
in the field, set aria-invalid, and clear on the next keystroke — while a
network failure survives editing, so the retry keeps its reason.
- One join per press: the guard is a ref read AND written synchronously at the
top of the handler, because a state flag is only visible after a re-render and
the second press of a double click lands before that.
- The pending and closed states use aria-disabled plus a handler guard, NEVER
the native disabled attribute: the browser blurs a disabled control, so a
keyboard user who just pressed Enter would be dropped on <body> while the
pending state is announced from nowhere.
- closed=true is a refusal, not a dead control: the reason is printed above the
form, the button is aria-describedby it, the field stays typable, and pressing
Join speaks the reason through the live region without calling onJoin.
- A rejection prints an Error's own message verbatim (a string rejection too),
falling back to failureMessage, and the typed address survives.
- Re-check a mounted ref after every await before calling setState: a late
resolve or rejection can land on an unmounted instance. Set that ref in the
effect BODY, not only in cleanup — StrictMode runs mount → cleanup → mount in
dev, so a merely-cleared ref would read false for the live instance and no
join would ever land.
- Keep onJoin in a latest-ref updated on every render: consumers pass an inline
arrow, so it must never sit in a dependency array.
Behavior — focus, keyboard and ARIA
- Root <section aria-labelledby> pointing at the h2 heading, data-status
carrying the machine state for tests.
- The joined panel REPLACES the form, so it is the form's deliberate successor:
role="group" + tabIndex={-1} + aria-labelledby its title, focused in an effect
when the transition came from a submit. Claim focus only if the form (or
nobody) held it — a visitor who tabbed elsewhere mid-request keeps their
place. "Nobody" counts because clicking a <button> leaves focus on <body> in
Safari. Rendering joined from defaultEntry on the FIRST paint must never steal
focus.
- One always-mounted sr-only role="status" aria-atomic region: it speaks the
pending label, the closed refusal and the copy result. Mounting a live region
at the same moment as its text is unreliable in most screen readers. Repeating
an identical string is a no-op, so append a zero-width space (written as an
escape) to force the DOM change when the same refusal is pressed twice.
- Validation and network messages are a visible role="alert" paragraph the input
points at with aria-describedby.
- Tab order is a plain list: field, submit, then in the joined panel the link
field, the copy button and the optional board link. No composite widget, no
roving tabindex, Enter and Space activate natively.
- The referral field is a read-only Input that selects its whole value on focus,
so Control/Command + C works where the clipboard API does not.
- The progress bar is aria-hidden: the sentence above it and the list below it
already carry every number it encodes.
Behavior — copy
- navigator.clipboard.writeText inside try/catch, with a typeof navigator guard
for SSR. Insecure context, denied permission and Safari's user-gesture rules
all end in the same honest "Copy failed" plus a spoken instruction to select
the field and press Control/Command + C — never a silent fake "Copied".
- The button face returns to idle on a 2400ms timer that is cleared before
re-arming and on unmount; the write is async, so re-check the mounted ref
before touching state or arming the timer. onCopyReferral fires only after the
text really reached the clipboard.
Rendering & styling
- Semantic tokens only, no hex / rgb / oklch: bg-card + border + rounded-2xl
root, bg-muted/40 position block, bg-primary for the progress fill and the
confirmation check, bg-primary/10 + ring-primary/30 for your leaderboard row,
bg-destructive/10 + text-destructive for the alert, text-muted-foreground for
every secondary line, focus-visible:ring-ring throughout.
- tabular-nums on every figure so the place does not jitter between renders;
wrap-anywhere / break-all on addresses, links, names and messages so a
64-character address wraps instead of overflowing the card.
- Numbers are grouped through one Intl.NumberFormat built from `locale`, in a
try/catch — a malformed BCP-47 tag throws at construction and must not blank
the block.
- Reduced motion: motion-reduce:animate-none on the pending spinner and
motion-reduce:transition-none on the progress bar width. Nothing functional
depends on either.
Customization levers
- Sub-blocks: no referralUrl on the entry and the whole referral row disappears;
leaders: [] and the teaser disappears; no leaderboardHref and no board link is
painted (a dead link is worse than none); heading="" drops the header and its
accessible name with it. Strip the position block by resolving without a
position and you have a plain confirmation.
- The claim: skipPerReferral is the only place the "N places per referral"
promise lives — it drives the sentence, the climbed figure and the projection
at once. 0 turns every promise off.
- Board depth: leaderboardSize (3) is how many rows show before your pinned row;
raise it for a full-page board, lower it for a sidebar teaser.
- Copy: every user-visible string is a prop, so localising the block is a props
object, not a fork. joinedDescription is the default confirmation line and
entry.message overrides it per response.
- Palette: the fill / ring / badge classes are the only place colour is decided.
The default is monochrome (primary + destructive + muted); swap the progress
fill for var(--chart-1) if the block should read as a data viz.
- Actions: onJoin is your fetcher, onCopyReferral your analytics event, and
closed is the switch a launch runbook flips when a batch fills up.Concepts
- Reported place, derived standing —
positionandtotalare the only two numbers the block is told. Ahead, behind, the distance to the front, the places already gained and the projected place are all arithmetic on those two plusskipPerReferral, and an unpublished total silently drops the figures that needed it instead of guessing one. That is the difference between a waitlist and a slot machine. - The claim is stated, not implied — a referral link is only viral if the reward is written down.
skipPerReferraldrives the sentence (“moves you up 5 places”), the credit line and the projection from one number, so the copy and the maths cannot drift apart; set it to 0 and every promise disappears while the link stays copyable. - One join per press — the duplicate guard is a ref read and written synchronously inside the submit handler, because a state flag only becomes visible after a re-render and the second press of a double click lands before that. The pending button is
aria-disabled, never natively disabled, so the keyboard user who pressed Enter keeps their focus. - The joined panel is a deliberate successor — it replaces the form, so it takes focus and announces itself; a panel that merely appears leaves a screen-reader user on
<body>wondering whether the signup worked. Focus is claimed only when the form (or nobody) held it, and never on a first paint that started out joined. - Competition-ranked teaser with your row pinned — ranks are computed, not passed in, so ties share a rank and the next rank skips; if you did not make the visible cut your row is pinned under an ellipsis with the exact number of referrals needed to pass the person above you. No “you” row means no gap line — the block will not invent a standing for someone it was not told about.
- A refusal beats a dead control — closed signups keep the field typable and the button focusable, print the reason above the form and speak it when pressed, rather than greying out a control that silently ignores you.
Age Gate
A date-of-birth gate with three explicit boxes, a real calendar behind them, and a minimum-age rule whose refusal is honest and always offers a way out.
Scrollytelling Panel
A pinned visual beside stepped copy, where an IntersectionObserver reading line hands the frame to whichever step you are reading.