{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "invite-members",
  "title": "Invite Members",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "Invite teammates by email: type or paste addresses into chips, flag invalid or duplicate ones with an explanation, pick a role, send, and manage pending invitations.",
  "dependencies": [
    "motion@^12",
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/motion-presets.json",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/invite-members.tsx",
      "content": "// Ballmac UI: Invite Members. https://ui.ballmac.com/components/invite-members\n\"use client\";\n\nimport * as React from \"react\";\nimport { AlertCircle, ChevronDown, LoaderCircle, Mail, RotateCw, Send, X } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { spring } from \"@/lib/ballmac/motion\";\nimport { cn } from \"@/lib/utils\";\nimport { useMessages } from \"@/lib/ballmac/i18n\";\n\ntype InviteRole = {\n  /** Value sent to `onInvite`. */\n  value: string;\n  /** Name shown in the list. */\n  label: string;\n};\n\ntype PendingInvite = {\n  email: string;\n  /** Role value. */\n  role: string;\n  /** Text such as \"Sent 2 days ago\". */\n  sent?: string;\n};\n\ntype Invitation = { email: string; role: string };\n\ntype InviteMembersProps = Omit<React.ComponentProps<\"section\">, \"title\" | \"onSubmit\"> & {\n  /** Roles people can be invited as. */\n  roles: InviteRole[];\n  /** Role selected at first. Defaults to the first role. */\n  defaultRole?: string;\n  /** Called with the valid invitations. Return a promise to show a sending state. */\n  onInvite?: (invitations: Invitation[]) => void | Promise<void>;\n  /** Invitations that were sent and have not been accepted. */\n  pending?: PendingInvite[];\n  /** Adds a Resend button on each pending invitation. */\n  onResend?: (email: string) => void;\n  /** Adds a Revoke button on each pending invitation. */\n  onRevoke?: (email: string) => void;\n  /** Emails that already belong to the workspace; they are flagged instead of invited. */\n  existing?: string[];\n  /** Heading. */\n  title?: string;\n  /** Text under the heading. */\n  description?: string;\n  /** Most invitations per batch. */\n  max?: number;\n};\n\nconst EMAIL = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n\n/**\n * Invite teammates by email. Type or paste addresses (separated by Enter, comma, space or semicolon) and they become\n * chips; invalid or duplicate ones are flagged with an icon and an explanation, not just color. Pending invitations\n * can be resent or revoked.\n */\nfunction InviteMembers({\n  roles,\n  defaultRole,\n  onInvite,\n  pending = [],\n  onResend,\n  onRevoke,\n  existing = [],\n  title,\n  description,\n  max = 20,\n  className,\n  ...props\n}: InviteMembersProps) {\n  const msg = useMessages()\n  title ??= msg(\"invite-members.title\", \"Invite teammates\")\n  description ??= msg(\"invite-members.description\", \"They will get an email with a link to join.\")\n  const reduce = useReducedMotion();\n  const inputId = React.useId();\n  const helpId = React.useId();\n  const inputRef = React.useRef<HTMLInputElement>(null);\n  const [chips, setChips] = React.useState<string[]>([]);\n  const [draft, setDraft] = React.useState(\"\");\n  const [role, setRole] = React.useState(defaultRole ?? roles[0]?.value ?? \"\");\n  const [sending, setSending] = React.useState(false);\n  const [message, setMessage] = React.useState(\"\");\n\n  const lowerExisting = new Set([...existing, ...pending.map((p) => p.email)].map((e) => e.toLowerCase()));\n  function problem(email: string, index: number) {\n    if (!EMAIL.test(email)) return \"Not a valid email address\";\n    if (lowerExisting.has(email.toLowerCase())) return \"Already invited or a member\";\n    if (chips.findIndex((c) => c.toLowerCase() === email.toLowerCase()) !== index) return \"Listed twice\";\n    return null;\n  }\n  const valid = chips.filter((c, i) => !problem(c, i));\n\n  function commit(text: string) {\n    const parts = text.split(/[\\s,;]+/).map((p) => p.trim()).filter(Boolean);\n    if (!parts.length) return;\n    setChips((prev) => [...prev, ...parts].slice(0, max));\n    setDraft(\"\");\n  }\n\n  async function send() {\n    if (!valid.length || sending) return;\n    setSending(true);\n    try {\n      await onInvite?.(valid.map((email) => ({ email, role })));\n      setMessage(`${valid.length} invitation${valid.length === 1 ? \"\" : \"s\"} sent`);\n      setChips([]);\n    } finally {\n      setSending(false);\n    }\n  }\n\n  return (\n    <section\n      data-slot=\"invite-members\"\n      aria-label={title}\n      className={cn(\n        \"w-full overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-[0_1px_2px_rgb(0_0_0/0.04),0_12px_32px_-16px_rgb(0_0_0/0.14)]\",\n        className,\n      )}\n      {...props}\n    >\n      <div className=\"grid gap-5 p-5 sm:p-6\">\n        <div className=\"flex items-start gap-3\">\n          <span className=\"flex size-10 shrink-0 items-center justify-center rounded-xl bg-chart-1/12 text-chart-1\">\n            <Mail aria-hidden=\"true\" className=\"size-5\" />\n          </span>\n          <div>\n            <h3 className=\"text-base font-semibold tracking-tight\">{title}</h3>\n            <p className=\"text-sm text-muted-foreground\">{description}</p>\n          </div>\n        </div>\n\n        <div className=\"grid gap-2\">\n          <label htmlFor={inputId} className=\"text-sm font-medium\">\n            {msg(\"invite-members.emailAddresses\", \"Email addresses\")}\n          </label>\n          <div\n            onClick={() => inputRef.current?.focus()}\n            className=\"flex min-h-11 cursor-text flex-wrap items-center gap-1.5 rounded-xl border border-input bg-background p-1.5 shadow-xs transition-[border-color,box-shadow] focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 dark:bg-input/30\"\n          >\n            <AnimatePresence initial={false}>\n              {chips.map((chip, index) => {\n                const issue = problem(chip, index);\n                return (\n                  <motion.span\n                    key={`${chip}-${index}`}\n                    layout={!reduce}\n                    initial={reduce ? false : { opacity: 0, scale: 0.85 }}\n                    animate={{ opacity: 1, scale: 1 }}\n                    exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.85 }}\n                    transition={spring.snappy}\n                    title={issue ?? undefined}\n                    className={cn(\n                      \"inline-flex max-w-full items-center gap-1 rounded-lg py-1 pe-1 ps-2.5 text-[13px] font-medium\",\n                      issue ? \"bg-destructive/10 text-foreground ring-1 ring-destructive/40\" : \"bg-muted text-foreground\",\n                    )}\n                  >\n                    {issue && <AlertCircle aria-hidden=\"true\" className=\"size-3.5 shrink-0 text-destructive\" />}\n                    <span className=\"truncate\">{chip}</span>\n                    {issue && <span className=\"sr-only\">: {issue}</span>}\n                    <button\n                      type=\"button\"\n                      aria-label={msg(\"invite-members.remove\", \"Remove {chip}\", { chip })}\n                      onClick={(e) => {\n                        e.stopPropagation();\n                        setChips((prev) => prev.filter((_, i) => i !== index));\n                        inputRef.current?.focus();\n                      }}\n                      className=\"inline-flex size-5 shrink-0 items-center justify-center rounded-md opacity-70 outline-none transition hover:bg-foreground/10 hover:opacity-100 focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n                    >\n                      <X aria-hidden=\"true\" className=\"size-3\" />\n                    </button>\n                  </motion.span>\n                );\n              })}\n            </AnimatePresence>\n            <input\n              ref={inputRef}\n              id={inputId}\n              type=\"email\"\n              inputMode=\"email\"\n              autoComplete=\"off\"\n              value={draft}\n              placeholder={chips.length ? \"\" : msg(\"invite-members.nameCompanyCom\", \"name@company.com\")}\n              aria-describedby={helpId}\n              onChange={(e) => {\n                const v = e.target.value;\n                if (/[,;\\s]$/.test(v)) commit(v);\n                else setDraft(v);\n              }}\n              onKeyDown={(e) => {\n                if (e.key === \"Enter\") {\n                  if (draft.trim()) {\n                    e.preventDefault();\n                    commit(draft);\n                  } else if (valid.length) {\n                    e.preventDefault();\n                    void send();\n                  }\n                } else if (e.key === \"Backspace\" && !draft && chips.length) {\n                  setChips((prev) => prev.slice(0, -1));\n                }\n              }}\n              onBlur={() => commit(draft)}\n              onPaste={(e) => {\n                const text = e.clipboardData.getData(\"text\");\n                if (/[\\s,;]/.test(text.trim())) {\n                  e.preventDefault();\n                  commit(draft + \" \" + text);\n                }\n              }}\n              className=\"h-8 min-w-40 flex-1 bg-transparent px-2 text-sm outline-none placeholder:text-muted-foreground\"\n            />\n          </div>\n          <p id={helpId} className=\"text-xs text-muted-foreground\">\n            {msg(\"invite-members.separateAddressesWithAComma\", \"Separate addresses with a comma, space or Enter. You can paste a list.\")}\n          </p>\n        </div>\n\n        <div className=\"flex flex-wrap items-end gap-3\">\n          <div className=\"grid gap-1.5\">\n            <label htmlFor={`${inputId}-role`} className=\"text-sm font-medium\">\n              {msg(\"invite-members.role\", \"Role\")}\n            </label>\n            <div className=\"relative\">\n              <select\n                id={`${inputId}-role`}\n                value={role}\n                onChange={(e) => setRole(e.target.value)}\n                className=\"h-9 w-44 cursor-pointer appearance-none rounded-md border border-input bg-background pe-8 ps-3 text-sm shadow-xs outline-none transition-[border-color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-input/30\"\n              >\n                {roles.map((r) => (\n                  <option key={r.value} value={r.value}>\n                    {r.label}\n                  </option>\n                ))}\n              </select>\n              <ChevronDown aria-hidden=\"true\" className=\"pointer-events-none absolute top-1/2 end-2.5 size-4 -translate-y-1/2 text-muted-foreground\" />\n            </div>\n          </div>\n          <button\n            type=\"button\"\n            onClick={() => void send()}\n            disabled={!valid.length || sending}\n            className=\"ms-auto inline-flex h-9 items-center gap-2 rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow-xs outline-none transition-colors hover:bg-primary/90 focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50\"\n          >\n            {sending ? (\n              <LoaderCircle aria-hidden=\"true\" className=\"size-4 animate-spin motion-reduce:animate-none\" />\n            ) : (\n              <Send aria-hidden=\"true\" className=\"size-4 rtl:-scale-x-100\" />\n            )}\n            {valid.length ? `Send ${valid.length} invitation${valid.length === 1 ? \"\" : \"s\"}` : \"Send invitations\"}\n          </button>\n        </div>\n        <p role=\"status\" className=\"min-h-4 text-sm font-medium\">\n          {message}\n        </p>\n      </div>\n\n      {pending.length > 0 && (\n        <div className=\"border-t bg-muted/30 px-2 py-3\">\n          <h4 className=\"px-4 pb-2 text-xs font-medium tracking-wide text-muted-foreground uppercase\">{msg(\"invite-members.pending\", \"Pending ({count})\", { count: pending.length })}</h4>\n          <ul>\n            {pending.map((p) => (\n              <li key={p.email} className=\"flex items-center gap-3 rounded-lg px-4 py-2 hover:bg-muted/60\">\n                <span aria-hidden=\"true\" className=\"flex size-8 shrink-0 items-center justify-center rounded-full bg-background text-xs font-semibold uppercase ring-1 ring-border\">\n                  {p.email.charAt(0)}\n                </span>\n                <span className=\"min-w-0 flex-1\">\n                  <span className=\"block truncate text-sm font-medium\">{p.email}</span>\n                  <span className=\"block text-xs text-muted-foreground\">\n                    {roles.find((r) => r.value === p.role)?.label ?? p.role}\n                    {p.sent ? ` · ${p.sent}` : \"\"}\n                  </span>\n                </span>\n                {onResend && (\n                  <button\n                    type=\"button\"\n                    onClick={() => onResend(p.email)}\n                    aria-label={msg(\"invite-members.resendInvitationTo\", \"Resend invitation to {email}\", { email: p.email })}\n                    className=\"inline-flex size-8 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-accent hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n                  >\n                    <RotateCw aria-hidden=\"true\" className=\"size-4\" />\n                  </button>\n                )}\n                {onRevoke && (\n                  <button\n                    type=\"button\"\n                    onClick={() => onRevoke(p.email)}\n                    aria-label={msg(\"invite-members.revokeInvitationFor\", \"Revoke invitation for {email}\", { email: p.email })}\n                    className=\"inline-flex size-8 items-center justify-center rounded-md text-muted-foreground outline-none transition-colors hover:bg-destructive/10 hover:text-destructive focus-visible:ring-[3px] focus-visible:ring-ring/50\"\n                  >\n                    <X aria-hidden=\"true\" className=\"size-4\" />\n                  </button>\n                )}\n              </li>\n            ))}\n          </ul>\n        </div>\n      )}\n    </section>\n  );\n}\n\nexport { InviteMembers, type InviteMembersProps, type InviteRole, type PendingInvite, type Invitation };\n",
      "type": "registry:ui",
      "target": "@components/ballmac/invite-members.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "invite",
      "team",
      "email",
      "roles"
    ],
    "version": "1.0.0",
    "updated": "2026-09-30",
    "ai": {
      "summary": "roles[], onInvite(invitations), pending[], onResend/onRevoke, existing[]. Addresses split on comma, space, semicolon or Enter and validate as you go.",
      "whenToUse": [
        "Team and workspace settings",
        "Onboarding step for inviting colleagues"
      ],
      "whenNotToUse": [
        "Searching existing users; use combobox",
        "Single email field; use input"
      ],
      "composesWith": [
        "team-switcher",
        "settings-panel",
        "avatar"
      ],
      "a11y": [
        {
          "keys": "Enter / comma / space",
          "action": "Turns typed text into a chip"
        },
        {
          "keys": "Backspace",
          "action": "Removes the last chip when the field is empty"
        },
        {
          "keys": "Screen readers",
          "action": "Invalid chips announce the problem; results are a polite status"
        }
      ],
      "customization": [
        "roles and defaultRole",
        "existing emails",
        "max per batch",
        "title and description"
      ]
    },
    "examples": [
      "invite-members-demo",
      "invite-members-states"
    ],
    "url": "https://ui.ballmac.com/components/invite-members"
  },
  "categories": [
    "saas"
  ],
  "type": "registry:ui"
}