# Team Invite

> An animated team invite flow for React and shadcn: emails become chips with inline validation, a role per person and an invite link, beside a members table that fills in.

An invite flow whose result fills in while you type. Each good email becomes a chip and lands as a row in the members table beside the form, under a tint that fades as it settles, with a Pending badge that turns to Joined when they accept. A bad address turns red with the reason under the field and goes back into the field to fix. Then a role per person or one for everyone, an optional link with copy, and a send that names who was invited.

- Tier: Eased Pro
- Interaction: Type
- Page: https://eased.dev/components/team-invite
- Installs: motion, @base-ui/react

## Install

Team Invite ships with Eased Pro (https://eased.dev/#pricing). Members add the `@eased-pro` registry with their token (https://eased.dev/account), then run:

```bash
npx shadcn add @eased-pro/team-invite
```

A three-step flow that invites people to a workspace (emails, roles and an optional link) beside the workspace's members table, which fills in as each good email is entered. Built on Onboarding Shell, the Onboarding core step engine, Field, Avatar, Badge, Segmented and Select.

## When to use

- Straight after a workspace is created: it takes the `Workspace` that [Workspace Setup](/components/workspace-setup) hands to `onInvite`.
- When people paste lists of addresses, and a bad one should be caught and fixed before anything is sent.
- When showing who is about to join, and with which role, is worth more than a bare textarea.

## When not to use

- Inviting one person from a settings page: a Field with an Input and a Select in a Dialog is enough.
- Managing people who are already members: a table with role Selects and a menu per row.
- A different set of questions: build your own flow on [Onboarding Shell](/components/onboarding-shell).

## Usage

```tsx example.tsx
import { TeamInvite } from "@/components/eased/team-invite/team-invite";

export function InviteTeam({ workspace }: { workspace: Workspace }) {
  const router = useRouter();
  const joined = useAcceptedInvites(workspace.slug);
  return (
    <TeamInvite
      className="h-dvh"
      workspace={workspace}
      members={[{ name: user.name, email: user.email, role: "Owner" }]}
      onSend={({ invites, link }) => api.sendInvites(workspace.slug, invites, link)}
      createLink={() => api.createInviteLink(workspace.slug)}
      joined={joined}
      onDone={() => router.push(`/${workspace.slug}`)}
    />
  );
}
```

## Examples

### After Workspace Setup

Show Team Invite when Workspace Setup's success screen hands over: the workspace's name, URL and colour come with it.

```tsx
const [workspace, setWorkspace] = useState<Workspace | null>(null);

return workspace ? <TeamInvite workspace={workspace} onSend={send} /> : <WorkspaceSetup onCreate={create} onInvite={setWorkspace} />;
```

### Drive it from outside

`useTeamInvite` returns the controller the component would make. Hold it yourself to prefill, script or test the flow.

```tsx
const setup = useTeamInvite({ workspace, onSend });
setup.add("maya@fernhill.co, theo@fernhill.co");
<TeamInvite setup={setup} />
```

## API reference

### TeamInvite

| Prop | Type | Default | Description |
|---|---|---|---|
| `workspace` | `InviteWorkspace` | Lumen | `{ name, url, color? }`. Workspace Setup's `Workspace` fits as it is. |
| `members` | `TeamMember[]` | you, as Owner | `{ name, email, role }`: the first rows of the table. An address already here is flagged. |
| `defaultRole` | `"member" \| "admin" \| "guest"` | `"member"` | The role a new chip starts with. |
| `onSend` | `(result: InviteResult) => void \| Promise` | | Runs on Send. A promise shows the button loading; a rejection stays on the last step with its message. |
| `createLink` | `(workspace) => string \| Promise<string>` | `<url>/join/<random>` | Makes the invite link. A rejection leaves the button to try again. |
| `joined` | `string[]` | `[]` | Emails that have accepted: their Pending badge settles to Joined. |
| `onDone` | `(result: InviteResult) => void` | | The success screen's Go to button. |
| `initial` | `Partial<InviteDraft>` | | Prefill, e.g. `{ text: "maya@fernhill.co" }`. |
| `persist` | `StepsPersist` | off | Save progress across reloads. |
| `setup` | `TeamInviteController` | | From `useTeamInvite`; the props above then go to the hook instead. |
| `manageFocus` | `boolean \| (() => boolean)` | `true` | See Onboarding Shell. |
| `className` | `string` | `""` | Give it a height. |

`InviteResult` is `{ workspace, invites: { email, role }[], link }`. Only good addresses are sent.

Also exported: `useTeamInvite`, `MembersPreview`, `MembersBadge`, `checkEntries`, `problemOf`, `nameOf` and `INVITE_ROLES`.

## Keyboard

| Key | Action |
|---|---|
| `Enter` | In the email field with text, makes a chip; in an empty field, continues. |
| `,` or `Space` | Makes a chip of what is typed. |
| `Backspace` | In an empty email field, takes the last chip back into the field to edit. |
| `Escape` | Goes back a step. |
| Arrow keys | Move between roles in the Everyone control. |

## Accessibility

- The email field is labelled; a bad address's reason ("theo@fernhill needs a domain ending, like .com") is the field's error, read as it appears.
- A red chip is a button named with its reason ("theo@fernhill: needs a domain ending, like .com. Edit"); every chip has a Remove button.
- Each person's role Select is named for them ("Role for maya@fernhill.co").
- The members table is decorative (`aria-hidden`): every value it shows is in the form.
- Focus moves to each step's heading, and to "3 invites sent" at the end.

## Motion

The signature is a row landing. Each good chip drops 8px into the members table on `smooth`, under a brand tint that fades as it settles; rows pasted together land 35ms apart, and the count beside Members rolls. The table's height springs to fit. A role change swaps in its cell, and a Pending badge settles to Joined with a colour cross-fade and a text swap. In the form, chips pop in on `snappy` and leave fast, the field shakes once when an address is flagged, and the link card springs open to the link. Reduced motion: rows and chips fade, nothing moves.

## Notes for AI

Use it after a workspace is created, with the workspace from Workspace Setup's `onInvite`. Wire `onSend` to your invite API, `createLink` to your link API, and `joined` to accepted invites from your realtime channel or a poll.

Don't parse or validate emails yourself; the hook splits pasted lists and flags bad, repeated and existing addresses. Don't wrap it in a card or dialog with its own padding: it is a full surface, so give it a height and let it fill.

## Related

- [Workspace Setup](/components/workspace-setup): the step before, which hands over the workspace.
- [Onboarding Shell](/components/onboarding-shell): the frame and step engine under this flow.
