Invite Members
Paste or type emails into chips, pick a role, and send; invalid addresses are flagged and sent invites list below.
$
View as Markdownnpx shadcn@latest add https://sundaeui.dev/r/invite-members.json'use client'
import { useRef, useState } from 'react'
import './invite-members.css'
export interface InviteMembersProps {
roles: string[]
onInvite?: (emails: string[], role: string) => Promise<void>
seatsLeft?: number
fill?: 'dark' | 'light'
className?: string
}
const valid = (e: string) => /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(e)
/** Email chips + role + send, with a pending list. */
export function InviteMembers({ roles, onInvite, seatsLeft, fill = 'dark', className }: InviteMembersProps) {
const [emails, setEmails] = useState<string[]>([])
const [text, setText] = useState('')
const [role, setRole] = useState(roles[0])
const [busy, setBusy] = useState(false)
const [sent, setSent] = useState<{ email: string; role: string }[]>([])
const input = useRef<HTMLInputElement>(null)
const add = (raw: string) => {
const parts = raw.split(/[\s,;]+/).map((s) => s.trim().toLowerCase()).filter(Boolean)
if (parts.length) setEmails((e) => [...new Set([...e, ...parts])])
setText('')
}
const bad = emails.filter((e) => !valid(e))
const over = seatsLeft !== undefined && emails.length > seatsLeft
const send = async () => {
setBusy(true)
await (onInvite ? onInvite(emails, role) : new Promise<void>((r) => setTimeout(r, 700)))
setSent((s) => [...emails.map((email) => ({ email, role })), ...s]); setEmails([]); setBusy(false)
}
return (
<div className={['sd-inv', className].filter(Boolean).join(' ')} data-fill={fill}>
<div className="sd-inv__h">Invite your team</div>
<div className="sd-inv__s">Paste a list or type emails separated by commas.</div>
<div className="sd-inv__box" onClick={() => input.current?.focus()}>
{emails.map((e) => (
<span key={e} className="sd-inv__chip" data-bad={!valid(e) || undefined}><span>{e}</span><button type="button" aria-label={`Remove ${e}`} onClick={() => setEmails(emails.filter((x) => x !== e))}>×</button></span>
))}
<input
ref={input}
className="sd-inv__in"
type="email"
aria-label="Email addresses"
placeholder={emails.length ? '' : 'name@company.com'}
value={text}
onChange={(e) => { const v = e.target.value; if (/[,;\s]$/.test(v)) add(v); else setText(v) }}
onPaste={(e) => { e.preventDefault(); add(text + e.clipboardData.getData('text')) }}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); add(text) } else if (e.key === 'Backspace' && !text && emails.length) setEmails(emails.slice(0, -1)) }}
onBlur={() => text && add(text)}
/>
</div>
{bad.length > 0 && <p className="sd-inv__err" role="alert">{bad.length === 1 ? `“${bad[0]}” isn’t a valid email` : `${bad.length} addresses aren’t valid`}</p>}
<div className="sd-inv__row">
<select className="sd-inv__role" aria-label="Role" value={role} onChange={(e) => setRole(e.target.value)}>{roles.map((r) => <option key={r}>{r}</option>)}</select>
<button type="button" className="sd-inv__send" disabled={!emails.length || bad.length > 0 || over || busy} onClick={send}>{busy ? 'Sending…' : `Invite${emails.length ? ` ${emails.length}` : ''}`}</button>
</div>
{seatsLeft !== undefined && <div className="sd-inv__seats">{over ? `Only ${seatsLeft} seats left on your plan` : `${seatsLeft - emails.length} of ${seatsLeft} seats remaining`}</div>}
{sent.length > 0 && (
<div className="sd-inv__pend" aria-live="polite">
<h4>Pending</h4>
{sent.map((s) => <div key={s.email} className="sd-inv__p"><span>{s.email}</span><span>{s.role}</span><button type="button" onClick={() => setSent(sent.filter((x) => x.email !== s.email))}>Revoke</button></div>)}
</div>
)}
</div>
)
}.sd-inv { --bg: #1c1c1c; --fg: #f6f6f6; --mute: #8e8e8e; --line: rgb(255 255 255 / 0.1); --soft: rgb(255 255 255 / 0.07); --strong: #fafafa; --on: #111;
color: var(--fg); font-size: 14px; line-height: 1.4; -webkit-tap-highlight-color: transparent; }
.sd-inv[data-fill='light'] { --bg: #fff; --fg: #121212; --mute: #6f6f6f; --line: rgb(0 0 0 / 0.09); --soft: rgb(0 0 0 / 0.05); --strong: #111; --on: #fff; }
.sd-inv button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
.sd-inv :focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.sd-inv .sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
.sd-inv { width: min(100%, 330px); padding: 16px; border-radius: 20px; background: var(--bg); box-shadow: inset 0 0 0 1px var(--line); }
.sd-inv__h { font-weight: 650; font-size: 15px; }
.sd-inv__s { font-size: 12.5px; color: var(--mute); margin: 2px 0 12px; }
.sd-inv__box { display: flex; flex-wrap: wrap; gap: 5px; min-height: 44px; padding: 6px; border-radius: 12px; box-shadow: inset 0 0 0 1px var(--line); cursor: text; }
.sd-inv__box:focus-within { box-shadow: inset 0 0 0 1.5px var(--fg); }
.sd-inv__chip { display: inline-flex; align-items: center; gap: 3px; max-width: 100%; height: 28px; padding: 0 3px 0 9px; border-radius: 8px; background: var(--soft); font-size: 12.5px; animation: sd-inv-pop 200ms cubic-bezier(0.3, 1.6, 0.4, 1); }
.sd-inv__chip span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sd-inv__chip[data-bad] { background: none; box-shadow: inset 0 0 0 1px var(--fg); text-decoration: underline wavy; text-underline-offset: 3px; }
.sd-inv__chip button { display: grid; place-items: center; width: 20px; height: 20px; border-radius: 5px; color: var(--mute); flex: none; }
.sd-inv__chip button:hover { color: var(--fg); }
@keyframes sd-inv-pop { from { transform: scale(0.7); opacity: 0; } }
.sd-inv__in { flex: 1; min-width: 120px; height: 28px; border: 0; outline: 0; background: none; color: var(--fg); font: inherit; font-size: 13.5px; padding: 0 4px; }
.sd-inv__in::placeholder { color: var(--mute); }
.sd-inv__err { margin: 6px 2px 0; font-size: 12px; }
.sd-inv__row { display: flex; gap: 6px; margin-top: 10px; }
.sd-inv__role { flex: 1; height: 40px; padding: 0 10px; border: 0; border-radius: 11px; background: var(--soft); color: var(--fg); font: inherit; font-size: 13.5px; }
.sd-inv__send { height: 40px; padding: 0 16px; border-radius: 11px; background: var(--strong) !important; color: var(--on) !important; font-weight: 600; font-size: 13.5px; }
.sd-inv__send:disabled { opacity: 0.35; cursor: default; }
.sd-inv__seats { margin-top: 8px; font-size: 12px; color: var(--mute); }
.sd-inv__pend { margin-top: 14px; padding-top: 10px; border-top: 1px solid var(--line); }
.sd-inv__pend h4 { margin: 0 0 4px; font-size: 11.5px; font-weight: 500; color: var(--mute); text-transform: uppercase; letter-spacing: 0.06em; }
.sd-inv__p { display: flex; align-items: center; gap: 8px; height: 34px; font-size: 13px; animation: sd-inv-in 260ms ease both; }
@keyframes sd-inv-in { from { opacity: 0; transform: translateY(4px); } }
.sd-inv__p span:first-child { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.sd-inv__p span:nth-child(2) { color: var(--mute); font-size: 12px; }
.sd-inv__p button { font-size: 12px; color: var(--mute); text-decoration: underline; }
@media (prefers-reduced-motion: reduce) { .sd-inv *, .sd-inv *::before, .sd-inv *::after { animation: none !important; transition: none !important; } }Overview
Typing a comma, space or Enter turns the text into a chip; pasting a list splits it into many. Invalid addresses become outlined chips with a warning until fixed or removed.
Send shows a short sending state, then the invites appear in a Pending list with their role and a Revoke action.
When to use
- Workspace onboarding, team settings, sharing dialogs.
When not to use
- Inviting one person: a single email field is enough.
Installation
$
pnpm dlx shadcn@latest add https://sundaeui.dev/r/invite-members.json$
npx shadcn@latest add https://sundaeui.dev/r/invite-members.json$
yarn dlx shadcn@latest add https://sundaeui.dev/r/invite-members.json$
bunx --bun shadcn@latest add https://sundaeui.dev/r/invite-members.jsonWorks in any React project. Files are written to components/sundae/. Read the source in the stage’s Code view.
- No dependencies beyond React 18 or later.
- Copy invite-members.tsx and invite-members.css into components/sundae/.
- The CSS file is imported by the component, so no extra configuration is needed.
- Import the component and use it as shown below.
Usage
import { InviteMembers } from '@/components/sundae/invite-members'
<InviteMembers />Props
| Prop | Type | Default |
|---|---|---|
roles*Roles to choose from. | string[] | — |
onInviteSend handler. | (emails: string[], role: string) => Promise<void> | — |
seatsLeftOptional seat limit. | number | — |
fillSurface: dark or light. | 'dark' | 'light' | 'dark' |
Accessibility
- The chip field is a labelled input; chips have remove buttons named with the address.
- Invalid addresses are listed in an alert; sent invites are announced.
- Reduced motion removes the movement and keeps every state change.
Customization
- Colours are CSS custom properties on .sd-inv; override them in your own stylesheet.
- fill="light" switches the built-in light palette.
Keyboard
- Enter / , / Space
- Add address
- Backspace
- Remove last address
FAQ
- Is Invite Members free?
- Yes. Invite Members is free for personal and commercial projects, with full source on this page.
- How do I install Invite Members?
- Run npx shadcn@latest add https://sundaeui.dev/r/invite-members.json. The files are written to components/sundae/. It has no dependencies beyond React 18 or later.
- Does Invite Members support keyboard and screen readers?
- The chip field is a labelled input; chips have remove buttons named with the address. Invalid addresses are listed in an alert; sent invites are announced.
- Does Invite Members respect reduced motion?
- Reduced motion removes the movement and keeps every state change.
More like this
All forms →Filter Chips
Forms · RefinedNotification Matrix
Forms · RefinedMulti Select
Forms · RefinedSize Picker
Forms · RefinedAssignee Picker
Forms · RefinedStandup Notes
Forms · Refined