Setup Checklist with Progress
The get-started card that ships with its first item already ticked, a native <progress> element rather than a div bar, and a dismiss control that appears only once there is nothing left to abandon.
The onboarding step where a product becomes multiplayer: pasted addresses split into chips, typos marked rather than dropped, and an honest way to skip.
components/team-invite-step.tsxnpx hoverlab add team-invite-step
Or over MCP, from your editor's agent — no account needed.
Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.
Start a page with this section — add more, order them, and leave with the page source.
Step 3 of 4
Paste as many addresses as you like — commas, spaces or one per line all work.
1 person will be invited to Northwind.
Can build and ship. The right default.
https://app.example.com/join/northwind-8f31c2
Invites expire after 14 days and can be revoked from Settings.
Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.
'use client'
/**
* <TeamInviteStep> — the onboarding step where a product becomes multiplayer.
*
* <OnboardingChecklist> tracks what is left and <SetupWizard> walks a single
* person through configuration. Neither covers the step with the highest
* retention value in most B2B products: getting a second person into the
* workspace. A tool one person uses is a trial; a tool three people use is
* a purchase decision.
*
* WHY CHIPS AND NOT A LIST OF FIELDS
*
* The realistic input is a paste — three addresses out of a Slack message,
* comma-separated, or a column out of a spreadsheet separated by newlines.
* A form with three `<input type="email">` rows makes that person edit their
* own clipboard. Splitting on commas, semicolons, spaces and newlines takes
* one line and turns the paste into the whole task.
*
* Enter, comma and blur all commit, because people expect different ones
* and none of them is wrong.
*
* INVALID ADDRESSES ARE KEPT, NOT REJECTED
*
* A typo'd address becomes a chip marked invalid rather than being dropped
* or blocking the paste. Dropping it silently loses somebody who was meant
* to be invited; blocking the whole paste for one typo makes the user redo
* the good ones. Marked, editable by removal, and excluded from the count —
* the invite button says how many will actually be sent.
*
* THE ROLE SELECT IS PER-INVITE-BATCH, NOT PER-PERSON
*
* Per-person roles are a settings-screen problem. At this moment everyone
* being pasted in is usually the same kind of colleague, and a role column
* on every chip triples the width for a distinction most teams make later.
*
* SKIPPING IS A REAL OPTION AND LOOKS LIKE ONE
*
* A greyed-out "skip" that a user has to hunt for produces invites to
* abandoned addresses and a worse activation number than an honest skip.
* People working alone are a legitimate case rather than a funnel leak.
*/
import * as React from 'react'
import { AlertCircle, Link2, Mail, UserPlus, X } from 'lucide-react'
export interface TeamInviteStepProps {
heading?: string
description?: string
workspaceName?: string
roles?: { value: string; label: string; hint?: string }[]
/** Prefilled addresses, e.g. from a detected email domain. */
initialEmails?: string[]
inviteLink?: string
onInvite?: (invites: { emails: string[]; role: string }) => void
onSkip?: () => void
className?: string
}
const DEFAULT_ROLES = [
{ value: 'member', label: 'Member', hint: 'Can build and ship. The right default.' },
{ value: 'admin', label: 'Admin', hint: 'Also manages billing and people.' },
{ value: 'viewer', label: 'Viewer', hint: 'Read-only. For stakeholders.' },
]
/*
Deliberately loose, and the same rule the newsletter endpoint uses. A
regex strict enough to reject every invalid address rejects valid ones
too — plus-addressing, new TLDs, unicode domains — and the only real
validation of an address is sending to it. This catches what is
obviously not an address so a typo becomes visible before send.
*/
function looksLikeEmail(value: string): boolean {
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
}
export function TeamInviteStep({
heading = 'Bring your team in',
description = 'Paste as many addresses as you like — commas, spaces or one per line all work.',
workspaceName = 'Northwind',
roles = DEFAULT_ROLES,
initialEmails = ['priya@northwind.com'],
inviteLink = 'https://app.example.com/join/northwind-8f31c2',
onInvite,
onSkip,
className = '',
}: TeamInviteStepProps) {
/*
Per-instance prefix for every id this block emits.
The literals these replaced were a latent duplicate the moment the
block appeared twice on one document, and `aria-labelledby` on a
duplicated id resolves to the first match -- so the second copy was
labelled by the first copy's heading. Client component, so `useId` is
the right tool.
*/
const uid = React.useId()
const [emails, setEmails] = React.useState<string[]>(initialEmails)
const [draft, setDraft] = React.useState('')
const [role, setRole] = React.useState(roles[0]?.value ?? 'member')
const [copied, setCopied] = React.useState(false)
/** Split a paste on every separator people actually use, and keep order. */
function commit(raw: string) {
const parts = raw
.split(/[\s,;]+/)
.map((part) => part.trim())
.filter(Boolean)
if (parts.length === 0) return
setEmails((current) => [...current, ...parts.filter((p) => !current.includes(p))])
setDraft('')
}
function handleKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
if (event.key === 'Enter' || event.key === ',') {
event.preventDefault()
commit(draft)
return
}
/* Backspace on an empty field removes the last chip — the behaviour
every chip input has, and its absence is felt immediately. */
if (event.key === 'Backspace' && draft === '') {
setEmails((current) => current.slice(0, -1))
}
}
const valid = emails.filter(looksLikeEmail)
const invalid = emails.filter((email) => !looksLikeEmail(email))
const activeRole = roles.find((r) => r.value === role)
return (
<section
aria-labelledby={`${uid}-team-invite-heading`}
className={`mx-auto w-full max-w-2xl px-4 py-16 sm:px-6 lg:px-8 ${className}`}
>
<div className="rounded-2xl border border-border bg-card p-6 sm:p-8">
<p className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Step 3 of 4
</p>
<h2 id={`${uid}-team-invite-heading`} className="mt-2 text-xl font-bold tracking-tight text-foreground">
{heading}
</h2>
<p className="mt-1.5 text-sm text-muted-foreground">{description}</p>
<div className="mt-6">
<label htmlFor={`${uid}-invite-emails`} className="text-sm font-medium text-foreground">
Email addresses
</label>
{/* The chips and the input share one bordered box so it reads as a
single field, and clicking anywhere in it focuses the input. */}
<div className="mt-2 flex flex-wrap items-center gap-1.5 rounded-xl border border-field bg-background p-2 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background">
{emails.map((email) => {
const ok = looksLikeEmail(email)
return (
<span
key={email}
className={`inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-sm ${
ok
? 'bg-muted text-foreground'
: 'bg-destructive/10 text-destructive ring-1 ring-inset ring-destructive/30'
}`}
>
{!ok ? <AlertCircle aria-hidden className="h-3.5 w-3.5" /> : null}
{email}
<button
type="button"
onClick={() => setEmails((c) => c.filter((e) => e !== email))}
className="-me-0.5 rounded p-0.5 opacity-60 transition hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<X aria-hidden className="h-3.5 w-3.5" />
<span className="sr-only">Remove {email}</span>
</button>
</span>
)
})}
<input
id={`${uid}-invite-emails`}
type="text"
inputMode="email"
value={draft}
onChange={(event) => {
/* A paste containing a separator commits immediately rather
than waiting for Enter, which is what people expect when
they drop in a whole column. */
if (/[\s,;]/.test(event.target.value)) commit(event.target.value)
else setDraft(event.target.value)
}}
onKeyDown={handleKeyDown}
onBlur={() => commit(draft)}
placeholder={emails.length ? 'Add another…' : 'name@company.com'}
className="h-8 min-w-[12rem] flex-1 bg-transparent px-1 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none border border-transparent"
/>
</div>
<p role="status" className="mt-2 text-xs text-muted-foreground">
{invalid.length > 0
? `${invalid.length} of these ${invalid.length === 1 ? 'does not look like an address' : 'do not look like addresses'} — remove or fix ${invalid.length === 1 ? 'it' : 'them'} and the rest still send.`
: valid.length > 0
? `${valid.length} ${valid.length === 1 ? 'person' : 'people'} will be invited to ${workspaceName}.`
: 'Nobody added yet.'}
</p>
</div>
<div className="mt-5">
<label htmlFor={`${uid}-invite-role`} className="text-sm font-medium text-foreground">
Invite everyone as
</label>
<select
id={`${uid}-invite-role`}
value={role}
onChange={(event) => setRole(event.target.value)}
className="mt-2 h-9 w-full rounded-lg border border-field bg-background px-3 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background sm:w-64"
>
{roles.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{activeRole?.hint ? (
<p className="mt-1.5 text-xs text-muted-foreground">{activeRole.hint}</p>
) : null}
</div>
<div className="mt-6 flex flex-wrap items-center gap-3">
<button
type="button"
disabled={valid.length === 0}
onClick={() => onInvite?.({ emails: valid, role })}
className="inline-flex h-9 items-center gap-1.5 rounded-lg bg-primary px-4 text-sm font-semibold text-primary-foreground transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50"
>
<UserPlus aria-hidden className="h-4 w-4" />
{valid.length > 0
? `Send ${valid.length} invite${valid.length === 1 ? '' : 's'}`
: 'Send invites'}
</button>
{/* An honest skip. See the note at the top. */}
<button
type="button"
onClick={onSkip}
className="inline-flex h-9 items-center rounded-lg px-3 text-sm font-medium text-muted-foreground transition hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
I work alone for now
</button>
</div>
{/* The other way in, for anyone whose colleagues' addresses they do
not actually know — which is most people in a large company. */}
<div className="mt-6 flex items-center gap-3 rounded-xl border border-dashed border-border px-4 py-3">
<Link2 aria-hidden className="h-4 w-4 shrink-0 text-muted-foreground" />
<p className="min-w-0 flex-1 break-all font-mono text-xs text-muted-foreground">
{inviteLink}
</p>
<button
type="button"
onClick={() => {
void navigator.clipboard?.writeText(inviteLink)
setCopied(true)
window.setTimeout(() => setCopied(false), 2000)
}}
className="shrink-0 text-xs font-medium text-foreground underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
{copied ? 'Copied' : 'Copy link'}
</button>
</div>
<p className="mt-3 flex items-center gap-1.5 text-xs text-muted-foreground">
<Mail aria-hidden className="h-3.5 w-3.5" />
Invites expire after 14 days and can be revoked from Settings.
</p>
</div>
</section>
)
}
bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.Drop it at components/team-invite-step.tsx and import it where you need the section:
import { TeamInviteStep } from '@/components/team-invite-step'4 of this block’s props are simple enough to drive from here. Change them and the block below re-renders — it is the same component whose source is above, not a mock of it. Everything else it accepts is in the table underneath.
Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.
| Prop | Type | Default |
|---|---|---|
heading | string | 'Bring your team in' |
description | string | 'Paste as many addresses as you like — commas… |
workspaceName | string | 'Northwind' |
roles | { value: string; label: string; hint?: string }[] | DEFAULT_ROLES |
initialEmailsPrefilled addresses, e.g. from a detected email domain. | string[] | ['priya@northwind.com'] |
inviteLink | string | 'https://app.example.com/join/northwind-8f31c2' |
onInvite | (invites: { emails: string[]; role: string }) => void | — |
onSkip | () => void | — |
className | string | '' |
The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.
This block is interactive. The markup below is its initial state with the event handlers stripped — you will need to re-wire the behaviour in your framework.
<!--
Team Invite Step — markup from the Hoverlab catalog.
This is the block rendered once to HTML and wrapped as a component
file. It is not a port of the React source: the Tailwind classes carry
the design, which is the part that took the work, and they are the same
in every framework.
This block is interactive in React and the handlers are NOT here.
Buttons, toggles and menus render in their initial state and do
nothing until you wire them up.
-->
<section aria-labelledby="_R_0_-team-invite-heading" class="mx-auto w-full max-w-2xl px-4 py-16 sm:px-6 lg:px-8 ">
<div class="rounded-2xl border border-border bg-card p-6 sm:p-8">
<p class="text-xs font-semibold uppercase tracking-wider text-muted-foreground">Step 3 of 4</p>
<h2 id="_R_0_-team-invite-heading" class="mt-2 text-xl font-bold tracking-tight text-foreground">Bring your team in</h2>
<p class="mt-1.5 text-sm text-muted-foreground">Paste as many addresses as you like — commas, spaces or one per line all work.</p>
<div class="mt-6">
<label for="_R_0_-invite-emails" class="text-sm font-medium text-foreground">Email addresses</label>
<div class="mt-2 flex flex-wrap items-center gap-1.5 rounded-xl border border-field bg-background p-2 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background">
<span class="inline-flex items-center gap-1.5 rounded-lg px-2 py-1 text-sm bg-muted text-foreground">priya@northwind.com<button type="button" class="-me-0.5 rounded p-0.5 opacity-60 transition hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x h-3.5 w-3.5" aria-hidden="true"><path d="M18 6 6 18"></path><path d="m6 6 12 12"></path></svg><span class="sr-only">Remove priya@northwind.com</span></button></span>
<input id="_R_0_-invite-emails" type="text" inputMode="email" placeholder="Add another…" class="h-8 min-w-[12rem] flex-1 bg-transparent px-1 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none border border-transparent" value="" />
</div>
<p role="status" class="mt-2 text-xs text-muted-foreground">1 person will be invited to Northwind.</p>
</div>
<div class="mt-5">
<label for="_R_0_-invite-role" class="text-sm font-medium text-foreground">Invite everyone as</label>
<select id="_R_0_-invite-role" class="mt-2 h-9 w-full rounded-lg border border-field bg-background px-3 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background sm:w-64">
<option value="member" selected="">Member</option>
<option value="admin">Admin</option>
<option value="viewer">Viewer</option>
</select>
<p class="mt-1.5 text-xs text-muted-foreground">Can build and ship. The right default.</p>
</div>
<div class="mt-6 flex flex-wrap items-center gap-3">
<button type="button" class="inline-flex h-9 items-center gap-1.5 rounded-lg bg-primary px-4 text-sm font-semibold text-primary-foreground transition hover:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-user-plus h-4 w-4" aria-hidden="true"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><line x1="19" x2="19" y1="8" y2="14"></line><line x1="22" x2="16" y1="11" y2="11"></line></svg>Send 1 invite</button>
<button type="button" class="inline-flex h-9 items-center rounded-lg px-3 text-sm font-medium text-muted-foreground transition hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">I work alone for now</button>
</div>
<div class="mt-6 flex items-center gap-3 rounded-xl border border-dashed border-border px-4 py-3">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-link2 lucide-link-2 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true">
<path d="M9 17H7A5 5 0 0 1 7 7h2"></path>
<path d="M15 7h2a5 5 0 1 1 0 10h-2"></path>
<line x1="8" x2="16" y1="12" y2="12"></line>
</svg>
<p class="min-w-0 flex-1 break-all font-mono text-xs text-muted-foreground">https://app.example.com/join/northwind-8f31c2</p>
<button type="button" class="shrink-0 text-xs font-medium text-foreground underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">Copy link</button>
</div>
<p class="mt-3 flex items-center gap-1.5 text-xs text-muted-foreground"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-mail h-3.5 w-3.5" aria-hidden="true"><path d="m22 7-8.991 5.727a2 2 0 0 1-2.009 0L2 7"></path><rect x="2" y="4" width="20" height="16" rx="2"></rect></svg>Invites expire after 14 days and can be revoked from Settings.</p>
</div>
</section>
What each framework gets across the whole catalog — effects convert properly; this rung is markup.
The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.
Want the whole screen instead of this one section? Open a page and copy it entire.
All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.
Browse OnboardingCopying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.
The get-started card that ships with its first item already ticked, a native <progress> element rather than a div bar, and a dismiss control that appears only once there is nothing left to abandon.
A choice-driven wizard with a persistent side rail, real radio groups in a fieldset so arrow keys work, and visited steps navigable while steps ahead stay out of the tab order.
A tour step anchored to the control it explains, with a spotlight cut by one spread shadow, a visible step count and focus that follows the step.
The "what best describes you" step, asked honestly — every option states what it actually changes, the skip is visible and names what you get instead, and the choice is reversible.
The empty product’s real first question. Sample data is offered with equal weight rather than as grey small print, every connector names its scope before the redirect, and each option carries the honest duration.
The first screen after signup, asking only the two things that cannot be changed silently later.
What a migration actually moves and what it cannot, stated before the switch rather than discovered after it.