Blocks

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  ArrowUp,
  Check,
  Copy,
  Link2,
  LoaderCircle,
  Lock,
  Mail,
  Trophy,
  TriangleAlert,
} from "lucide-react"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/waitlist.json

Prompt

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 standingposition and total are 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 plus skipPerReferral, 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. skipPerReferral drives 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.

On This Page