# Team Members Table

> Workspace member management with roles, two-factor status, last activity and a remove action.

A shadcn/ui block from the [@spectrumui](https://ui.spectrumhq.in) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/spectrumui/team-members-table).

- Page, with a live preview: https://shadcn.exchange/r/spectrumui/team-members-table
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/spectrumui/team-members-table-light-6a795723f84fc5d8.png
- Registry item: https://ui.spectrumhq.in/r/team-members-table.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://ui.spectrumhq.in/r/team-members-table.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- Registry items: `@spectrumui/data-table`

## Files

### `components/spectrumui/blocks/tables/team-members-table.tsx`

```tsx
'use client';

import * as React from 'react';
import { DataTable, type DataTableColumn } from '@/components/spectrumui/data-table';

type IconProps = React.SVGProps<SVGSVGElement>;

function IconShield(props: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" {...props}>
      <path
        transform="translate(3.5, 2)"
        d="M9.071,0.1 L15.708,2.327 C16.451,2.575 16.954,3.257 16.958,4.022 L17,10.663 C17.013,12.676 16.279,14.628 14.935,16.158 C14.317,16.86 13.525,17.463 12.513,18.002 L8.945,19.91 C8.833,19.969 8.71,19.999 8.587,20 C8.463,20.001 8.339,19.972 8.228,19.914 L4.627,18.051 C3.604,17.52 2.805,16.926 2.181,16.233 C0.814,14.719 0.055,12.776 0.042,10.76 L0,4.124 C-0.004,3.358 0.489,2.671 1.228,2.413 L7.841,0.106 C8.233,-0.033 8.671,-0.036 9.071,0.1 Z M12.245,7.219 C11.948,6.933 11.47,6.935 11.177,7.225 L11.177,7.225 L7.808,10.545 L6.429,9.219 C6.132,8.934 5.655,8.937 5.361,9.226 C5.068,9.515 5.071,9.98 5.368,10.265 L5.368,10.265 L7.284,12.109 C7.433,12.252 7.626,12.323 7.819,12.321 C8.012,12.32 8.205,12.247 8.352,12.102 L8.352,12.102 L12.251,8.258 C12.544,7.969 12.541,7.504 12.245,7.219 Z"
      />
    </svg>
  );
}

function IconMore(props: IconProps) {
  return (
    <svg viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" {...props}>
      <path
        transform="translate(2, 2)"
        d="M10,0 C15.52,0 20,4.48 20,10 C20,15.52 15.52,20 10,20 C4.47,20 0,15.52 0,10 C0,4.48 4.47,0 10,0 Z M14.48,8.801 C13.81,8.801 13.28,9.34 13.28,10 C13.28,10.66 13.81,11.2 14.48,11.2 C15.14,11.2 15.67,10.66 15.67,10 C15.67,9.34 15.14,8.801 14.48,8.801 Z M10,8.801 C9.34,8.801 8.8,9.34 8.8,10 C8.8,10.66 9.34,11.2 10,11.2 C10.66,11.2 11.19,10.66 11.19,10 C11.19,9.34 10.66,8.801 10,8.801 Z M5.52,8.801 C4.86,8.801 4.32,9.34 4.32,10 C4.32,10.66 4.86,11.2 5.52,11.2 C6.18,11.2 6.71,10.66 6.71,10 C6.71,9.34 6.18,8.801 5.52,8.801 Z"
      />
    </svg>
  );
}

type Role = 'Owner' | 'Admin' | 'Editor' | 'Viewer';

interface Member {
  id: string;
  name: string;
  email: string;
  avatar: string;
  role: Role;
  twoFactor: boolean;
  lastActive: string;
}

const MEMBERS: Member[] = [
  {
    id: 'u1',
    name: 'Freya Lindgren',
    email: 'freya@driftlab.se',
    avatar: '/avatars/people/01.jpg',
    role: 'Owner',
    twoFactor: true,
    lastActive: 'Now',
  },
  {
    id: 'u2',
    name: 'Mateo Alvarez',
    email: 'mateo@driftlab.se',
    avatar: '/avatars/people/03.jpg',
    role: 'Admin',
    twoFactor: true,
    lastActive: '5 min ago',
  },
  {
    id: 'u3',
    name: 'Aiko Watanabe',
    email: 'aiko@driftlab.se',
    avatar: '/avatars/people/06.jpg',
    role: 'Editor',
    twoFactor: true,
    lastActive: '1 hour ago',
  },
  {
    id: 'u4',
    name: 'Jonas Keller',
    email: 'jonas@driftlab.se',
    avatar: '/avatars/people/09.jpg',
    role: 'Editor',
    twoFactor: false,
    lastActive: 'Yesterday',
  },
  {
    id: 'u5',
    name: 'Zara Osei',
    email: 'zara@driftlab.se',
    avatar: '/avatars/people/12.jpg',
    role: 'Editor',
    twoFactor: true,
    lastActive: '2 days ago',
  },
  {
    id: 'u6',
    name: 'Nils Bakker',
    email: 'nils@driftlab.se',
    avatar: '/avatars/people/13.jpg',
    role: 'Viewer',
    twoFactor: false,
    lastActive: 'Aug 30',
  },
  {
    id: 'u7',
    name: 'Leila Haddad',
    email: 'leila@driftlab.se',
    avatar: '/avatars/people/05.jpg',
    role: 'Viewer',
    twoFactor: true,
    lastActive: 'Aug 27',
  },
];

const ROLE_RANK: Record<Role, number> = { Owner: 0, Admin: 1, Editor: 2, Viewer: 3 };

const columns: DataTableColumn<Member>[] = [
  {
    id: 'name',
    header: 'Member',
    sortable: true,
    value: (row) => row.name,
    cell: (row) => (
      <div className="flex items-center gap-2.5">
        {/* eslint-disable-next-line @next/next/no-img-element */}
        <img
          src={row.avatar}
          alt=""
          loading="lazy"
          width={28}
          height={28}
          className="size-7 shrink-0 rounded-full object-cover outline outline-1 -outline-offset-1 outline-black/10 dark:outline-white/10"
        />
        <span className="min-w-0">
          <span className="block truncate">{row.name}</span>
          <span className="block truncate text-xs font-normal text-neutral-500 dark:text-neutral-400">
            {row.email}
          </span>
        </span>
      </div>
    ),
  },
  {
    id: 'role',
    header: 'Role',
    sortable: true,
    value: (row) => ROLE_RANK[row.role],
    cell: (row) => (
      <span className="inline-flex rounded-full bg-neutral-100 px-2 py-0.5 text-xs font-medium text-neutral-700 ring-1 ring-inset ring-neutral-500/15 dark:bg-neutral-100/10 dark:text-neutral-300 dark:ring-white/15">
        {row.role}
      </span>
    ),
  },
  {
    id: 'twoFactor',
    header: '2FA',
    sortable: true,
    hideBelow: 'sm',
    value: (row) => row.twoFactor,
    cell: (row) =>
      row.twoFactor ? (
        <span className="inline-flex items-center gap-1.5 text-emerald-700 dark:text-emerald-300">
          <IconShield className="size-4" />
          Enabled
        </span>
      ) : (
        <span className="text-neutral-400 dark:text-neutral-500">Off</span>
      ),
  },
  {
    id: 'lastActive',
    header: 'Last active',
    hideBelow: 'md',
    value: (row) => row.lastActive,
  },
];

export function TeamMembersTable({
  variant = 'Comfortable',
}: {
  variant?: 'Comfortable' | 'Compact';
}) {
  const [members, setMembers] = React.useState(MEMBERS);
  const removed = MEMBERS.length - members.length;

  return (
    <div className="mx-auto w-full max-w-4xl px-4 py-10 sm:px-6">
      <DataTable
        data={members}
        columns={columns}
        rowId={(row) => row.id}
        rowLabel={(row) => row.name}
        caption="Workspace members with role, two-factor status and last activity."
        density={variant === 'Compact' ? 'compact' : 'default'}
        title="Members"
        quickFilter={{
          columnId: 'role',
          label: 'Filter by role',
          getValue: (row) => row.role,
        }}
        selectable
        defaultSort={{ columnId: 'role', direction: 'asc' }}
        onDelete={(ids) => setMembers((current) => current.filter((row) => !ids.includes(row.id)))}
        toolbar={
          removed > 0 ? (
            <button
              type="button"
              onClick={() => setMembers(MEMBERS)}
              className="rounded-full px-2 text-sm font-medium text-neutral-500 underline underline-offset-2 transition-colors duration-100 ease-out hover:text-neutral-900 focus-visible:ring-1 focus-visible:ring-neutral-950 focus-visible:outline-hidden dark:text-neutral-400 dark:hover:text-neutral-100 dark:focus-visible:ring-neutral-300"
            >
              Restore {removed}
            </button>
          ) : null
        }
        rowActions={(row) => (
          <button
            type="button"
            aria-label={`Open actions for ${row.name}`}
            className="grid size-8 place-items-center rounded-xl text-neutral-400 transition-colors duration-100 ease-out hover:bg-neutral-100 hover:text-neutral-900 focus-visible:ring-1 focus-visible:ring-neutral-950 focus-visible:outline-hidden dark:hover:bg-neutral-800 dark:hover:text-neutral-100 dark:focus-visible:ring-neutral-300"
          >
            <IconMore className="size-[18px]" />
          </button>
        )}
        bulkActions={({ ids, remove }) => (
          <button
            type="button"
            onClick={remove}
            className="rounded-full px-2.5 py-1.5 text-sm font-medium text-rose-300 transition-colors duration-100 ease-out hover:bg-white/10 focus-visible:ring-1 focus-visible:ring-current focus-visible:outline-hidden dark:text-rose-700 dark:hover:bg-black/10"
          >
            Remove {ids.length === 1 ? 'member' : `${ids.length} members`}
          </button>
        )}
      />
    </div>
  );
}
```
