# Tables

> Workspace members card with search and role filter, avatars with presence dots, role pill dropdown with descriptions, status and last active columns, and a selection bar to set roles or remove members.

A shadcn/ui block from the [@shadcnuikit](https://shadcnuikit.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/shadcnuikit/tables5).

- Page, with a live preview: https://shadcn.exchange/r/shadcnuikit/tables5
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/shadcnuikit/tables5-light-dcc3238d52cc0d30.png
- Registry item: https://shadcnuikit.com/r/tables5.json
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://shadcnuikit.com/r/tables5.json
```

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

## Dependencies

- npm packages: `@tanstack/react-table`, `lucide-react`
- Registry items: `avatar`, `button`, `card`, `checkbox`, `dropdown-menu`, `empty`, `input-group`, `select`, `table`

## Files

### `contents/blocks/dashboard-ui/tables/5/index.tsx`

```tsx
"use client";

import * as React from "react";
import {
  ColumnDef,
  ColumnFiltersState,
  RowSelectionState,
  flexRender,
  getCoreRowModel,
  getFilteredRowModel,
  useReactTable,
} from "@tanstack/react-table";
import {
  ChevronDown,
  Search,
  ShieldCheck,
  UserMinus,
  Users,
  X,
} from "lucide-react";

import { cn } from "@/lib/utils";
import {
  Avatar,
  AvatarBadge,
  AvatarFallback,
  AvatarImage,
} from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Checkbox } from "@/components/ui/checkbox";
import {
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuLabel,
  DropdownMenuRadioGroup,
  DropdownMenuRadioItem,
  DropdownMenuSeparator,
  DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
  Empty,
  EmptyContent,
  EmptyHeader,
  EmptyMedia,
  EmptyTitle,
} from "@/components/ui/empty";
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table";

const ROLES = ["Owner", "Editor", "Analyst", "Guest"] as const;
type Role = (typeof ROLES)[number];
type Presence = "online" | "idle" | "offline";

type Member = {
  id: string;
  name: string;
  email: string;
  avatar: string;
  role: Role;
  presence: Presence;
  lastSeen: string;
};

const initialMembers: Member[] = [
  {
    id: "m1",
    name: "Aria Lindqvist",
    email: "aria@northwind.io",
    avatar: "https://i.pravatar.cc/100?img=47",
    role: "Owner",
    presence: "online",
    lastSeen: "2026-10-04T09:12:00Z",
  },
  {
    id: "m2",
    name: "Tomas Okafor",
    email: "tomas@northwind.io",
    avatar: "https://i.pravatar.cc/100?img=12",
    role: "Editor",
    presence: "online",
    lastSeen: "2026-10-04T08:55:00Z",
  },
  {
    id: "m3",
    name: "Lena Moreau",
    email: "lena@northwind.io",
    avatar: "https://i.pravatar.cc/100?img=32",
    role: "Analyst",
    presence: "idle",
    lastSeen: "2026-10-03T17:40:00Z",
  },
  {
    id: "m4",
    name: "Kenji Arai",
    email: "kenji@northwind.io",
    avatar: "https://i.pravatar.cc/100?img=68",
    role: "Editor",
    presence: "offline",
    lastSeen: "2026-09-29T11:05:00Z",
  },
  {
    id: "m5",
    name: "Priya Raman",
    email: "priya@northwind.io",
    avatar: "https://i.pravatar.cc/100?img=26",
    role: "Analyst",
    presence: "online",
    lastSeen: "2026-10-04T09:01:00Z",
  },
  {
    id: "m6",
    name: "Felix Brandt",
    email: "felix@partner.studio",
    avatar: "https://i.pravatar.cc/100?img=33",
    role: "Guest",
    presence: "offline",
    lastSeen: "2026-09-21T14:30:00Z",
  },
];

const roleHints: Record<Role, string> = {
  Owner: "Full access and billing",
  Editor: "Create and edit content",
  Analyst: "View reports and export",
  Guest: "Read only, shared items",
};

const presenceMeta: Record<Presence, { label: string; dot: string }> = {
  online: { label: "Online", dot: "bg-green-500 dark:bg-green-400" },
  idle: {
    label: "Idle",
    dot: "bg-background ring-2 ring-inset ring-muted-foreground",
  },
  offline: { label: "Offline", dot: "bg-muted-foreground/40" },
};

const dateFormat = new Intl.DateTimeFormat("en-US", {
  month: "short",
  day: "numeric",
  hour: "numeric",
  minute: "2-digit",
  timeZone: "UTC",
});

function initials(name: string) {
  return name
    .split(" ")
    .map((part) => part[0])
    .join("")
    .slice(0, 2);
}

function RoleMenu({
  value,
  onChange,
  name,
}: {
  value: Role;
  onChange: (role: Role) => void;
  name: string;
}) {
  return (
    <DropdownMenu>
      <DropdownMenuTrigger asChild>
        <Button
          variant="outline"
          size="sm"
          className="h-7 gap-1 pr-2 pl-2.5 text-xs"
          aria-label={`Change role for ${name}, currently ${value}`}
        >
          {value === "Owner" && <ShieldCheck className="size-3.5" />}
          {value}
          <ChevronDown className="text-muted-foreground size-3.5" />
        </Button>
      </DropdownMenuTrigger>
      <DropdownMenuContent align="start" className="w-60">
        <DropdownMenuLabel className="text-muted-foreground text-xs">
          Workspace role
        </DropdownMenuLabel>
        <DropdownMenuRadioGroup
          value={value}
          onValueChange={(next) => onChange(next as Role)}
        >
          {ROLES.map((role) => (
            <DropdownMenuRadioItem
              key={role}
              value={role}
              className="items-start"
            >
              <div className="flex flex-col">
                <span>{role}</span>
                <span className="text-muted-foreground text-xs font-normal">
                  {roleHints[role]}
                </span>
              </div>
            </DropdownMenuRadioItem>
          ))}
        </DropdownMenuRadioGroup>
      </DropdownMenuContent>
    </DropdownMenu>
  );
}

export default function TeamMembersTable() {
  const [members, setMembers] = React.useState<Member[]>(initialMembers);
  const [rowSelection, setRowSelection] = React.useState<RowSelectionState>({});
  const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>(
    [],
  );
  const [query, setQuery] = React.useState("");

  const updateRole = React.useCallback((ids: string[], role: Role) => {
    setMembers((prev) =>
      prev.map((m) => (ids.includes(m.id) ? { ...m, role } : m)),
    );
  }, []);

  const columns = React.useMemo<ColumnDef<Member>[]>(
    () => [
      {
        id: "select",
        header: ({ table }) => (
          <Checkbox
            checked={
              table.getIsAllPageRowsSelected() ||
              (table.getIsSomePageRowsSelected() && "indeterminate")
            }
            onCheckedChange={(value) =>
              table.toggleAllPageRowsSelected(!!value)
            }
            disabled={!table.getRowModel().rows.length}
            aria-label="Select all members"
          />
        ),
        cell: ({ row }) => (
          <Checkbox
            checked={row.getIsSelected()}
            onCheckedChange={(value) => row.toggleSelected(!!value)}
            aria-label={`Select ${row.original.name}`}
          />
        ),
      },
      {
        accessorKey: "name",
        header: "Member",
        cell: ({ row }) => {
          const member = row.original;
          return (
            <div className="flex items-center gap-3">
              <Avatar size="lg">
                <AvatarImage src={member.avatar} alt="" />
                <AvatarFallback>{initials(member.name)}</AvatarFallback>
                <AvatarBadge className={presenceMeta[member.presence].dot} />
              </Avatar>
              <div className="min-w-0">
                <div className="truncate font-medium">{member.name}</div>
                <div className="text-muted-foreground truncate text-xs">
                  {member.email}
                </div>
              </div>
            </div>
          );
        },
      },
      {
        accessorKey: "role",
        header: "Role",
        filterFn: (row, columnId, value: string) =>
          value === "all" || row.getValue(columnId) === value,
        cell: ({ row }) => (
          <RoleMenu
            value={row.original.role}
            name={row.original.name}
            onChange={(role) => updateRole([row.original.id], role)}
          />
        ),
      },
      {
        accessorKey: "presence",
        header: "Status",
        cell: ({ row }) => {
          const meta = presenceMeta[row.original.presence];
          return (
            <span className="inline-flex items-center gap-2 text-sm">
              <span className={cn("size-2 rounded-full", meta.dot)} />
              {meta.label}
            </span>
          );
        },
      },
      {
        accessorKey: "lastSeen",
        header: "Last active",
        cell: ({ row }) =>
          row.original.presence === "online" ? (
            <span className="text-muted-foreground text-sm">Now</span>
          ) : (
            <span className="text-muted-foreground text-sm tabular-nums">
              {dateFormat.format(new Date(row.original.lastSeen))}
            </span>
          ),
      },
    ],
    [updateRole],
  );

  const table = useReactTable({
    data: members,
    columns,
    state: { rowSelection, columnFilters, globalFilter: query },
    onRowSelectionChange: setRowSelection,
    onColumnFiltersChange: setColumnFilters,
    onGlobalFilterChange: setQuery,
    globalFilterFn: (row, _columnId, value: string) => {
      const needle = value.trim().toLowerCase();
      if (!needle) return true;
      return (
        row.original.name.toLowerCase().includes(needle) ||
        row.original.email.toLowerCase().includes(needle)
      );
    },
    getRowId: (row) => row.id,
    getCoreRowModel: getCoreRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
  });

  const roleFilter =
    (table.getColumn("role")?.getFilterValue() as string) ?? "all";
  const selectedIds = Object.keys(rowSelection).filter(
    (id) => rowSelection[id],
  );
  const onlineCount = members.filter((m) => m.presence === "online").length;
  const rows = table.getRowModel().rows;

  const removeSelected = () => {
    setMembers((prev) => prev.filter((m) => !selectedIds.includes(m.id)));
    setRowSelection({});
  };

  return (
    <Card className="mx-auto w-full max-w-4xl gap-0 py-0 shadow-none">
      <CardHeader className="gap-1 border-b py-5">
        <CardTitle>Workspace members</CardTitle>
        <CardDescription>
          {members.length} people, {onlineCount} online right now. Change a role
          from its pill.
        </CardDescription>
      </CardHeader>

      <div className="flex flex-col gap-2 border-b px-6 py-3 sm:flex-row sm:items-center">
        {selectedIds.length > 0 ? (
          <div className="flex min-h-9 flex-1 flex-wrap items-center gap-2">
            <Button
              variant="ghost"
              size="icon-sm"
              onClick={() => setRowSelection({})}
              aria-label="Clear selection"
            >
              <X />
            </Button>
            <span className="text-sm font-medium">
              {selectedIds.length} selected
            </span>
            <div className="flex flex-wrap items-center gap-2 sm:ml-auto">
              <Select
                onValueChange={(role) => {
                  updateRole(selectedIds, role as Role);
                  setRowSelection({});
                }}
              >
                <SelectTrigger
                  size="sm"
                  className="w-36"
                  aria-label="Set role for selected"
                >
                  <SelectValue placeholder="Set role" />
                </SelectTrigger>
                <SelectContent>
                  {ROLES.map((role) => (
                    <SelectItem key={role} value={role}>
                      {role}
                    </SelectItem>
                  ))}
                </SelectContent>
              </Select>
              <Button variant="destructive" size="sm" onClick={removeSelected}>
                <UserMinus />
                Remove
              </Button>
            </div>
          </div>
        ) : (
          <>
            <InputGroup className="sm:max-w-64">
              <InputGroupAddon>
                <Search />
              </InputGroupAddon>
              <InputGroupInput
                value={query}
                onChange={(event) => setQuery(event.target.value)}
                placeholder="Search by name or email"
                aria-label="Search members"
              />
            </InputGroup>
            <Select
              value={roleFilter}
              onValueChange={(value) =>
                table.getColumn("role")?.setFilterValue(value)
              }
            >
              <SelectTrigger
                className="w-full sm:ml-auto sm:w-40"
                aria-label="Filter by role"
              >
                <SelectValue />
              </SelectTrigger>
              <SelectContent align="end">
                <SelectItem value="all">All roles</SelectItem>
                {ROLES.map((role) => (
                  <SelectItem key={role} value={role}>
                    {role}
                  </SelectItem>
                ))}
              </SelectContent>
            </Select>
          </>
        )}
      </div>

      <Table>
        <TableHeader>
          {table.getHeaderGroups().map((headerGroup) => (
            <TableRow key={headerGroup.id} className="hover:bg-transparent">
              {headerGroup.headers.map((header) => (
                <TableHead
                  key={header.id}
                  className={cn(
                    "text-muted-foreground first:w-10 first:pl-6 last:pr-6",
                    header.column.id === "presence" && "hidden sm:table-cell",
                    header.column.id === "lastSeen" && "hidden md:table-cell",
                  )}
                >
                  {header.isPlaceholder
                    ? null
                    : flexRender(
                        header.column.columnDef.header,
                        header.getContext(),
                      )}
                </TableHead>
              ))}
            </TableRow>
          ))}
        </TableHeader>
        <TableBody>
          {rows.length ? (
            rows.map((row) => (
              <TableRow
                key={row.id}
                data-state={row.getIsSelected() ? "selected" : undefined}
              >
                {row.getVisibleCells().map((cell) => (
                  <TableCell
                    key={cell.id}
                    className={cn(
                      "py-3 first:pl-6 last:pr-6",
                      cell.column.id === "presence" && "hidden sm:table-cell",
                      cell.column.id === "lastSeen" && "hidden md:table-cell",
                    )}
                  >
                    {flexRender(cell.column.columnDef.cell, cell.getContext())}
                  </TableCell>
                ))}
              </TableRow>
            ))
          ) : (
            <TableRow className="hover:bg-transparent">
              <TableCell colSpan={columns.length} className="h-40 p-0">
                <Empty className="gap-3 p-4 whitespace-normal">
                  <EmptyHeader>
                    <EmptyMedia variant="icon">
                      <Users />
                    </EmptyMedia>
                    <EmptyTitle className="text-base">
                      No members match
                    </EmptyTitle>
                  </EmptyHeader>
                  <EmptyContent>
                    <Button
                      variant="outline"
                      size="sm"
                      onClick={() => {
                        setQuery("");
                        setColumnFilters([]);
                      }}
                    >
                      Reset filters
                    </Button>
                  </EmptyContent>
                </Empty>
              </TableCell>
            </TableRow>
          )}
        </TableBody>
      </Table>
    </Card>
  );
}
```
