Invite Team
A four-state invite panel: a paste-a-list email field with per-address reasons, a role per invite, a seat meter that blocks over-allocation, undoable revoke and a copyable join link.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/invite-team.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "InviteTeam" block: a workspace invitation
panel driven by one zod contract, with four first-class states. Dependencies:
zod, lucide-react, shadcn's Button, Label and DropdownMenu. No animation library.
Contract (invite-team.contract.ts is the single source of truth; props are
z.infer of it, never a parallel hand-written interface)
- inviteTeamSchema: {
status: "loading" | "empty" | "error" | "ready",
now: ISO-8601 instant (required — the clock is injected, never read from
Date.now() during render, or SSR and hydration disagree),
workspace: string,
seats: { total: int >= 0, used: int >= 0 } | null // null = uncapped plan
roles: { id, label, description?, seatFree?: boolean }[],
invites: { id, email, role, status, sentAt }[], // role matches roles[].id
inviteLink: string | null // null = link joining off
}
invite status: "pending" | "accepted" | "expired" | "revoked".
- seats.used counts people ALREADY IN the workspace. Outstanding invitations are
deliberately not in it — the component derives those from invites[], so two
stored counters can never drift apart.
- seatFree roles (guest / viewer tiers) cost no seat. A full workspace can still
invite guests, which is what the billing page promises.
- Component props = InviteTeamData + onInvite(drafts: {email, role}[]),
onResend(invite), onRevoke(invite), onRetry(), onCopyLink(url),
defaultRole?, expiresAfterDays = 7, resendCooldownMs = 30000,
undoRevokeMs = 6000, maxRecipients = 50, locale = "en-US",
heading?, description?, className, plus the rest spread onto <section>
through forwardRef.
- Every optional handler gates its own affordance: no onInvite → no compose row,
no onResend / onRevoke → no such button, no onRetry → no retry. A control with
nothing behind it is a lie.
Behavior — composing
- Chip field: a bordered box holding one chip per drafted address plus a bare
<input> that flexes to fill the last row. The box, not the input, carries the
focus ring (focus-within) and role="group" with an aria-label counting the
recipients; clicking its padding focuses the input.
- Committing text into a chip happens on Enter, on any separator typed
(, ; tab newline — handled in onChange, because mobile keyboards and autofill
do not always emit a matching keydown) and on blur. Blur-commit is what makes
"type an address, then click Send" work.
- Paste: if the clipboard text contains no whitespace / , / ; let the browser
paste it normally (a single address stays editable). Otherwise preventDefault
and parse: split records on , ; tab newline; inside a record an
angle-bracketed address wins and its display name is dropped ("Ada Lovelace
<[email protected]>" must not arrive as "Ada", "Lovelace" and an address);
a record with no angle brackets is split further on whitespace; leading and
trailing quotes, brackets, commas and full stops are stripped.
- maxRecipients caps the draft list. A longer paste is truncated OUT LOUD:
"20 addresses added. 12 skipped — the list is capped at 50 recipients."
- Every draft is evaluated in ONE ordered pass, in the order the user typed:
syntax → duplicate within the batch → already invited → already a member →
seat budget. The budget is spent while walking, so the address that actually
overflows is the one flagged, and seatFree roles never spend anything.
The reason rides on the chip ("no seat left", "already invited") and is
repeated inside the remove button's accessible name.
- Email syntax is a deliberately loose pre-flight regex (one @, a dot in the
domain, none of the separator characters). The server is the authority on
whether an address exists; the regex only saves a round trip.
- Role per invite with a default: each chip carries roleId | null, where null
means "follow the batch default" set by the "Invite as" menu next to Send.
Picking a role on a chip pins an override; picking the batch role again on
that chip clears the override instead of pinning it.
- Send: onInvite receives only the addresses that passed every check; the failed
ones STAY in the field with their reasons, because clearing them would delete
the only copy of what the user pasted. A ref guard is read and written
synchronously inside the handler (a state-only guard lets a double click
through) and is re-armed by an effect that runs after every commit.
- The Send button uses aria-disabled + a handler guard, never the native
disabled attribute — a natively disabled button swallows the click that
follows the blur-commit, which is exactly how a just-typed address is sent.
When it cannot send it says why in the summary line it is described by.
Behavior — seats
- pendingSeats = invites whose EFFECTIVE lifecycle is "pending", minus the
seatFree ones, minus any revoke still inside its undo window.
- reserved = seats.used + pendingSeats; remaining = max(0, total - reserved);
overBy = max(0, reserved - total). remaining === null when seats is null, and
then the meter is not rendered and nothing is ever blocked for capacity.
- The bar is three stacked segments — members (bg-primary), pending
(bg-primary/50) and a live preview of what the current drafts will consume
(bg-primary/25). Each width is clamped against what is left AFTER the
segments before it, so the three can never sum past 100% and a downgraded
plan (used > total) fills the track and reports the overage in words.
- role="progressbar" with aria-valuemin / max / now and an aria-valuetext that
spells the whole sentence ("9 of 12 seats used, 3 remaining"); the segments
are aria-hidden. total === 0 renders "This plan includes no seats".
Behavior — the invitation list
- Lifecycle is DERIVED: an invitation still marked "pending" past
sentAt + expiresAfterDays renders as Expired, because backends sweep late or
never. Everything else is taken from the payload.
- Row: email (wrap-anywhere, never truncated), role pill, lifecycle pill, and a
meta line "Sent 2 days ago · Expires in 5 days" built with
Intl.RelativeTimeFormat against `now` (coarsest fit: seconds → minutes →
hours → days up to 45 → months → years). A typo'd locale must fall back to
en-US instead of throwing at Intl construction.
- Only pending and expired rows are actionable: accepted people are members now
(another screen owns them) and revoked rows are already dead.
- Resend is one-shot per row: the handler reads AND writes a Map<id, timer> ref
synchronously, paints "Sent" for resendCooldownMs, and explains itself if
pressed again. aria-disabled, never native disabled.
- Revoke is NOT a confirm modal. It parks: the row goes muted with a strike
through and an Undo button, and onRevoke fires only when the window closes.
If the panel unmounts with a revoke still parked, it is FLUSHED, not dropped —
navigating away is not consent to cancel an action the user asked for.
- Focus is moved deliberately at every unmount: removing a chip focuses the
previous chip (or the field), revoking focuses Undo, undoing focuses Revoke
again, a window that closes under the user's focus hands it back to the field,
and a retry that works does the same — Try again unmounts with the error branch
it just replaced, so the claim it stakes is held across a loading hop (that
branch has nothing to focus), spent on the field the ready / empty branch
mounts, and dropped if the user focused something else in the meantime.
Otherwise focus falls to <body> and the next Tab restarts the page.
Behavior — states, link and cleanup
- Four first-class branches. loading: skeleton rows (aria-hidden) plus an
sr-only role="status"; empty: "No invitations yet" while the compose row stays
usable; error: says nothing was sent, that invitations already out are
unaffected, and that inviting is paused because a list you could not read
cannot be checked for duplicates or seats; ready: the rows.
`status` is the authority — a payload that says empty while carrying rows
renders empty, and loading / error never render the meter or the compose row.
- Invite link row: a select-all <code> plus a Copy button. A rejected clipboard
write (insecure context, denied permission) reports failure and tells the user
to select and press Ctrl/Cmd+C — never a silent "Copied". The async write can
land after unmount, so it checks a mounted ref before arming its reset timer.
- One polite sr-only role="status" region announces every result (added,
skipped, sent, resent, revoking with the undo window, kept, copied, and the
reason a blocked Send did nothing) and is cleared on a timer, so the next
identical message is announced again.
- Cleanup: on unmount clear the announcement timer, the copy timer and every
per-row cooldown timer, and flush every parked revoke. Optimistic marks are
keyed by invite id and only read for rows the data still carries, so nothing
needs pruning when invites[] changes.
Keyboard map
- Field: Enter commits the typed text, or sends when the field is empty;
, and ; commit; Escape clears the uncommitted text; Backspace on an empty
field UN-COMMITS the last chip back into the input (editable, not destroyed).
- Tab reaches each chip's role menu and remove button, then the field, then
"Invite as", then Send — DOM order is the reading order.
- Role menus are shadcn/Radix DropdownMenu radio groups: arrows, typeahead and
Escape come from Radix.
Rendering & styling
- Semantic tokens only: bg-card panel, border / divide-y separators, bg-muted
chips and skeletons, bg-primary + /50 + /25 for the three meter segments,
border-primary/30 + bg-primary/10 + text-primary for Pending, destructive
tints for rejected chips, Revoked and the over-allocation line, border-dashed
for Expired. No hex, no rgb().
- Addresses use wrap-anywhere in rows and break-all in chips: overflow-wrap
alone wraps glyphs without shrinking the element's min-content contribution,
so one long unbroken address still forces a phone viewport to scroll.
- Only the meter preview animates (transition-[width]) and it is disabled under
motion-reduce; skeletons pulse with motion-reduce:animate-none. Nothing
depends on animation to function.
- cn() merges className everywhere; every control has focus-visible:ring-2
ring-ring; decorative icons are aria-hidden.
Customization levers
- Seat model: drop `seats` to null for an uncapped plan (the meter disappears
and the "no seat" branch never fires), or delete seatFree to make every role
billable. The maths lives in one place — reserved / remaining / overBy.
- Friction: undoRevokeMs 0 plus a confirm dialog turns the undo window into a
classic modal confirm; raise resendCooldownMs to match your provider's rate
limit; expiresAfterDays 0 disables derived expiry entirely.
- Density: heading and description accept null for a bare panel; the meter, the
link row and the compose row are independent blocks that can be reordered or
dropped without touching the state machines.
- Batch size: maxRecipients guards the DOM and your API; the truncation message
is derived from it, so changing the number changes the copy.
- Roles: role descriptions are optional and the menu grows to fit; add a locked
/ not-assignable role by filtering it out of `roles` and rendering it as read
only in the row pill instead.
- Copy: every literal (empty, error, link warning, problem reasons) is a single
string meant to be replaced by your voice or an i18n lookup — PROBLEM_TEXT is
one record with five entries.Concepts
- Paste-a-list, not one-at-a-time — the field's real input is a column someone copied out of a spreadsheet or an email client. Records split on commas, semicolons, tabs and new lines;
Ada Lovelace <[email protected]>yields one address, not three tokens; and a paste bigger than the cap says how many it dropped instead of quietly keeping the first fifty. - Every rejection carries its reason — an address is refused for one of five different reasons (
invalid,duplicate,invited,member,no-seat) and each is a different fix. The chip states which one, so nobody has to guess why Send is not sending; the seat overflow even lands on the exact address that ran out of budget, because the budget is spent while walking the list in order. - The seat budget is derived, never stored —
seats.usedcounts members, pending invitations are counted from the rows on screen, drafts are previewed as a third bar segment, and a revoke inside its undo window has already released its seat. Two stored counters drift apart the first time an invite expires; one derivation cannot. - Seat-free roles — guest and viewer tiers cost nothing, so a full workspace can still invite them. That single flag is why the meter and the "no seat" rejection cannot be a plain
invites.lengthcomparison. - Undo window instead of a confirm modal — revoking a pending invitation is low-stakes and reversible, so the panel parks the action for a few seconds behind an Undo button rather than interrupting with a dialog. The callback fires when the window closes — and is flushed if the panel unmounts, because leaving the page is not the same as taking it back.
- aria-disabled with a handler guard — Send and Resend never take the native
disabledattribute. Natively disabling Send would swallow the click that follows the field's blur-commit (losing a just-typed address), and a natively disabled Resend would blur itself out from under the keyboard user who is standing on it.