Skip to content
Product block

Team Invite Step

The onboarding step where a product becomes multiplayer: pasted addresses split into chips, typos marked rather than dropped, and an honest way to skip.

279 lineslucide-reactAdded 25 Aug 2026Updated 10 Sept 2026
  • onboarding
  • invite
  • team
  • chips
  • email

What's included

  • components/team-invite-step.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add team-invite-step

Or over MCP, from your editor's agent — no account needed.

License

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.

Was this useful?

Start a page with this section — add more, order them, and leave with the page source.

Preview

Step 3 of 4

Bring your team in

Paste as many addresses as you like — commas, spaces or one per line all work.

priya@northwind.com

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.

Source

components/team-invite-step.tsx
'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>
  )
}

Before you paste

  • Styling is Tailwind utility classes on semantic tokens (bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.
  • Install: npm i lucide-react
  • Every prop has a default, so it renders standalone before you wire it up.

Where it goes

Drop it at components/team-invite-step.tsx and import it where you need the section:

import { TeamInviteStep } from '@/components/team-invite-step'

Customize

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.

Props

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.

PropTypeDefault
headingstring'Bring your team in'
descriptionstring'Paste as many addresses as you like — commas…
workspaceNamestring'Northwind'
roles{ value: string; label: string; hint?: string }[]DEFAULT_ROLES
initialEmailsPrefilled addresses, e.g. from a detected email domain.string[]['priya@northwind.com']
inviteLinkstring'https://app.example.com/join/northwind-8f31c2'
onInvite(invites: { emails: string[]; role: string }) => void—
onSkip() => void—
classNamestring''

Not using React?

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.html
<!--
  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>
  • This is rendered HTML, not a translation of the React source. The Tailwind classes carry the design and work in any framework.
  • It is one frame: the component in its initial state, with no props applied beyond the defaults.
  • This block is interactive in React — toggles, menus or form state. None of that survives here; the markup is the closed/default state and the handlers are gone. Re-wire them in your own framework.
  • Requires Tailwind, and the design tokens the classes reference (bg-card, text-muted-foreground, and so on). The template ZIPs ship a globals.css that defines them.

What each framework gets across the whole catalog — effects convert properly; this rung is markup.

For AI

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.

See the prompt

Used in these pages

Want the whole screen instead of this one section? Open a page and copy it entire.

7 more blocks in Onboarding

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 Onboarding

Shipping one commercially

Copying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.

  • A commercial licence for everything in the catalog
  • Unlimited bundle exports, in Vue, Svelte and Tailwind
  • One payment — no subscription, nothing to renew
Pro — $79 once

More Onboarding blocks

View category
Open the full page for this block

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.

Onboarding230 lines1 dep
Open the full page for this block

Guided Setup Wizard

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.

Onboarding265 lines1 dep
Open the full page for this block

Product Tour Coach Mark

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.

Onboarding262 lines1 dep
Open the full page for this block

Role Picker

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.

Onboarding191 lines1 dep
Open the full page for this block

Import or Start Empty

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.

Onboarding216 lines1 dep
Open the full page for this block

Workspace Setup Form

The first screen after signup, asking only the two things that cannot be changed silently later.

Onboarding176 linesNo deps
Open the full page for this block

Data Migration Split

What a migration actually moves and what it cannot, stated before the switch rather than discovered after it.

Onboarding158 linesNo deps