# Members table

> A React members table for B2B team settings: a role select per row, status, activity, 2FA, pending invitations, seats and an invite dialog.

- Type: React component (Tables & Lists)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add members-table`
- Docs: https://wingo-ui.com/components/members-table

## Interaction

Switch between Members and Invitations with the tabs (arrow keys move between them) and type in the search to filter by name or email, with the matches highlighted and the result count announced. Click the Member, Role or Last active header to sort (a third click clears it). Pick a new role right in the row: it applies at once with a spinner in the picker and, if the request fails, rolls back with an alert icon and a red outline that stay until the picker is opened again. Your own role and the owner's are locked (hover or focus them for the reason; on a phone your own card says it). The ⋯ menu per row fades in on hover or focus and is always visible on touch; Remove from team asks first, the row slides out once it is done and focus moves to the next row. On the Invitations tab each row can be resent or revoked, and expired ones say so. Invite members opens a dialog focused on the address field: press Enter, comma or space after each address, or paste a whole list ("Ana Pop <ana@firma.ro>; ..."), and addresses that are invalid, already members or already invited are listed under Not added with the reason. Cmd+Enter or Ctrl+Enter sends from the address field or the message; more addresses than free seats keeps Send disabled and explains why, and the new invitations slide into the Invitations tab. Below 768px of its own width (a phone, a narrow settings column) every row is a card with a full-width role picker, two cards to a row from 672px, and the invite button turns into an icon; on a phone the menus, pickers and the dialog become bottom sheets.

## Installation

```bash
npx wingo-ui@latest add members-table
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/members-table` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/members-table.tsx`

npm dependencies: `npm i motion lucide-react`

Wingo UI dependencies (installed automatically):

- [Alert Dialog](https://wingo-ui.com/components/alert-dialog) ([Markdown](https://wingo-ui.com/components/alert-dialog.md))
- [Avatar](https://wingo-ui.com/components/avatar) ([Markdown](https://wingo-ui.com/components/avatar.md))
- [Badge](https://wingo-ui.com/components/badge) ([Markdown](https://wingo-ui.com/components/badge.md))
- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Dialog](https://wingo-ui.com/components/dialog) ([Markdown](https://wingo-ui.com/components/dialog.md))
- [Dropdown Menu](https://wingo-ui.com/components/dropdown-menu) ([Markdown](https://wingo-ui.com/components/dropdown-menu.md))
- [Empty State](https://wingo-ui.com/components/empty-state) ([Markdown](https://wingo-ui.com/components/empty-state.md))
- [Progress](https://wingo-ui.com/components/progress) ([Markdown](https://wingo-ui.com/components/progress.md))
- [Search Input](https://wingo-ui.com/components/search-input) ([Markdown](https://wingo-ui.com/components/search-input.md))
- [Select](https://wingo-ui.com/components/select) ([Markdown](https://wingo-ui.com/components/select.md))
- [Table](https://wingo-ui.com/components/table) ([Markdown](https://wingo-ui.com/components/table.md))
- [Tabs](https://wingo-ui.com/components/tabs) ([Markdown](https://wingo-ui.com/components/tabs.md))
- [Tag Input](https://wingo-ui.com/components/tag-input) ([Markdown](https://wingo-ui.com/components/tag-input.md))
- [Textarea](https://wingo-ui.com/components/textarea) ([Markdown](https://wingo-ui.com/components/textarea.md))
- [Tooltip](https://wingo-ui.com/components/tooltip) ([Markdown](https://wingo-ui.com/components/tooltip.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [useLocale](https://wingo-ui.com/components/use-locale) ([Markdown](https://wingo-ui.com/components/use-locale.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Number format](https://wingo-ui.com/components/number-format) ([Markdown](https://wingo-ui.com/components/number-format.md))
- [Relative time](https://wingo-ui.com/components/relative-time) ([Markdown](https://wingo-ui.com/components/relative-time.md))
- [Text match](https://wingo-ui.com/components/text-match) ([Markdown](https://wingo-ui.com/components/text-match.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
import { useState } from "react"
import { MembersTable, type MemberData, type MemberRole, type PendingInvite } from "@/components/ui/members-table"

const ROLES: MemberRole[] = [
  { value: "admin", label: "Admin", description: "Full access, including billing and the team" },
  { value: "sales", label: "Sales", description: "Clients, quotes and the pipeline" },
  { value: "viewer", label: "Read only", description: "Sees everything, changes nothing" },
]

export function TeamSettings({ initial }: { initial: MemberData[] }) {
  const [members, setMembers] = useState(initial)
  const [invites, setInvites] = useState<PendingInvite[]>([])
  return (
    <MembersTable
      members={members}
      roles={ROLES}
      invites={invites}
      seats={{ used: members.length, total: 12 }}
      onRoleChange={async (member, role) => {
        await fetch(`/api/members/${member.id}`, { method: "PATCH", body: JSON.stringify({ role }) })
        setMembers((list) => list.map((m) => (m.id === member.id ? { ...m, role } : m)))
      }}
      onRemove={async (member) => {
        await fetch(`/api/members/${member.id}`, { method: "DELETE" })
        setMembers((list) => list.filter((m) => m.id !== member.id))
      }}
      onInvite={async ({ emails, role }) => {
        await fetch("/api/invites", { method: "POST", body: JSON.stringify({ emails, role }) })
        setInvites((list) => [
          ...emails.map((email) => ({ id: email, email, role, invitedAt: Date.now() })),
          ...list,
        ])
      }}
      labels={{ invite: "Add teammates", search: "Search the team" }}
    />
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `members` | `MemberData[]` |  | The team: { id, name, email, avatar?, role, status?: "active" \| "suspended", lastActiveAt?, joinedAt?, twoFactor?, isYou?, isOwner?, title? }. isYou and isOwner lock the role and removal (owner rules win). |
| `roles` | `MemberRole[]` |  | The roles the select offers, in rank order: { value, label, description?, disabled? }. A member whose role is not listed shows the raw value with Unknown role. |
| `invites?` | `PendingInvite[]` | `"[]"` | Pending invitations: { id, email, role, invitedAt, invitedBy?: { name, avatar? }, expiresAt? }. Expired ones sort last with an Expired badge and stop reserving a seat. |
| `onRoleChange?` | `(member: MemberData, role: string) => (void \| Promise<unknown>)` |  | Optimistic: the row shows the new role with a spinner while the promise runs; a rejection rolls it back, outlines the select and announces labels.roleError. |
| `onRemove?` | `(member: MemberData) => (void \| Promise<unknown>)` |  | Runs after the confirmation (unless confirmRemove is false). A returned promise keeps the confirmation loading; a rejection shows labels.removeError in it. |
| `onInvite?` | `(input: MemberInviteInput) => (void \| Promise<unknown>)` |  | { emails, role, message? }. Shows the invite button. A returned promise keeps the dialog loading; on success it closes and switches to the Invitations tab, on rejection the error shows under the address field. The same dialog is exported alone as InviteMembersDialog (see Parts). |
| `onResendInvite?` | `(invite: PendingInvite) => (void \| Promise<unknown>)` |  | Adds Resend invitation to each invitation's menu; the ⋯ button spins while it runs. |
| `onRevokeInvite?` | `(invite: PendingInvite) => (void \| Promise<unknown>)` |  | Adds Revoke invitation. The row leaves at once and comes back if the promise rejects. |
| `getActions?` | `(member: MemberData) => MenuEntry[]` |  | Extra entries for a member's ⋯ menu, above Remove from team (the W1 MenuEntry shape). |
| `confirmRemove?` | `boolean` | `true` | Ask before removing someone, in an alert dialog with their name and email. Off, the row leaves at once and comes back if the request fails. |
| `canManage?` | `boolean` | `true` | false is a member's view: roles read as text, no remove, no invite, no invitation actions. |
| `seats?` | `{ used: number; total: number }` |  | The seats meter in the header. Pending invitations reserve seats (the lighter part of the bar), and the invite dialog refuses more addresses than are free with labels.noSeats. |
| `defaultInviteRole?` | `string` | `""` | The role picked when the invite dialog opens. Empty picks the last enabled role (the least access). |
| `tab?` | `"members" \| "invites"` |  | The active tab, controlled. Pair it with onTabChange. |
| `defaultTab?` | `"members" \| "invites"` | `"members"` | The tab shown first while uncontrolled. |
| `onTabChange?` | `(tab: "members" \| "invites") => void` |  | Called when the tab changes, also after invitations were sent. |
| `showInvites?` | `boolean` | `true` | The Invitations tab. Off shows the members alone under a heading. |
| `searchable?` | `boolean` | `true` | The search field. It filters by name and email, ignoring case and diacritics, and highlights the matches. |
| `sort?` | `TableSort \| null` |  | The sort, controlled: { key: "name" \| "role" \| "lastActive", direction }. null is unsorted. |
| `defaultSort?` | `TableSort \| null` | `"{ key: \"name\", direction: \"asc\" }"` | The first sort while uncontrolled. Role sorts by the order of roles, with the owner first. |
| `onSortChange?` | `(sort: TableSort \| null) => void` |  | Called when a header is pressed. |
| `showStatus?` | `boolean` | `true` | The Status column: Active (success) or Suspended (neutral) badges with a dot. |
| `showTwoFactor?` | `boolean` | `true` | The 2FA column: a green shield when on, an Off warning badge when not. |
| `showLastActive?` | `boolean` | `true` | The Last active column (sortable, newest first). |
| `timeFormat?` | `"relative" \| "time" \| "date" \| "datetime"` | `"relative"` | How last activity and sent times read: "5 min ago", a clock time, a date or both. |
| `density?` | `"compact" \| "default" \| "comfortable"` | `"default"` | Row height 36 / 48 / 56px with a 24 / 32 / 40px avatar. Compact puts the email on the name's line. Cards (below 768px of width) keep one size. |
| `variant?` | `"surface" \| "plain"` | `"surface"` | surface sits the table in a raised panel; plain drops the panel, for a table inside a settings card. |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | The invite button, the active tab's bar, the seats bar and the dialog's Send button. |
| `color?` | `string` | `""` | Any CSS color in place of the tone. |
| `inviteOpen?` | `boolean` |  | The invite dialog, controlled. Pair it with onInviteOpenChange. |
| `defaultInviteOpen?` | `boolean` | `false` | Opens the invite dialog on mount. |
| `onInviteOpenChange?` | `(open: boolean) => void` |  | Called when the invite dialog opens or closes. |
| `responsive?` | `boolean` | `true` | Below 768px the role picker, the row menus and the invite dialog open as bottom sheets. |
| `loading?` | `boolean` | `false` | Skeleton rows (cards on phones) with the real shape: avatar, two lines, a role pill. |
| `skeletonCount?` | `number` | `5` | Skeleton rows while loading (at most four cards on phones). |
| `emptyState?` | `ReactNode` |  | Replaces the empty state shown when there are no members (the default offers the invite button). |
| `now?` | `Date \| number` |  | The reference time for relative times and expiry. Defaults to a live clock that ticks every minute; demos pin 24.09.2026 16:40. |
| `locale?` | `string` | `""` | Formats times, dates and counts. Empty uses the UIProvider locale, then the browser's. |
| `labels?` | `Partial<MembersTableLabels>` | `"DEFAULT_MEMBERS_TABLE_LABELS"` | Every string, English by default: caption, members(count, formatted), invites(count, formatted), invite, search, results, member, role, status, sortAscending / sortDescending / clearSort ({column} is the header text), lastActive, twoFactor, twoFactorOn / Off, you, owner, active, suspended, never, joined(date), roleFor(name), unknownRole, selfRole, ownerRole, roleChanged(name, role), roleError, savingRole, remove, removeTitle(name), removeDescription, removeConfirm, removing, cancel, selfRemove, ownerRemove, removed(name), removeError, actions, actionsFor(name), email, invitedBy, sentAt, expiresAt, expired, expires(date), resend, revoke, resent(email), revoked(email), inviteActionError, seats(used, total), seatsReserved(count), noSeats(free), freeSeats(free), inviteTitle, inviteDescription, emails, emailsPlaceholder, emailsHint, invalidEmail, alreadyMember, alreadyInvited, notAdded, dismiss(email), inviteRole, message, messagePlaceholder, summary(count, formatted, role), send(count), sending, sent(count, formatted), inviteError, close, empty, emptyDescription, noResults, noResultsDescription(query), clearSearch, noInvites, noInvitesDescription. |
| `classNames?` | `Partial<Record<"header" \| "tabs" \| "search" \| "seats" \| "invite" \| "table" \| "row" \| "cards" \| "card" \| "invites" \| "empty" \| "dialog", string>>` |  | Extra classes for the parts. dialog goes on the invite dialog's content. |
| `className?` | `string` |  | Extra classes for the root, merged last. Other props spread onto it. |

## Parts

### InviteMembersDialog (component)

The invite dialog on its own (a bottom sheet on phones), for an Invite button elsewhere in the app. Takes the Dialog props (open, defaultOpen, onOpenChange, trigger, responsive, size) plus the ones below; classNames also styles emails, rejected, role, message, summary and send.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `roles` | `MemberRole[]` |  | The roles to pick from. |
| `members?` | `MemberData[]` |  | Current members; their addresses are refused as already members. |
| `invites?` | `PendingInvite[]` | `"[]"` | Pending invitations; their addresses are refused as already invited. |
| `defaultRole?` | `string` | `""` | The role picked when the dialog opens; empty picks the last enabled role. |
| `defaultEmails?` | `string[]` | `"[]"` | Addresses already in the field when it opens. |
| `freeSeats?` | `number` |  | Seats left. More valid addresses than this blocks sending; undefined means no limit. |
| `onInvite?` | `(input: MemberInviteInput) => (void \| Promise<unknown>)` |  | Sends the invitations. A promise keeps the dialog loading; a rejection shows its message under the field. |
| `showMessage?` | `boolean` | `true` | An optional personal message field. |
| `maxMessageLength?` | `number` | `500` | Character limit of the message. |
| `tone?` | `Tone` | `"neutral"` | Color of the send button. |
| `labels?` | `Partial<MembersTableLabels>` |  | The same copy object as the table. |

### splitEmailList (function)

splitEmailList(text) splits a pasted list (commas, semicolons, new lines, tabs, spaces, "Name <email>") into lowercased addresses. The invite field uses it on paste.

### DEFAULT_MEMBERS_TABLE_LABELS (constant)

The English copy of the table and the invite dialog. Spread it into labels when you translate only a few keys.

### MemberData (type)

One member row: id, name, email, avatar, role, status, lastActiveAt, joinedAt, twoFactor, isYou, isOwner and an optional title.

## Examples

- **Read-only**: A member's view of the team: roles as text, no actions.
- **Pasted list**: Four pasted addresses: two added, one invalid, one already a member.
- **Seats full**: One free seat and two addresses: the dialog explains and keeps Send disabled.
- **Compact, 40 members**: Dense rows with the email on one line, search and sorting.
- **Loading and empty**: Skeleton rows, then an Invitations tab with nothing pending.
- **Phone cards**: At 390px every row is a card with a full-width role picker.

## See also

- [Table](https://wingo-ui.com/components/table) ([Markdown](https://wingo-ui.com/components/table.md))
- [Data Table](https://wingo-ui.com/components/data-table) ([Markdown](https://wingo-ui.com/components/data-table.md))
- [Permissions Matrix](https://wingo-ui.com/components/permissions-matrix) ([Markdown](https://wingo-ui.com/components/permissions-matrix.md))
- [Settings Layout](https://wingo-ui.com/components/settings-layout) ([Markdown](https://wingo-ui.com/components/settings-layout.md))
- [Tag Input](https://wingo-ui.com/components/tag-input) ([Markdown](https://wingo-ui.com/components/tag-input.md))
- [Dialog](https://wingo-ui.com/components/dialog) ([Markdown](https://wingo-ui.com/components/dialog.md))

---

Canonical: https://wingo-ui.com/components/members-table
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
