Members table React component for Tailwind CSS v4
v1.0.0Added in 1.0.0
A React members table for B2B team settings: a role select per row, status, activity, 2FA, pending invitations, seats and an invite dialog.
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.
Members table is a Pro React component in the React table and list components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add members-table, together with Alert Dialog, Avatar, Badge, Button, Dialog and 18 more and the npm packages motion and lucide-react.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add members-tableMembers table is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Usage
Import MembersTable from @/components/ui/members-table.
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
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.
invitesdefault"[]"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).
confirmRemovedefaulttruebooleanAsk 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.
canManagedefaulttruebooleanfalse 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.
defaultInviteRoledefault""stringThe 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.
defaultTabdefault"members""members" | "invites"The tab shown first while uncontrolled.
onTabChange(tab: "members" | "invites") => voidCalled when the tab changes, also after invitations were sent.
showInvitesdefaulttruebooleanThe Invitations tab. Off shows the members alone under a heading.
searchabledefaulttruebooleanThe search field. It filters by name and email, ignoring case and diacritics, and highlights the matches.
sortTableSort | nullThe sort, controlled: { key: "name" | "role" | "lastActive", direction }. null is unsorted.
defaultSortdefault"{ key: "name", direction: "asc" }"TableSort | nullThe first sort while uncontrolled. Role sorts by the order of roles, with the owner first.
onSortChange(sort: TableSort | null) => voidCalled when a header is pressed.
showStatusdefaulttruebooleanThe Status column: Active (success) or Suspended (neutral) badges with a dot.
showTwoFactordefaulttruebooleanThe 2FA column: a green shield when on, an Off warning badge when not.
showLastActivedefaulttruebooleanThe Last active column (sortable, newest first).
timeFormatdefault"relative""relative" | "time" | "date" | "datetime"How last activity and sent times read: "5 min ago", a clock time, a date or both.
densitydefault"default""compact" | "default" | "comfortable"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.
variantdefault"surface""surface" | "plain"surface sits the table in a raised panel; plain drops the panel, for a table inside a settings card.
tonedefault"neutral""neutral" | "brand" | "success" | "warning" | "danger" | "info"The invite button, the active tab's bar, the seats bar and the dialog's Send button.
colordefault""stringAny CSS color in place of the tone.
inviteOpenbooleanThe invite dialog, controlled. Pair it with onInviteOpenChange.
defaultInviteOpendefaultfalsebooleanOpens the invite dialog on mount.
onInviteOpenChange(open: boolean) => voidCalled when the invite dialog opens or closes.
responsivedefaulttruebooleanBelow 768px the role picker, the row menus and the invite dialog open as bottom sheets.
loadingdefaultfalsebooleanSkeleton rows (cards on phones) with the real shape: avatar, two lines, a role pill.
skeletonCountdefault5numberSkeleton rows while loading (at most four cards on phones).
emptyStateReactNodeReplaces the empty state shown when there are no members (the default offers the invite button).
nowDate | numberThe reference time for relative times and expiry. Defaults to a live clock that ticks every minute; demos pin 24.09.2026 16:40.
localedefault""stringFormats times, dates and counts. Empty uses the UIProvider locale, then the browser's.
labelsdefault"DEFAULT_MEMBERS_TABLE_LABELS"Partial<MembersTableLabels>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.
classNamesPartial<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.
classNamestringExtra classes for the root, merged last. Other props spread onto it.
Parts
InviteMembersDialog
componentThe 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.
roles*MemberRole[]The roles to pick from.
membersMemberData[]Current members; their addresses are refused as already members.
invitesdefault"[]"PendingInvite[]Pending invitations; their addresses are refused as already invited.
defaultRoledefault""stringThe role picked when the dialog opens; empty picks the last enabled role.
defaultEmailsdefault"[]"string[]Addresses already in the field when it opens.
freeSeatsnumberSeats 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.
showMessagedefaulttruebooleanAn optional personal message field.
maxMessageLengthdefault500numberCharacter limit of the message.
tonedefault"neutral"ToneColor of the send button.
labelsPartial<MembersTableLabels>The same copy object as the table.
splitEmailList
functionsplitEmailList(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
constantThe English copy of the table and the invite dialog. Spread it into labels when you translate only a few keys.
MemberData
typeOne 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.
Dependencies
Files
- components/ui/members-table.tsx
FAQ
Is the React Members table component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install Members table?
Run npx wingo-ui@latest add members-table. It also adds the Wingo UI items it builds on (Alert Dialog, Avatar, Badge, Button, Dialog and 18 more) and the npm packages motion and lucide-react. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/members-table.
How does Members table behave on phones?
Below 768px the role picker, the row menus and the invite dialog open as bottom sheets. The responsive prop controls this and is on by default.
See also
Used in
More React table and list components
Articles using this component
- Role Management UI in React: Members, Roles, Audit Log
Build it: screens and apps9 min read