# Users Table

A searchable users table with role filters, row selection, and bulk role and status actions.

Users Table is an open-source block from suiss/ui for React, React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/blocks/users-table

**React**

**Example**

```tsx title="components/blocks/users-table.tsx"
"use client"

import {
  ChartBarIcon,
  GearIcon,
  MagnifyingGlassIcon,
  PlusIcon,
  ShieldIcon,
  UsersIcon,
} from "@phosphor-icons/react"
import * as React from "react"

import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group"
import {
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarGroup,
  SidebarGroupContent,
  SidebarGroupLabel,
  SidebarHeader,
  SidebarInset,
  SidebarMenu,
  SidebarMenuBadge,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarProvider,
  SidebarRail,
  SidebarTrigger,
  useSidebar,
} from "@/components/ui/sidebar"
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

type NavItem = {
  value: string
  label: string
  icon: React.ReactNode
  badge?: React.ReactNode
  group?: string
}

function NavSidebar({
  items,
  value: valueProp,
  defaultValue,
  onValueChange,
  header,
  footer,
}: {
  items: NavItem[]
  value?: string
  defaultValue?: string
  onValueChange?: (value: string) => void
  header?: React.ReactNode
  footer?: React.ReactNode
}) {
  const { isMobile, setOpenMobile } = useSidebar()
  const [uncontrolledValue, setUncontrolledValue] = React.useState(
    defaultValue ?? items[0]?.value
  )
  const value = valueProp ?? uncontrolledValue
  const groups = [...new Set(items.map((item) => item.group ?? ""))]

  return (
    <Sidebar collapsible="icon">
      {header && (
        <SidebarHeader className="group-data-[collapsible=icon]:hidden">
          {header}
        </SidebarHeader>
      )}
      <SidebarContent>
        {groups.map((group) => (
          <SidebarGroup key={group}>
            {group && <SidebarGroupLabel>{group}</SidebarGroupLabel>}
            <SidebarGroupContent>
              <SidebarMenu>
                {items
                  .filter((item) => (item.group ?? "") === group)
                  .map((item) => (
                    <SidebarMenuItem key={item.value}>
                      <SidebarMenuButton
                        isActive={item.value === value}
                        tooltip={item.label}
                        aria-current={item.value === value ? "page" : undefined}
                        onClick={() => {
                          if (valueProp === undefined) {
                            setUncontrolledValue(item.value)
                          }
                          onValueChange?.(item.value)
                          if (isMobile) setOpenMobile(false)
                        }}
                      >
                        {item.icon}
                        <span>{item.label}</span>
                      </SidebarMenuButton>
                      {item.badge !== undefined && (
                        <SidebarMenuBadge>{item.badge}</SidebarMenuBadge>
                      )}
                    </SidebarMenuItem>
                  ))}
              </SidebarMenu>
            </SidebarGroupContent>
          </SidebarGroup>
        ))}
      </SidebarContent>
      {footer && (
        <SidebarFooter className="group-data-[collapsible=icon]:hidden">
          {footer}
        </SidebarFooter>
      )}
      <SidebarRail />
    </Sidebar>
  )
}

const nav: NavItem[] = [
  {
    value: "users",
    label: "Users",
    icon: (
      <UsersIcon />
    ),
  },
  {
    value: "roles",
    label: "Roles",
    icon: (
      <ShieldIcon />
    ),
  },
  {
    value: "log",
    label: "Activity",
    icon: (
      <ChartBarIcon />
    ),
  },
  {
    value: "settings",
    label: "Settings",
    icon: (
      <GearIcon />
    ),
  },
]

const roles = ["Admin", "Editor", "Support", "Viewer"]

type Status = "Active" | "Suspended" | "Invited"

type User = {
  name: string
  email: string
  role: string
  status: Status
  seen: string
  hue: number
}

const initialUsers: User[] = [
  {
    name: "Jamie Rivera",
    email: "jamie@example.com",
    role: "Admin",
    status: "Active",
    seen: "2 min ago",
    hue: 250,
  },
  {
    name: "Morgan Lee",
    email: "morgan@example.com",
    role: "Editor",
    status: "Active",
    seen: "1 hr ago",
    hue: 30,
  },
  {
    name: "Riley Chen",
    email: "riley@example.com",
    role: "Support",
    status: "Active",
    seen: "Yesterday",
    hue: 150,
  },
  {
    name: "Taylor Kim",
    email: "taylor@example.com",
    role: "Viewer",
    status: "Suspended",
    seen: "3 days ago",
    hue: 200,
  },
  {
    name: "Avery Brooks",
    email: "avery@example.com",
    role: "Editor",
    status: "Invited",
    seen: "—",
    hue: 330,
  },
  {
    name: "Jordan Park",
    email: "jordan@example.com",
    role: "Support",
    status: "Active",
    seen: "5 hr ago",
    hue: 100,
  },
]

const initials = (name: string) =>
  name
    .split(" ")
    .map((part) => part[0])
    .join("")

export function UsersTable() {
  const [users, setUsers] = React.useState(initialUsers)
  const [query, setQuery] = React.useState("")
  const [role, setRole] = React.useState("All")
  const [selected, setSelected] = React.useState<string[]>([])
  const list = users.filter(
    (user) =>
      (role === "All" || user.role === role) &&
      (!query ||
        `${user.name} ${user.email}`
          .toLowerCase()
          .includes(query.toLowerCase()))
  )

  const toggle = (email: string) =>
    setSelected((current) =>
      current.includes(email)
        ? current.filter((item) => item !== email)
        : [...current, email]
    )

  const bulk = (patch: Partial<User>) => {
    setUsers((current) =>
      current.map((user) =>
        selected.includes(user.email) ? { ...user, ...patch } : user
      )
    )
    setSelected([])
  }

  return (
    <SidebarProvider>
      <NavSidebar
        items={nav}
        defaultValue="users"
        header={
          <p className="flex items-center gap-2 px-2 pt-1 text-sm font-semibold">
            <span className="font-bold">suiss</span> Admin
          </p>
        }
      />
      <SidebarInset>
        <div className="flex flex-col gap-5 px-5 py-5.5 md:px-7">
          <div className="flex flex-wrap items-start gap-3">
            <SidebarTrigger className="-ms-2 mt-0.5" />
            <div className="flex flex-1 flex-col">
              <h1 className="text-3xl font-semibold tracking-tight">Users</h1>
              <p className="text-sm text-muted-foreground">
                {users.length} people
              </p>
            </div>
            <div className="flex w-full items-center gap-2 sm:w-auto">
              <InputGroup className="h-8 flex-1 sm:w-48">
                <InputGroupAddon>
                  <MagnifyingGlassIcon />
                </InputGroupAddon>
                <InputGroupInput
                  type="search"
                  value={query}
                  onChange={(event) => setQuery(event.target.value)}
                  placeholder="Search"
                  aria-label="Search users"
                  className="text-sm"
                />
              </InputGroup>
              <Button size="sm" className="gap-1">
                <PlusIcon className="size-3.25" />
                Invite
              </Button>
            </div>
          </div>
          <ToggleGroup
            spacing={0}
            value={[role]}
            onValueChange={(value: string[]) => {
              if (!value[0]) return
              setRole(value[0])
              setSelected([])
            }}
            aria-label="Role"
            className="max-w-full overflow-x-auto"
          >
            {["All", ...roles].map((item) => (
              <ToggleGroupItem
                key={item}
                value={item}
                size="sm"
                className="px-4 md:px-8"
              >
                {item}
              </ToggleGroupItem>
            ))}
          </ToggleGroup>
          <div className="overflow-hidden rounded-xl bg-muted">
            <Table>
              <TableHeader>
                <TableRow className="border-border hover:bg-transparent">
                  <TableHead className="ps-4 text-xs font-normal text-muted-foreground">
                    Name
                  </TableHead>
                  <TableHead className="hidden text-xs font-normal text-muted-foreground sm:table-cell">
                    Role
                  </TableHead>
                  <TableHead className="text-xs font-normal text-muted-foreground">
                    Status
                  </TableHead>
                  <TableHead className="hidden pe-4 text-end text-xs font-normal text-muted-foreground md:table-cell">
                    Last sign-in
                  </TableHead>
                </TableRow>
              </TableHeader>
              <TableBody>
                {list.map((user) => {
                  const isSelected = selected.includes(user.email)
                  return (
                    <TableRow
                      key={user.email}
                      data-selected={isSelected ? "true" : undefined}
                      onClick={() => toggle(user.email)}
                      className="group/row cursor-default border-0 select-none not-data-selected:even:bg-background/60 not-data-selected:hover:bg-foreground/5 has-focus-visible:border-ring has-focus-visible:ring-3 has-focus-visible:ring-ring/50 data-selected:bg-primary data-selected:text-primary-foreground"
                    >
                      <TableCell className="ps-4">
                        <span className="flex items-center gap-2.5">
                          <Checkbox
                            checked={isSelected}
                            onClick={(event) => event.stopPropagation()}
                            onCheckedChange={() => toggle(user.email)}
                            aria-label={`Select ${user.name}`}
                            className="absolute sr-only"
                          />
                          <Avatar className="size-7">
                            <AvatarFallback
                              style={{
                                background: `linear-gradient(160deg, oklch(0.78 0.1 ${user.hue}), oklch(0.6 0.14 ${user.hue + 30}))`,
                              }}
                              className="text-xs text-white"
                            >
                              {initials(user.name)}
                            </AvatarFallback>
                          </Avatar>
                          <span className="flex min-w-0 flex-col">
                            <span className="text-sm font-semibold">
                              {user.name}
                            </span>
                            <span className="truncate text-xs text-muted-foreground group-data-selected/row:text-primary-foreground">
                              {user.email}
                            </span>
                          </span>
                        </span>
                      </TableCell>
                      <TableCell className="hidden text-sm sm:table-cell">
                        {user.role}
                      </TableCell>
                      <TableCell
                        data-status={isSelected ? undefined : user.status}
                        className="text-sm data-[status=Invited]:text-muted-foreground data-[status=Suspended]:text-destructive group-data-selected/row:text-primary-foreground"
                      >
                        {user.status}
                      </TableCell>
                      <TableCell className="hidden pe-4 text-end text-sm text-muted-foreground group-data-selected/row:text-primary-foreground md:table-cell">
                        {user.seen}
                      </TableCell>
                    </TableRow>
                  )
                })}
              </TableBody>
            </Table>
            {list.length === 0 && (
              <p className="p-6 text-center text-sm text-muted-foreground">
                No matching users.
              </p>
            )}
            <div className="flex min-h-12 flex-wrap items-center gap-2 border-t border-border py-2 ps-4 pe-3">
              <span
                className="flex-1 text-xs text-muted-foreground"
                aria-live="polite"
              >
                {selected.length
                  ? `${selected.length} selected`
                  : "Click rows to select"}
              </span>
              {selected.length > 0 && (
                <>
                  <Button
                    size="sm"
                    variant="secondary"
                    onClick={() => bulk({ role: "Editor" })}
                  >
                    Make Editor
                  </Button>
                  <Button
                    size="sm"
                    variant="secondary"
                    onClick={() => bulk({ status: "Active" })}
                  >
                    Activate
                  </Button>
                  <Button
                    size="sm"
                    variant="destructive"
                    onClick={() => bulk({ status: "Suspended" })}
                  >
                    Suspend
                  </Button>
                </>
              )}
            </div>
          </div>
        </div>
      </SidebarInset>
    </SidebarProvider>
  )
}
```

A users table with search, role filters and row selection; selecting rows reveals bulk actions to make editors, activate or suspend. Role and last sign-in columns appear as the screen widens, and the search and Invite button take the full width on mobile. It sits in a `Sidebar` with admin navigation that opens as a sheet from the trigger on mobile. Use it for any admin list that needs bulk actions.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/users-table
```

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add blocks users-table
```

### Manual

1. Install the dependencies:

```bash title="Terminal"
npm install @phosphor-icons/react
```

2. Add the pieces it builds on: [Avatar](https://ui.suiss.app/docs/components/avatar), [Button](https://ui.suiss.app/docs/components/button), [Checkbox](https://ui.suiss.app/docs/components/checkbox), [Input Group](https://ui.suiss.app/docs/components/input-group), [Sidebar](https://ui.suiss.app/docs/components/sidebar), [Table](https://ui.suiss.app/docs/components/table), [Toggle Group](https://ui.suiss.app/docs/components/toggle-group).

3. Copy the source into `components/blocks/users-table.tsx`:

```tsx title="components/blocks/users-table.tsx"
"use client"

import {
  ChartBarIcon,
  GearIcon,
  MagnifyingGlassIcon,
  PlusIcon,
  ShieldIcon,
  UsersIcon,
} from "@phosphor-icons/react"
import * as React from "react"

import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group"
import {
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarGroup,
  SidebarGroupContent,
  SidebarGroupLabel,
  SidebarHeader,
  SidebarInset,
  SidebarMenu,
  SidebarMenuBadge,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarProvider,
  SidebarRail,
  SidebarTrigger,
  useSidebar,
} from "@/components/ui/sidebar"
import {
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
} from "@/components/ui/table"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

type NavItem = {
  value: string
  label: string
  icon: React.ReactNode
  badge?: React.ReactNode
  group?: string
}

function NavSidebar({
  items,
  value: valueProp,
  defaultValue,
  onValueChange,
  header,
  footer,
}: {
  items: NavItem[]
  value?: string
  defaultValue?: string
  onValueChange?: (value: string) => void
  header?: React.ReactNode
  footer?: React.ReactNode
}) {
  const { isMobile, setOpenMobile } = useSidebar()
  const [uncontrolledValue, setUncontrolledValue] = React.useState(
    defaultValue ?? items[0]?.value
  )
  const value = valueProp ?? uncontrolledValue
  const groups = [...new Set(items.map((item) => item.group ?? ""))]

  return (
    <Sidebar collapsible="icon">
      {header && (
        <SidebarHeader className="group-data-[collapsible=icon]:hidden">
          {header}
        </SidebarHeader>
      )}
      <SidebarContent>
        {groups.map((group) => (
          <SidebarGroup key={group}>
            {group && <SidebarGroupLabel>{group}</SidebarGroupLabel>}
            <SidebarGroupContent>
              <SidebarMenu>
                {items
                  .filter((item) => (item.group ?? "") === group)
                  .map((item) => (
                    <SidebarMenuItem key={item.value}>
                      <SidebarMenuButton
                        isActive={item.value === value}
                        tooltip={item.label}
                        aria-current={item.value === value ? "page" : undefined}
                        onClick={() => {
                          if (valueProp === undefined) {
                            setUncontrolledValue(item.value)
                          }
                          onValueChange?.(item.value)
                          if (isMobile) setOpenMobile(false)
                        }}
                      >
                        {item.icon}
                        <span>{item.label}</span>
                      </SidebarMenuButton>
                      {item.badge !== undefined && (
                        <SidebarMenuBadge>{item.badge}</SidebarMenuBadge>
                      )}
                    </SidebarMenuItem>
                  ))}
              </SidebarMenu>
            </SidebarGroupContent>
          </SidebarGroup>
        ))}
      </SidebarContent>
      {footer && (
        <SidebarFooter className="group-data-[collapsible=icon]:hidden">
          {footer}
        </SidebarFooter>
      )}
      <SidebarRail />
    </Sidebar>
  )
}

const nav: NavItem[] = [
  {
    value: "users",
    label: "Users",
    icon: (
      <UsersIcon />
    ),
  },
  {
    value: "roles",
    label: "Roles",
    icon: (
      <ShieldIcon />
    ),
  },
  {
    value: "log",
    label: "Activity",
    icon: (
      <ChartBarIcon />
    ),
  },
  {
    value: "settings",
    label: "Settings",
    icon: (
      <GearIcon />
    ),
  },
]

const roles = ["Admin", "Editor", "Support", "Viewer"]

type Status = "Active" | "Suspended" | "Invited"

type User = {
  name: string
  email: string
  role: string
  status: Status
  seen: string
  hue: number
}

const initialUsers: User[] = [
  {
    name: "Jamie Rivera",
    email: "jamie@example.com",
    role: "Admin",
    status: "Active",
    seen: "2 min ago",
    hue: 250,
  },
  {
    name: "Morgan Lee",
    email: "morgan@example.com",
    role: "Editor",
    status: "Active",
    seen: "1 hr ago",
    hue: 30,
  },
  {
    name: "Riley Chen",
    email: "riley@example.com",
    role: "Support",
    status: "Active",
    seen: "Yesterday",
    hue: 150,
  },
  {
    name: "Taylor Kim",
    email: "taylor@example.com",
    role: "Viewer",
    status: "Suspended",
    seen: "3 days ago",
    hue: 200,
  },
  {
    name: "Avery Brooks",
    email: "avery@example.com",
    role: "Editor",
    status: "Invited",
    seen: "—",
    hue: 330,
  },
  {
    name: "Jordan Park",
    email: "jordan@example.com",
    role: "Support",
    status: "Active",
    seen: "5 hr ago",
    hue: 100,
  },
]

const initials = (name: string) =>
  name
    .split(" ")
    .map((part) => part[0])
    .join("")

export function UsersTable() {
  const [users, setUsers] = React.useState(initialUsers)
  const [query, setQuery] = React.useState("")
  const [role, setRole] = React.useState("All")
  const [selected, setSelected] = React.useState<string[]>([])
  const list = users.filter(
    (user) =>
      (role === "All" || user.role === role) &&
      (!query ||
        `${user.name} ${user.email}`
          .toLowerCase()
          .includes(query.toLowerCase()))
  )

  const toggle = (email: string) =>
    setSelected((current) =>
      current.includes(email)
        ? current.filter((item) => item !== email)
        : [...current, email]
    )

  const bulk = (patch: Partial<User>) => {
    setUsers((current) =>
      current.map((user) =>
        selected.includes(user.email) ? { ...user, ...patch } : user
      )
    )
    setSelected([])
  }

  return (
    <SidebarProvider>
      <NavSidebar
        items={nav}
        defaultValue="users"
        header={
          <p className="flex items-center gap-2 px-2 pt-1 text-sm font-semibold">
            <span className="font-bold">suiss</span> Admin
          </p>
        }
      />
      <SidebarInset>
        <div className="flex flex-col gap-5 px-5 py-5.5 md:px-7">
          <div className="flex flex-wrap items-start gap-3">
            <SidebarTrigger className="-ms-2 mt-0.5" />
            <div className="flex flex-1 flex-col">
              <h1 className="text-3xl font-semibold tracking-tight">Users</h1>
              <p className="text-sm text-muted-foreground">
                {users.length} people
              </p>
            </div>
            <div className="flex w-full items-center gap-2 sm:w-auto">
              <InputGroup className="h-8 flex-1 sm:w-48">
                <InputGroupAddon>
                  <MagnifyingGlassIcon />
                </InputGroupAddon>
                <InputGroupInput
                  type="search"
                  value={query}
                  onChange={(event) => setQuery(event.target.value)}
                  placeholder="Search"
                  aria-label="Search users"
                  className="text-sm"
                />
              </InputGroup>
              <Button size="sm" className="gap-1">
                <PlusIcon className="size-3.25" />
                Invite
              </Button>
            </div>
          </div>
          <ToggleGroup
            spacing={0}
            value={[role]}
            onValueChange={(value: string[]) => {
              if (!value[0]) return
              setRole(value[0])
              setSelected([])
            }}
            aria-label="Role"
            className="max-w-full overflow-x-auto"
          >
            {["All", ...roles].map((item) => (
              <ToggleGroupItem
                key={item}
                value={item}
                size="sm"
                className="px-4 md:px-8"
              >
                {item}
              </ToggleGroupItem>
            ))}
          </ToggleGroup>
          <div className="overflow-hidden rounded-xl bg-muted">
            <Table>
              <TableHeader>
                <TableRow className="border-border hover:bg-transparent">
                  <TableHead className="ps-4 text-xs font-normal text-muted-foreground">
                    Name
                  </TableHead>
                  <TableHead className="hidden text-xs font-normal text-muted-foreground sm:table-cell">
                    Role
                  </TableHead>
                  <TableHead className="text-xs font-normal text-muted-foreground">
                    Status
                  </TableHead>
                  <TableHead className="hidden pe-4 text-end text-xs font-normal text-muted-foreground md:table-cell">
                    Last sign-in
                  </TableHead>
                </TableRow>
              </TableHeader>
              <TableBody>
                {list.map((user) => {
                  const isSelected = selected.includes(user.email)
                  return (
                    <TableRow
                      key={user.email}
                      data-selected={isSelected ? "true" : undefined}
                      onClick={() => toggle(user.email)}
                      className="group/row cursor-default border-0 select-none not-data-selected:even:bg-background/60 not-data-selected:hover:bg-foreground/5 has-focus-visible:border-ring has-focus-visible:ring-3 has-focus-visible:ring-ring/50 data-selected:bg-primary data-selected:text-primary-foreground"
                    >
                      <TableCell className="ps-4">
                        <span className="flex items-center gap-2.5">
                          <Checkbox
                            checked={isSelected}
                            onClick={(event) => event.stopPropagation()}
                            onCheckedChange={() => toggle(user.email)}
                            aria-label={`Select ${user.name}`}
                            className="absolute sr-only"
                          />
                          <Avatar className="size-7">
                            <AvatarFallback
                              style={{
                                background: `linear-gradient(160deg, oklch(0.78 0.1 ${user.hue}), oklch(0.6 0.14 ${user.hue + 30}))`,
                              }}
                              className="text-xs text-white"
                            >
                              {initials(user.name)}
                            </AvatarFallback>
                          </Avatar>
                          <span className="flex min-w-0 flex-col">
                            <span className="text-sm font-semibold">
                              {user.name}
                            </span>
                            <span className="truncate text-xs text-muted-foreground group-data-selected/row:text-primary-foreground">
                              {user.email}
                            </span>
                          </span>
                        </span>
                      </TableCell>
                      <TableCell className="hidden text-sm sm:table-cell">
                        {user.role}
                      </TableCell>
                      <TableCell
                        data-status={isSelected ? undefined : user.status}
                        className="text-sm data-[status=Invited]:text-muted-foreground data-[status=Suspended]:text-destructive group-data-selected/row:text-primary-foreground"
                      >
                        {user.status}
                      </TableCell>
                      <TableCell className="hidden pe-4 text-end text-sm text-muted-foreground group-data-selected/row:text-primary-foreground md:table-cell">
                        {user.seen}
                      </TableCell>
                    </TableRow>
                  )
                })}
              </TableBody>
            </Table>
            {list.length === 0 && (
              <p className="p-6 text-center text-sm text-muted-foreground">
                No matching users.
              </p>
            )}
            <div className="flex min-h-12 flex-wrap items-center gap-2 border-t border-border py-2 ps-4 pe-3">
              <span
                className="flex-1 text-xs text-muted-foreground"
                aria-live="polite"
              >
                {selected.length
                  ? `${selected.length} selected`
                  : "Click rows to select"}
              </span>
              {selected.length > 0 && (
                <>
                  <Button
                    size="sm"
                    variant="secondary"
                    onClick={() => bulk({ role: "Editor" })}
                  >
                    Make Editor
                  </Button>
                  <Button
                    size="sm"
                    variant="secondary"
                    onClick={() => bulk({ status: "Active" })}
                  >
                    Activate
                  </Button>
                  <Button
                    size="sm"
                    variant="destructive"
                    onClick={() => bulk({ status: "Suspended" })}
                  >
                    Suspend
                  </Button>
                </>
              )}
            </div>
          </div>
        </div>
      </SidebarInset>
    </SidebarProvider>
  )
}
```

4. Update the import paths to match your project.

## Usage

```tsx
import { UsersTable } from "@/components/blocks/users-table"
```

```tsx
<UsersTable />
```

Replace `initialUsers` with your users, each with a name, email, role, status, last-seen text and avatar hue. Call your API in `bulk`, which applies a change to every selected user. Search and filtering run in the browser, so move them server-side for large lists, and give Invite a handler.

## Notes

Each row has a hidden checkbox labeled with the user's name, so selection works from the keyboard as well as by clicking the row.

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/blocks-users-table--default.light.webp)

```tsx title="uniwind/components/blocks/users-table.tsx"
import { cn } from "cn"
import {
  ChartBarIcon,
  GearIcon,
  MagnifyingGlassIcon,
  type Icon as PhosphorIcon,
  PlusIcon,
  ShieldIcon,
  UsersIcon,
} from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import {
  SelectionList,
  SelectionListBar,
  SelectionListItem,
  SelectionListTrigger,
} from "@/components/interactions/selection-list"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Chip, ChipGroup } from "@/components/ui/chip"
import {
  Header,
  HeaderActions,
  HeaderLargeTitle,
  HeaderScrollView,
  HeaderTitle,
  useHeaderScroll,
} from "@/components/ui/header"
import { Icon } from "@/components/ui/icon"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group"
import { Screen } from "@/components/ui/screen"
import { TabBar, TabBarItem, TabBarList } from "@/components/ui/tab-bar"
import { Text } from "@/components/ui/text"

const SECTIONS: { value: string; label: string; icon: PhosphorIcon }[] = [
  { value: "users", label: "Users", icon: UsersIcon },
  { value: "roles", label: "Roles", icon: ShieldIcon },
  { value: "log", label: "Activity", icon: ChartBarIcon },
  { value: "settings", label: "Settings", icon: GearIcon },
]

const ROLES = ["Admin", "Editor", "Support", "Viewer"]

type Status = "Active" | "Suspended" | "Invited"

type User = {
  name: string
  email: string
  role: string
  status: Status
  seen: string
  tint: string
}

const INITIAL_USERS: User[] = [
  {
    name: "Jamie Rivera",
    email: "jamie@example.com",
    role: "Admin",
    status: "Active",
    seen: "2 min ago",
    tint: "bg-indigo-500",
  },
  {
    name: "Morgan Lee",
    email: "morgan@example.com",
    role: "Editor",
    status: "Active",
    seen: "1 hr ago",
    tint: "bg-orange-500",
  },
  {
    name: "Riley Chen",
    email: "riley@example.com",
    role: "Support",
    status: "Active",
    seen: "Yesterday",
    tint: "bg-green-500",
  },
  {
    name: "Taylor Kim",
    email: "taylor@example.com",
    role: "Viewer",
    status: "Suspended",
    seen: "3 days ago",
    tint: "bg-cyan-500",
  },
  {
    name: "Avery Brooks",
    email: "avery@example.com",
    role: "Editor",
    status: "Invited",
    seen: "—",
    tint: "bg-pink-500",
  },
  {
    name: "Jordan Park",
    email: "jordan@example.com",
    role: "Support",
    status: "Active",
    seen: "5 hr ago",
    tint: "bg-teal-400",
  },
]

const STATUS_CLASS: Record<Status, string> = {
  Active: "text-foreground",
  Suspended: "text-destructive",
  Invited: "text-muted-foreground",
}

const initials = (name: string) =>
  name
    .split(" ")
    .map((part) => part[0])
    .join("")

function AdminTabBar({
  value,
  onValueChange,
}: {
  value: string
  onValueChange: (value: string) => void
}) {
  return (
    <TabBar value={value} onValueChange={(next) => onValueChange(next)}>
      <TabBarList>
        {SECTIONS.map((section) => (
          <TabBarItem
            key={section.value}
            value={section.value}
            icon={section.icon}
          >
            {section.label}
          </TabBarItem>
        ))}
      </TabBarList>
    </TabBar>
  )
}

function OtherSection({ value, home }: { value: string; home: string }) {
  const label = SECTIONS.find((section) => section.value === value)?.label
  return (
    <View className="flex-1 gap-2 px-4 pt-13">
      <Text role="heading" className="text-3xl font-bold">
        {label}
      </Text>
      <Text className="text-lg text-muted-foreground">
        {`This section isn't part of the sample. Choose ${home}.`}
      </Text>
    </View>
  )
}

function UserRow({ user, first }: { user: User; first: boolean }) {
  return (
    <SelectionListItem
      value={user.email}
      accessibilityLabel={`${user.name}, ${user.role}, ${user.status}`}
      className={cn(!first && "border-t border-border")}
    >
      <View className="min-w-0 flex-1 flex-row items-center gap-3 px-4 py-2.5">
        <Avatar size="sm" className="size-8">
          <AvatarFallback className={user.tint}>
            {initials(user.name)}
          </AvatarFallback>
        </Avatar>
        <View className="min-w-0 flex-1">
          <Text numberOfLines={1} className="text-sm font-semibold">
            {user.name}
          </Text>
          <Text numberOfLines={1} className="text-xs text-muted-foreground">
            {user.email}
          </Text>
        </View>
        <View className="items-end gap-1">
          <Badge variant="secondary" className="bg-secondary px-2">
            <Text>{user.role}</Text>
          </Badge>
          <Text className={cn("text-xs", STATUS_CLASS[user.status])}>
            {user.status === "Active" ? user.seen : user.status}
          </Text>
        </View>
      </View>
    </SelectionListItem>
  )
}

function UsersTable({
  onInvite,
  onFilterChange,
  onBulkUpdate,
  onNavigate,
}: {
  onInvite?: () => void
  onFilterChange?: (role: string) => void
  onBulkUpdate?: (
    emails: string[],
    patch: { role?: string; status?: Status }
  ) => void
  onNavigate?: (section: string) => void
}) {
  const [section, setSection] = React.useState("users")
  const [users, setUsers] = React.useState(INITIAL_USERS)
  const [query, setQuery] = React.useState("")
  const [role, setRole] = React.useState("All")
  const [editing, setEditing] = React.useState(false)
  const [selected, setSelected] = React.useState<string[]>([])
  const scroll = useHeaderScroll()
  const list = users.filter(
    (user) =>
      (role === "All" || user.role === role) &&
      (!query ||
        `${user.name} ${user.email}`
          .toLowerCase()
          .includes(query.toLowerCase()))
  )

  const bulk = (patch: { role?: string; status?: Status }) => {
    onBulkUpdate?.(selected, patch)
    setUsers((current) =>
      current.map((user) =>
        selected.includes(user.email) ? { ...user, ...patch } : user
      )
    )
    setSelected([])
    setEditing(false)
  }

  const selection = selected.length
    ? `${selected.length} selected`
    : "Tap rows to select"
  const summary = editing ? selection : `${users.length} people`

  if (section !== "users") {
    return (
      <Screen edges={["top", "right", "left"]}>
        <OtherSection value={section} home="Users" />
        <AdminTabBar
          value={section}
          onValueChange={(value) => {
            setSection(value)
            onNavigate?.(value)
          }}
        />
      </Screen>
    )
  }

  return (
    <Screen edges={["top", "right", "left"]}>
      <SelectionList
        editing={editing}
        onEditingChange={setEditing}
        value={selected}
        onValueChange={setSelected}
        className="flex-1"
      >
        <Header scroll={scroll}>
          <HeaderTitle>Users</HeaderTitle>
          <HeaderActions>
            <SelectionListTrigger />
            {editing ? null : (
              <Button
                variant="ghost"
                size="icon"
                accessibilityLabel="Invite"
                onPress={onInvite}
              >
                <Icon
                  as={PlusIcon}
                  weight="bold"
                  className="size-5 text-primary"
                />
              </Button>
            )}
          </HeaderActions>
        </Header>
        <HeaderScrollView
          scroll={scroll}
          keyboardShouldPersistTaps="handled"
          keyboardDismissMode="on-drag"
        >
          <HeaderLargeTitle
            subtitle={
              <Text
                accessibilityLiveRegion="polite"
                className="text-sm text-muted-foreground"
              >
                {summary}
              </Text>
            }
          >
            Users
          </HeaderLargeTitle>
          <View className="gap-3 pt-1 pb-28">
            <View className="px-4">
              <InputGroup className="h-9 rounded-[0.625rem]">
                <InputGroupAddon>
                  <Icon
                    as={MagnifyingGlassIcon}
                    weight="bold"
                    className="size-4 text-muted-foreground"
                  />
                </InputGroupAddon>
                <InputGroupInput
                  value={query}
                  onChangeText={setQuery}
                  placeholder="Search"
                  accessibilityLabel="Search users"
                  returnKeyType="search"
                  autoCorrect={false}
                  autoCapitalize="none"
                  clearButtonMode="while-editing"
                />
              </InputGroup>
            </View>
            <ChipGroup
              scrollable
              value={[role]}
              onValueChange={(value) => {
                if (!value[0]) return
                setRole(value[0])
                setSelected([])
                onFilterChange?.(value[0])
              }}
              accessibilityLabel="Role"
              className="px-4"
            >
              {["All", ...ROLES].map((item) => (
                <Chip key={item} value={item}>
                  {item}
                </Chip>
              ))}
            </ChipGroup>
            <View className="mx-4 overflow-hidden rounded-xl bg-muted">
              {list.map((user, index) => (
                <UserRow key={user.email} user={user} first={index === 0} />
              ))}
              {list.length === 0 ? (
                <Text className="p-6 text-center text-sm text-muted-foreground">
                  No matching users.
                </Text>
              ) : null}
            </View>
          </View>
        </HeaderScrollView>
        <SelectionListBar accessibilityLabel="Selection actions">
          <Button
            variant="plain"
            size="sm"
            disabled={selected.length === 0}
            onPress={() => bulk({ role: "Editor" })}
          >
            <Text>Make Editor</Text>
          </Button>
          <Button
            variant="plain"
            size="sm"
            disabled={selected.length === 0}
            onPress={() => bulk({ status: "Active" })}
          >
            <Text>Activate</Text>
          </Button>
          <Button
            variant="plain"
            size="sm"
            disabled={selected.length === 0}
            onPress={() => bulk({ status: "Suspended" })}
          >
            <Text
              className={
                selected.length === 0
                  ? "text-destructive opacity-50"
                  : "text-destructive"
              }
            >
              Suspend
            </Text>
          </Button>
        </SelectionListBar>
      </SelectionList>
      {editing ? null : (
        <AdminTabBar
          value={section}
          onValueChange={(value) => {
            setSection(value)
            onNavigate?.(value)
          }}
        />
      )}
    </Screen>
  )
}

export { UsersTable }
```

```swift title="swift/Blocks/UsersTable.swift"
import PhosphorSwift
import SwiftUI

public struct UsersTable: View {
  struct User: Identifiable, Equatable {
    let name: String
    let email: String
    var role: String
    var status: String
    let seen: String
    let tint: Color

    var id: String { email }
  }

  static let roles = ["Admin", "Editor", "Support", "Viewer"]

  static var initialUsers: [User] {
    [
      User(name: "Jamie Rivera", email: "jamie@example.com", role: "Admin", status: "Active", seen: "2 min ago", tint: Palette.indigo500),
      User(name: "Morgan Lee", email: "morgan@example.com", role: "Editor", status: "Active", seen: "1 hr ago", tint: Palette.orange500),
      User(name: "Riley Chen", email: "riley@example.com", role: "Support", status: "Active", seen: "Yesterday", tint: Palette.green500),
      User(name: "Taylor Kim", email: "taylor@example.com", role: "Viewer", status: "Suspended", seen: "3 days ago", tint: Palette.blue500),
      User(name: "Avery Brooks", email: "avery@example.com", role: "Editor", status: "Invited", seen: "—", tint: Palette.pink500),
      User(name: "Jordan Park", email: "jordan@example.com", role: "Support", status: "Active", seen: "5 hr ago", tint: Palette.teal400),
    ]
  }

  static func filtered(_ users: [User], role: String, query: String) -> [User] {
    users.filter { user in
      (role == "All" || user.role == role)
        && (query.isEmpty || "\(user.name) \(user.email)".lowercased().contains(query.lowercased()))
    }
  }

  private var onNavigate: ((String) -> Void)?
  private var onInvite: (() -> Void)?
  private var onFilterChange: ((String) -> Void)?
  private var onBulkUpdate: (([String], String?, String?) -> Void)?
  @State private var users: [User]
  @State private var query: String
  @State private var role: String?
  @State private var editing: Bool
  @State private var selected: Set<String>
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init(
    onInvite: (() -> Void)? = nil,
    onFilterChange: ((_ role: String) -> Void)? = nil,
    onBulkUpdate: ((_ emails: [String], _ role: String?, _ status: String?) -> Void)? = nil,
    onNavigate: ((_ section: String) -> Void)? = nil
  ) {
    self.init(role: "All")
    self.onInvite = onInvite
    self.onFilterChange = onFilterChange
    self.onBulkUpdate = onBulkUpdate
    self.onNavigate = onNavigate
  }

  init(role: String, query: String = "", selected: Set<String> = []) {
    _users = State(initialValue: Self.initialUsers)
    _query = State(initialValue: query)
    _role = State(initialValue: role)
    _editing = State(initialValue: !selected.isEmpty)
    _selected = State(initialValue: selected)
  }

  private var animation: Animation? {
    reduceMotion ? nil : .timingCurve(0.32, 0.72, 0, 1, duration: 0.4)
  }

  private var roleSelection: Binding<String?> {
    Binding(
      get: { role },
      set: { value in
        let next = value ?? "All"
        if next != role { onFilterChange?(next) }
        withAnimation(animation) {
          role = next
          selected = []
        }
      }
    )
  }

  public var body: some View {
    UsersTableShell(home: "users", onNavigate: onNavigate) {
      page
    }
  }

  @ViewBuilder private var page: some View {
    let list = Self.filtered(users, role: role ?? "All", query: query)
    Screen {
      SelectionList(isEditing: $editing, selection: $selected) {
        ZStack(alignment: .bottom) {
          HeaderScrollView {
            HeaderLargeTitle("Users", subtitle: "\(users.count) people")
            VStack(alignment: .leading, spacing: 16) {
              InputGroup {
                InputGroupAddon {
                  Ph.magnifyingGlass.regular.frame(width: 16, height: 16)
                }
                InputGroupInput("Search", text: $query)
                  .submitLabel(.search)
                  .textInputAutocapitalization(.never)
                  .autocorrectionDisabled()
                  .accessibilityLabel(Text("Search users"))
              }
              .padding(.horizontal, 16)
              ChipGroup(selection: roleSelection, scrollable: true) {
                ForEach(["All"] + Self.roles, id: \.self) { item in
                  Chip(LocalizedStringKey(item), value: item)
                }
              }
              .contentMargins(.horizontal, 16, for: .scrollContent)
              .accessibilityLabel(Text("Role"))
              VStack(alignment: .leading, spacing: 8) {
                VStack(spacing: 0) {
                  ForEach(Array(list.enumerated()), id: \.element.id) { index, user in
                    SelectionListItem(value: user.email) {
                      UsersTableRow(user: user, selected: selected.contains(user.email))
                        .contentShape(Rectangle())
                        .onTapGesture {
                          selected.insert(user.email)
                          editing = true
                        }
                    }
                    .overlay(alignment: .top) {
                      if index > 0 {
                        EsuissColor.border.frame(height: 1).padding(.leading, 16)
                      }
                    }
                  }
                  if list.isEmpty {
                    Text("No matching users.")
                      .scaledFont(.sm)
                      .foregroundStyle(EsuissColor.mutedForeground)
                      .frame(maxWidth: .infinity)
                      .padding(24)
                  }
                }
                .background(EsuissColor.muted, in: RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous))
                .clipShape(RoundedRectangle(cornerRadius: Radius.xl2, style: .continuous))
                Text(selected.isEmpty ? "Tap rows to select" : "\(selected.count) selected")
                  .scaledFont(.xs)
                  .foregroundStyle(EsuissColor.mutedForeground)
                  .padding(.horizontal, 4)
                  .contentTransition(.numericText())
              }
              .padding(.horizontal, 16)
            }
            .padding(.top, 4)
            .padding(.bottom, 96)
          } header: {
            Header {
              HeaderTitle("Users")
            } leading: {
              SelectionListTrigger()
            } trailing: {
              Button {
                onInvite?()
              } label: {
                SwiftUI.Label {
                  Text("Invite")
                } icon: {
                  Ph.plus.bold.frame(width: 13, height: 13)
                }
              }
              .buttonStyle(.variant(.default, size: .sm))
            }
          }
          SelectionListBar {
            Button("Make Editor") { bulk(role: "Editor") }
              .buttonStyle(.variant(.secondary, size: .sm, block: true))
            Button("Activate") { bulk(status: "Active") }
              .buttonStyle(.variant(.secondary, size: .sm, block: true))
            Button("Suspend") { bulk(status: "Suspended") }
              .buttonStyle(.variant(.destructive, size: .sm, block: true))
          }
          .disabled(selected.isEmpty)
        }
      }
    }
  }

  private func bulk(role: String? = nil, status: String? = nil) {
    onBulkUpdate?(users.map(\.email).filter(selected.contains), role, status)
    withAnimation(animation) {
      for index in users.indices where selected.contains(users[index].email) {
        if let role { users[index].role = role }
        if let status { users[index].status = status }
      }
      selected = []
      editing = false
    }
  }
}

private struct UsersTableRow: View {
  let user: UsersTable.User
  let selected: Bool

  var body: some View {
    HStack(spacing: 10) {
      Text(verbatim: user.name.split(separator: " ").compactMap(\.first).map(String.init).joined())
        .scaledFont(.xs, weight: .semibold)
        .lineLimit(1)
        .minimumScaleFactor(0.5)
        .foregroundStyle(EsuissColor.primaryForeground)
        .frame(width: 32, height: 32)
        .background(user.tint, in: Circle())
        .accessibilityHidden(true)
      VStack(alignment: .leading, spacing: 1) {
        Text(verbatim: user.name)
          .scaledFont(.sm, weight: .semibold)
        Text(verbatim: user.email)
          .scaledFont(.xs)
          .foregroundStyle(EsuissColor.mutedForeground)
      }
      .lineLimit(1)
      .frame(maxWidth: .infinity, alignment: .leading)
      VStack(alignment: .trailing, spacing: 3) {
        Badge(LocalizedStringKey(user.role), variant: .secondary)
        Text(LocalizedStringKey(user.status))
          .scaledFont(.xs)
          .foregroundStyle(user.status == "Suspended" ? EsuissColor.destructive : user.status == "Invited" ? EsuissColor.mutedForeground : EsuissColor.foreground)
      }
    }
    .foregroundStyle(EsuissColor.foreground)
    .padding(.horizontal, 16)
    .padding(.vertical, 10)
    .frame(minHeight: 56)
    .accessibilityElement(children: .combine)
  }
}

private struct UsersTableShell<Content: View>: View {
  let home: String
  let onNavigate: ((String) -> Void)?
  let content: Content

  @State private var tab: String

  init(home: String, onNavigate: ((String) -> Void)?, @ViewBuilder content: () -> Content) {
    self.home = home
    self.onNavigate = onNavigate
    self.content = content()
    _tab = State(initialValue: home)
  }

  private static var sections: [(value: String, label: LocalizedStringKey, icon: Image, active: Image)] {
    [
      ("users", "Users", Ph.users.regular, Ph.users.fill),
      ("roles", "Roles", Ph.shield.regular, Ph.shield.fill),
      ("log", "Activity", Ph.chartBar.regular, Ph.chartBar.fill),
      ("settings", "Settings", Ph.gear.regular, Ph.gear.fill),
    ]
  }

  var body: some View {
    let selection = Binding<String>(
      get: { tab },
      set: { value in
        guard value != tab else { return }
        onNavigate?(value)
        tab = value
      }
    )
    Screen {
      ZStack {
        content
          .opacity(tab == home ? 1 : 0)
          .allowsHitTesting(tab == home)
          .accessibilityHidden(tab != home)
        if tab != home {
          Empty {
            EmptyHeader {
              EmptyDescription("This section isn't part of the sample. Choose \(Text(Self.sections.first { $0.value == home }?.label ?? "")).")
            }
          }
          .background(EsuissColor.background)
        }
      }
      .frame(maxHeight: .infinity)
      TabBar(selection: selection) {
        ForEach(Self.sections, id: \.value) { section in
          TabBarItem(section.label, value: section.value, icon: section.icon, activeIcon: section.active)
        }
      }
    }
  }
}
```

```kt title="kotlin/blocks/UsersTable.kt"
package com.esuiss.ui.blocks

import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.adamglin.PhosphorIcons
import com.adamglin.phosphoricons.Bold
import com.adamglin.phosphoricons.Fill
import com.adamglin.phosphoricons.Regular
import com.adamglin.phosphoricons.bold.Plus
import com.adamglin.phosphoricons.fill.ChartBar as FillChartBar
import com.adamglin.phosphoricons.fill.Gear as FillGear
import com.adamglin.phosphoricons.fill.Shield as FillShield
import com.adamglin.phosphoricons.fill.Users as FillUsers
import com.adamglin.phosphoricons.regular.ChartBar
import com.adamglin.phosphoricons.regular.Gear
import com.adamglin.phosphoricons.regular.MagnifyingGlass
import com.adamglin.phosphoricons.regular.Shield
import com.adamglin.phosphoricons.regular.Users
import com.esuiss.ui.components.Badge
import com.esuiss.ui.components.BadgeVariant
import com.esuiss.ui.components.Button
import com.esuiss.ui.components.ButtonSize
import com.esuiss.ui.components.ButtonVariant
import com.esuiss.ui.components.Chip
import com.esuiss.ui.components.ChipGroup
import com.esuiss.ui.components.Header
import com.esuiss.ui.components.HeaderActions
import com.esuiss.ui.components.HeaderActionsSide
import com.esuiss.ui.components.HeaderLargeTitle
import com.esuiss.ui.components.HeaderTitle
import com.esuiss.ui.components.Icon
import com.esuiss.ui.components.InputGroup
import com.esuiss.ui.components.InputGroupAddon
import com.esuiss.ui.components.InputGroupInput
import com.esuiss.ui.components.LocalContentStyle
import com.esuiss.ui.components.Screen
import com.esuiss.ui.components.ScreenEdge
import com.esuiss.ui.components.TabBar
import com.esuiss.ui.components.TabBarItem
import com.esuiss.ui.components.rememberHeaderState
import com.esuiss.ui.interactions.SelectionList
import com.esuiss.ui.interactions.SelectionListBar
import com.esuiss.ui.interactions.SelectionListItem
import com.esuiss.ui.interactions.SelectionListScope
import com.esuiss.ui.interactions.SelectionListTrigger
import com.esuiss.ui.theme.Colors
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography

private class TableUser(
    val name: String,
    val email: String,
    role: String,
    status: String,
    val seen: String,
    val hue: Int,
) {
    var role by mutableStateOf(role)
    var status by mutableStateOf(status)
}

private fun tableUsers() = listOf(
    TableUser("Jamie Rivera", "jamie@example.com", "Admin", "Active", "2 min ago", 250),
    TableUser("Morgan Lee", "morgan@example.com", "Editor", "Active", "1 hr ago", 30),
    TableUser("Riley Chen", "riley@example.com", "Support", "Active", "Yesterday", 150),
    TableUser("Taylor Kim", "taylor@example.com", "Viewer", "Suspended", "3 days ago", 200),
    TableUser("Avery Brooks", "avery@example.com", "Editor", "Invited", "—", 330),
    TableUser("Jordan Park", "jordan@example.com", "Support", "Active", "5 hr ago", 100),
)

private val TableRoles = listOf("Admin", "Editor", "Support", "Viewer")

private fun tableTint(colors: Colors, hue: Int): Color = when (hue) {
    250 -> Palette.indigo500
    30 -> Palette.orange500
    150 -> Palette.green500
    200 -> Palette.blue500
    330 -> Palette.pink500
    else -> Palette.teal500
}

@Composable
fun UsersTable(
    modifier: Modifier = Modifier,
    onInvite: () -> Unit = {},
    onFilterChange: (role: String) -> Unit = {},
    onBulkUpdate: (emails: List<String>, role: String?, status: String?) -> Unit = { _, _, _ -> },
    onNavigate: (section: String) -> Unit = {},
) {
    val colors = EsuissTheme.colors
    val header = rememberHeaderState()
    val users = remember { mutableStateListOf<TableUser>().apply { addAll(tableUsers()) } }
    var query by rememberSaveable { mutableStateOf("") }
    var role by rememberSaveable { mutableStateOf("All") }
    var editing by rememberSaveable { mutableStateOf(false) }
    var selected by remember { mutableStateOf(listOf<String>()) }
    val list = users.filter {
        (role == "All" || it.role == role) &&
            (query.isBlank() || "${it.name} ${it.email}".contains(query.trim(), ignoreCase = true))
    }

    fun bulk(role: String? = null, status: String? = null) {
        onBulkUpdate(selected, role, status)
        users.filter { it.email in selected }.forEach { user ->
            if (role != null) user.role = role
            if (status != null) user.status = status
        }
        selected = emptyList()
        editing = false
    }

    Screen(modifier, edges = setOf(ScreenEdge.Top, ScreenEdge.Left, ScreenEdge.Right)) {
        AdminTabs("users", onNavigate) {
            SelectionList(
                editing = editing,
                onEditingChange = { editing = it },
                value = selected,
                onValueChange = { selected = it },
            ) {
                Column(
                    Modifier
                        .fillMaxSize()
                        .nestedScroll(header.nestedScrollConnection),
                ) {
                    Header(state = header) {
                        HeaderActions(side = HeaderActionsSide.Leading) { SelectionListTrigger() }
                        HeaderTitle("Users")
                        HeaderLargeTitle("Users", subtitle = "${users.size} people")
                        HeaderActions {
                            Button(onClick = onInvite, size = ButtonSize.Sm) {
                                Icon(PhosphorIcons.Bold.Plus, contentDescription = null, size = 13.dp)
                                BasicText("Invite", style = LocalContentStyle.current)
                            }
                        }
                    }
                    Column(
                        Modifier
                            .weight(1f)
                            .fillMaxWidth()
                            .verticalScroll(rememberScrollState())
                            .padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 96.dp),
                        verticalArrangement = Arrangement.spacedBy(16.dp),
                    ) {
                        InputGroup(Modifier.semantics { contentDescription = "Search users" }) {
                            InputGroupAddon {
                                Icon(PhosphorIcons.Regular.MagnifyingGlass, contentDescription = null, size = 16.dp, tint = colors.mutedForeground)
                            }
                            InputGroupInput(value = query, onValueChange = { query = it }, placeholder = "Search")
                        }
                        ChipGroup(
                            value = role,
                            onValueChange = { value ->
                                if (value != null && value != role) {
                                    onFilterChange(value)
                                    role = value
                                    selected = emptyList()
                                }
                            },
                            modifier = Modifier.semantics { contentDescription = "Role" },
                            scrollable = true,
                        ) {
                            (listOf("All") + TableRoles).forEach { Chip(it, it) }
                        }
                        Column(
                            Modifier
                                .fillMaxWidth()
                                .clip(RoundedCornerShape(Radius.xl2))
                                .background(colors.muted),
                        ) {
                            list.forEachIndexed { index, user ->
                                key(user.email) {
                                    if (index > 0) {
                                        Box(
                                            Modifier
                                                .padding(start = if (editing) 96.dp else 56.dp)
                                                .fillMaxWidth()
                                                .height(1.dp)
                                                .background(colors.border),
                                        )
                                    }
                                    UserTableRow(user)
                                }
                            }
                            if (list.isEmpty()) {
                                BasicText(
                                    "No matching users.",
                                    modifier = Modifier
                                        .fillMaxWidth()
                                        .padding(24.dp),
                                    style = Typography.sm.copy(color = colors.mutedForeground, textAlign = TextAlign.Center),
                                )
                            }
                        }
                        BasicText(
                            if (selected.isNotEmpty()) "${selected.size} selected" else "Tap rows to select",
                            modifier = Modifier.padding(horizontal = 4.dp),
                            style = Typography.xs.copy(color = colors.mutedForeground),
                        )
                    }
                }
                SelectionListBar {
                    val empty = this@SelectionList.selected.isEmpty()
                    Button(
                        text = "Make Editor",
                        onClick = { bulk(role = "Editor") },
                        modifier = Modifier.weight(1f),
                        variant = ButtonVariant.Plain,
                        size = ButtonSize.Sm,
                        enabled = !empty,
                    )
                    Button(
                        text = "Activate",
                        onClick = { bulk(status = "Active") },
                        modifier = Modifier.weight(1f),
                        variant = ButtonVariant.Plain,
                        size = ButtonSize.Sm,
                        enabled = !empty,
                    )
                    Button(
                        text = "Suspend",
                        onClick = { bulk(status = "Suspended") },
                        modifier = Modifier.weight(1f),
                        variant = ButtonVariant.Destructive,
                        size = ButtonSize.Sm,
                        enabled = !empty,
                    )
                }
            }
        }
    }
}

@Composable
private fun SelectionListScope.UserTableRow(user: TableUser) {
    val colors = EsuissTheme.colors
    val status = when (user.status) {
        "Suspended" -> colors.destructive
        "Invited" -> colors.mutedForeground
        else -> colors.foreground
    }
    SelectionListItem(user.email, contentDescription = user.name) {
        Row(
            modifier = Modifier
                .weight(1f)
                .clickable(
                    interactionSource = remember { MutableInteractionSource() },
                    indication = null,
                    role = Role.Checkbox,
                    onClickLabel = "Select ${user.name}",
                ) {
                    setEditing(true)
                    toggle(user.email)
                }
                .padding(horizontal = 16.dp, vertical = 10.dp),
            horizontalArrangement = Arrangement.spacedBy(12.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            Box(
                Modifier
                    .size(28.dp)
                    .background(tableTint(colors, user.hue), CircleShape),
                contentAlignment = Alignment.Center,
            ) {
                BasicText(
                    user.name.split(" ").mapNotNull { it.firstOrNull() }.joinToString(""),
                    style = Typography.xs.copy(color = Palette.white, fontWeight = FontWeight.Medium),
                )
            }
            Column(Modifier.weight(1f)) {
                BasicText(
                    user.name,
                    style = Typography.sm.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold),
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis,
                )
                BasicText(
                    user.email,
                    style = Typography.xs.copy(color = colors.mutedForeground),
                    maxLines = 1,
                    overflow = TextOverflow.Ellipsis,
                )
            }
            Column(horizontalAlignment = Alignment.End, verticalArrangement = Arrangement.spacedBy(2.dp)) {
                Badge(user.role, variant = BadgeVariant.Outline)
                BasicText(user.status, style = Typography.xs.copy(color = status))
            }
        }
    }
}

private val AdminSections = listOf("users" to "Users", "roles" to "Roles", "log" to "Activity", "settings" to "Settings")

@Composable
private fun AdminTabs(home: String, onNavigate: (String) -> Unit, content: @Composable () -> Unit) {
    val colors = EsuissTheme.colors
    var tab by rememberSaveable { mutableStateOf(home) }
    Column(Modifier.fillMaxSize()) {
        Box(
            Modifier
                .weight(1f)
                .fillMaxWidth(),
        ) {
            if (tab == home) {
                content()
            } else {
                BasicText(
                    "This section isn't part of the sample. Choose ${AdminSections.first { it.first == home }.second}.",
                    modifier = Modifier
                        .align(Alignment.Center)
                        .padding(32.dp),
                    style = Typography.base.copy(color = colors.mutedForeground, textAlign = TextAlign.Center),
                )
            }
        }
        TabBar(
            value = tab,
            onValueChange = {
                onNavigate(it)
                tab = it
            },
        ) {
            TabBarItem("users", "Users", PhosphorIcons.Regular.Users, selectedIcon = PhosphorIcons.Fill.FillUsers)
            TabBarItem("roles", "Roles", PhosphorIcons.Regular.Shield, selectedIcon = PhosphorIcons.Fill.FillShield)
            TabBarItem("log", "Activity", PhosphorIcons.Regular.ChartBar, selectedIcon = PhosphorIcons.Fill.FillChartBar)
            TabBarItem("settings", "Settings", PhosphorIcons.Regular.Gear, selectedIcon = PhosphorIcons.Fill.FillGear)
        }
    }
}
```

A collapsing `Header` holds a search `InputGroup` and a `ChipGroup` that filters by role. Rows show an `Avatar`, role and a `Badge` for status. The `SelectionList` trigger enters select mode, and the `SelectionListBar` applies bulk actions: make Editor, activate or suspend. An invite `Button` sits in the header, and the admin `TabBar` switches sections.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/users-table
```

See [Installation](https://ui.suiss.app/docs/installation) for the `components.json` setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add users-table
```

**SwiftUI**

```bash title="Terminal"
npx @esuiss/ui@latest add users-table --platform swift
```

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add users-table --platform kotlin
```

The CLI also adds what it builds on: [Avatar](https://ui.suiss.app/docs/components/avatar), [Badge](https://ui.suiss.app/docs/components/badge), [Button](https://ui.suiss.app/docs/components/button), [Chip](https://ui.suiss.app/docs/components/chip), [Header](https://ui.suiss.app/docs/components/header), [Icon](https://ui.suiss.app/docs/components/icon), [Input Group](https://ui.suiss.app/docs/components/input-group), [Screen](https://ui.suiss.app/docs/components/screen), [Selection List](https://ui.suiss.app/docs/interactions/selection-list), [Tab Bar](https://ui.suiss.app/docs/components/tab-bar), [Text](https://ui.suiss.app/docs/components/text).

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn phosphor-react-native react-native-svg
```

2. Add the pieces it builds on: [Avatar](https://ui.suiss.app/docs/components/avatar), [Badge](https://ui.suiss.app/docs/components/badge), [Button](https://ui.suiss.app/docs/components/button), [Chip](https://ui.suiss.app/docs/components/chip), [Header](https://ui.suiss.app/docs/components/header), [Icon](https://ui.suiss.app/docs/components/icon), [Input Group](https://ui.suiss.app/docs/components/input-group), [Screen](https://ui.suiss.app/docs/components/screen), [Selection List](https://ui.suiss.app/docs/interactions/selection-list), [Tab Bar](https://ui.suiss.app/docs/components/tab-bar), [Text](https://ui.suiss.app/docs/components/text).

3. Copy the source into `components/blocks/users-table.tsx`:

- `uniwind/components/blocks/users-table.tsx`: shown above

4. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn phosphor-react-native react-native-svg
```

2. Add the pieces it builds on: [Avatar](https://ui.suiss.app/docs/components/avatar), [Badge](https://ui.suiss.app/docs/components/badge), [Button](https://ui.suiss.app/docs/components/button), [Chip](https://ui.suiss.app/docs/components/chip), [Header](https://ui.suiss.app/docs/components/header), [Icon](https://ui.suiss.app/docs/components/icon), [Input Group](https://ui.suiss.app/docs/components/input-group), [Screen](https://ui.suiss.app/docs/components/screen), [Selection List](https://ui.suiss.app/docs/interactions/selection-list), [Tab Bar](https://ui.suiss.app/docs/components/tab-bar), [Text](https://ui.suiss.app/docs/components/text).

3. Copy the source into `components/blocks/users-table.tsx`:

```tsx title="nativewind/components/blocks/users-table.tsx"
import { cn } from "cn"
import {
  ChartBarIcon,
  GearIcon,
  MagnifyingGlassIcon,
  type Icon as PhosphorIcon,
  PlusIcon,
  ShieldIcon,
  UsersIcon,
} from "phosphor-react-native"
import * as React from "react"
import { View } from "react-native"
import {
  SelectionList,
  SelectionListBar,
  SelectionListItem,
  SelectionListTrigger,
} from "@/components/interactions/selection-list"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Chip, ChipGroup } from "@/components/ui/chip"
import {
  Header,
  HeaderActions,
  HeaderLargeTitle,
  HeaderScrollView,
  HeaderTitle,
  useHeaderScroll,
} from "@/components/ui/header"
import { Icon } from "@/components/ui/icon"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group"
import { Screen } from "@/components/ui/screen"
import { TabBar, TabBarItem, TabBarList } from "@/components/ui/tab-bar"
import { Text } from "@/components/ui/text"

const SECTIONS: { value: string; label: string; icon: PhosphorIcon }[] = [
  { value: "users", label: "Users", icon: UsersIcon },
  { value: "roles", label: "Roles", icon: ShieldIcon },
  { value: "log", label: "Activity", icon: ChartBarIcon },
  { value: "settings", label: "Settings", icon: GearIcon },
]

const ROLES = ["Admin", "Editor", "Support", "Viewer"]

type Status = "Active" | "Suspended" | "Invited"

type User = {
  name: string
  email: string
  role: string
  status: Status
  seen: string
  tint: string
}

const INITIAL_USERS: User[] = [
  {
    name: "Jamie Rivera",
    email: "jamie@example.com",
    role: "Admin",
    status: "Active",
    seen: "2 min ago",
    tint: "bg-indigo-500",
  },
  {
    name: "Morgan Lee",
    email: "morgan@example.com",
    role: "Editor",
    status: "Active",
    seen: "1 hr ago",
    tint: "bg-orange-500",
  },
  {
    name: "Riley Chen",
    email: "riley@example.com",
    role: "Support",
    status: "Active",
    seen: "Yesterday",
    tint: "bg-green-500",
  },
  {
    name: "Taylor Kim",
    email: "taylor@example.com",
    role: "Viewer",
    status: "Suspended",
    seen: "3 days ago",
    tint: "bg-cyan-500",
  },
  {
    name: "Avery Brooks",
    email: "avery@example.com",
    role: "Editor",
    status: "Invited",
    seen: "—",
    tint: "bg-pink-500",
  },
  {
    name: "Jordan Park",
    email: "jordan@example.com",
    role: "Support",
    status: "Active",
    seen: "5 hr ago",
    tint: "bg-teal-400",
  },
]

const STATUS_CLASS: Record<Status, string> = {
  Active: "text-foreground",
  Suspended: "text-destructive",
  Invited: "text-muted-foreground",
}

const initials = (name: string) =>
  name
    .split(" ")
    .map((part) => part[0])
    .join("")

function AdminTabBar({
  value,
  onValueChange,
}: {
  value: string
  onValueChange: (value: string) => void
}) {
  return (
    <TabBar value={value} onValueChange={(next) => onValueChange(next)}>
      <TabBarList>
        {SECTIONS.map((section) => (
          <TabBarItem
            key={section.value}
            value={section.value}
            icon={section.icon}
          >
            {section.label}
          </TabBarItem>
        ))}
      </TabBarList>
    </TabBar>
  )
}

function OtherSection({ value, home }: { value: string; home: string }) {
  const label = SECTIONS.find((section) => section.value === value)?.label
  return (
    <View className="flex-1 gap-2 px-4 pt-[3.25rem]">
      <Text role="heading" className="text-3xl font-bold">
        {label}
      </Text>
      <Text className="text-lg text-muted-foreground">
        {`This section isn't part of the sample. Choose ${home}.`}
      </Text>
    </View>
  )
}

function UserRow({ user, first }: { user: User; first: boolean }) {
  return (
    <SelectionListItem
      value={user.email}
      accessibilityLabel={`${user.name}, ${user.role}, ${user.status}`}
      className={cn(!first && "border-t border-border")}
    >
      <View className="min-w-0 flex-1 flex-row items-center gap-3 px-4 py-2.5">
        <Avatar size="sm" className="size-8">
          <AvatarFallback className={user.tint}>
            {initials(user.name)}
          </AvatarFallback>
        </Avatar>
        <View className="min-w-0 flex-1">
          <Text numberOfLines={1} className="text-sm font-semibold">
            {user.name}
          </Text>
          <Text numberOfLines={1} className="text-xs text-muted-foreground">
            {user.email}
          </Text>
        </View>
        <View className="items-end gap-1">
          <Badge variant="secondary" className="bg-secondary px-2">
            <Text>{user.role}</Text>
          </Badge>
          <Text className={cn("text-xs", STATUS_CLASS[user.status])}>
            {user.status === "Active" ? user.seen : user.status}
          </Text>
        </View>
      </View>
    </SelectionListItem>
  )
}

function UsersTable({
  onInvite,
  onFilterChange,
  onBulkUpdate,
  onNavigate,
}: {
  onInvite?: () => void
  onFilterChange?: (role: string) => void
  onBulkUpdate?: (
    emails: string[],
    patch: { role?: string; status?: Status }
  ) => void
  onNavigate?: (section: string) => void
}) {
  const [section, setSection] = React.useState("users")
  const [users, setUsers] = React.useState(INITIAL_USERS)
  const [query, setQuery] = React.useState("")
  const [role, setRole] = React.useState("All")
  const [editing, setEditing] = React.useState(false)
  const [selected, setSelected] = React.useState<string[]>([])
  const scroll = useHeaderScroll()
  const list = users.filter(
    (user) =>
      (role === "All" || user.role === role) &&
      (!query ||
        `${user.name} ${user.email}`
          .toLowerCase()
          .includes(query.toLowerCase()))
  )

  const bulk = (patch: { role?: string; status?: Status }) => {
    onBulkUpdate?.(selected, patch)
    setUsers((current) =>
      current.map((user) =>
        selected.includes(user.email) ? { ...user, ...patch } : user
      )
    )
    setSelected([])
    setEditing(false)
  }

  const selection = selected.length
    ? `${selected.length} selected`
    : "Tap rows to select"
  const summary = editing ? selection : `${users.length} people`

  if (section !== "users") {
    return (
      <Screen edges={["top", "right", "left"]}>
        <OtherSection value={section} home="Users" />
        <AdminTabBar
          value={section}
          onValueChange={(value) => {
            setSection(value)
            onNavigate?.(value)
          }}
        />
      </Screen>
    )
  }

  return (
    <Screen edges={["top", "right", "left"]}>
      <SelectionList
        editing={editing}
        onEditingChange={setEditing}
        value={selected}
        onValueChange={setSelected}
        className="flex-1"
      >
        <Header scroll={scroll}>
          <HeaderTitle>Users</HeaderTitle>
          <HeaderActions>
            <SelectionListTrigger />
            {editing ? null : (
              <Button
                variant="ghost"
                size="icon"
                accessibilityLabel="Invite"
                onPress={onInvite}
              >
                <Icon
                  as={PlusIcon}
                  weight="bold"
                  className="size-5 text-primary"
                />
              </Button>
            )}
          </HeaderActions>
        </Header>
        <HeaderScrollView
          scroll={scroll}
          keyboardShouldPersistTaps="handled"
          keyboardDismissMode="on-drag"
        >
          <HeaderLargeTitle
            subtitle={
              <Text
                accessibilityLiveRegion="polite"
                className="text-sm text-muted-foreground"
              >
                {summary}
              </Text>
            }
          >
            Users
          </HeaderLargeTitle>
          <View className="gap-3 pt-1 pb-28">
            <View className="px-4">
              <InputGroup className="h-9 rounded-[0.625rem]">
                <InputGroupAddon>
                  <Icon
                    as={MagnifyingGlassIcon}
                    weight="bold"
                    className="size-4 text-muted-foreground"
                  />
                </InputGroupAddon>
                <InputGroupInput
                  value={query}
                  onChangeText={setQuery}
                  placeholder="Search"
                  accessibilityLabel="Search users"
                  returnKeyType="search"
                  autoCorrect={false}
                  autoCapitalize="none"
                  clearButtonMode="while-editing"
                />
              </InputGroup>
            </View>
            <ChipGroup
              scrollable
              value={[role]}
              onValueChange={(value) => {
                if (!value[0]) return
                setRole(value[0])
                setSelected([])
                onFilterChange?.(value[0])
              }}
              accessibilityLabel="Role"
              className="px-4"
            >
              {["All", ...ROLES].map((item) => (
                <Chip key={item} value={item}>
                  {item}
                </Chip>
              ))}
            </ChipGroup>
            <View className="mx-4 overflow-hidden rounded-xl bg-muted">
              {list.map((user, index) => (
                <UserRow key={user.email} user={user} first={index === 0} />
              ))}
              {list.length === 0 ? (
                <Text className="p-6 text-center text-sm text-muted-foreground">
                  No matching users.
                </Text>
              ) : null}
            </View>
          </View>
        </HeaderScrollView>
        <SelectionListBar accessibilityLabel="Selection actions">
          <Button
            variant="plain"
            size="sm"
            disabled={selected.length === 0}
            onPress={() => bulk({ role: "Editor" })}
          >
            <Text>Make Editor</Text>
          </Button>
          <Button
            variant="plain"
            size="sm"
            disabled={selected.length === 0}
            onPress={() => bulk({ status: "Active" })}
          >
            <Text>Activate</Text>
          </Button>
          <Button
            variant="plain"
            size="sm"
            disabled={selected.length === 0}
            onPress={() => bulk({ status: "Suspended" })}
          >
            <Text
              className={
                selected.length === 0
                  ? "text-destructive opacity-50"
                  : "text-destructive"
              }
            >
              Suspend
            </Text>
          </Button>
        </SelectionListBar>
      </SelectionList>
      {editing ? null : (
        <AdminTabBar
          value={section}
          onValueChange={(value) => {
            setSection(value)
            onNavigate?.(value)
          }}
        />
      )}
    </Screen>
  )
}

export { UsersTable }
```

4. Update the import paths to match your project.

**StyleSheet**

1. Install the dependencies:

```bash title="Terminal"
npx expo install phosphor-react-native react-native-svg
```

2. Add the pieces it builds on: [Avatar](https://ui.suiss.app/docs/components/avatar), [Badge](https://ui.suiss.app/docs/components/badge), [Button](https://ui.suiss.app/docs/components/button), [Chip](https://ui.suiss.app/docs/components/chip), [Header](https://ui.suiss.app/docs/components/header), [Icon](https://ui.suiss.app/docs/components/icon), [Input Group](https://ui.suiss.app/docs/components/input-group), [Screen](https://ui.suiss.app/docs/components/screen), [Selection List](https://ui.suiss.app/docs/interactions/selection-list), [Tab Bar](https://ui.suiss.app/docs/components/tab-bar), [Text](https://ui.suiss.app/docs/components/text).

3. Copy the source into `components/blocks/users-table.tsx`:

```tsx title="stylesheet/components/blocks/users-table.tsx"
import {
  ChartBarIcon,
  GearIcon,
  MagnifyingGlassIcon,
  type Icon as PhosphorIcon,
  PlusIcon,
  ShieldIcon,
  UsersIcon,
} from "phosphor-react-native"
import * as React from "react"
import { StyleSheet, View } from "react-native"
import {
  SelectionList,
  SelectionListBar,
  SelectionListItem,
  SelectionListTrigger,
} from "@/components/interactions/selection-list"
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Chip, ChipGroup } from "@/components/ui/chip"
import {
  Header,
  HeaderActions,
  HeaderLargeTitle,
  HeaderScrollView,
  HeaderTitle,
  useHeaderScroll,
} from "@/components/ui/header"
import { Icon } from "@/components/ui/icon"
import {
  InputGroup,
  InputGroupAddon,
  InputGroupInput,
} from "@/components/ui/input-group"
import { Screen } from "@/components/ui/screen"
import { TabBar, TabBarItem, TabBarList } from "@/components/ui/tab-bar"
import { Text } from "@/components/ui/text"
import {
  fontSize,
  palette,
  radius,
  type ThemeColors,
  useTheme,
} from "@/components/ui/theme"

type Tone = keyof ThemeColors | keyof typeof palette

function tone(colors: ThemeColors, key: Tone) {
  return { ...colors, ...palette }[key]
}

const SECTIONS: { value: string; label: string; icon: PhosphorIcon }[] = [
  { value: "users", label: "Users", icon: UsersIcon },
  { value: "roles", label: "Roles", icon: ShieldIcon },
  { value: "log", label: "Activity", icon: ChartBarIcon },
  { value: "settings", label: "Settings", icon: GearIcon },
]

const ROLES = ["Admin", "Editor", "Support", "Viewer"]

type Status = "Active" | "Suspended" | "Invited"

type Tint = Tone

type User = {
  name: string
  email: string
  role: string
  status: Status
  seen: string
  tint: Tint
}

const INITIAL_USERS: User[] = [
  {
    name: "Jamie Rivera",
    email: "jamie@example.com",
    role: "Admin",
    status: "Active",
    seen: "2 min ago",
    tint: "indigo500",
  },
  {
    name: "Morgan Lee",
    email: "morgan@example.com",
    role: "Editor",
    status: "Active",
    seen: "1 hr ago",
    tint: "orange500",
  },
  {
    name: "Riley Chen",
    email: "riley@example.com",
    role: "Support",
    status: "Active",
    seen: "Yesterday",
    tint: "green500",
  },
  {
    name: "Taylor Kim",
    email: "taylor@example.com",
    role: "Viewer",
    status: "Suspended",
    seen: "3 days ago",
    tint: "cyan500",
  },
  {
    name: "Avery Brooks",
    email: "avery@example.com",
    role: "Editor",
    status: "Invited",
    seen: "—",
    tint: "pink500",
  },
  {
    name: "Jordan Park",
    email: "jordan@example.com",
    role: "Support",
    status: "Active",
    seen: "5 hr ago",
    tint: "teal400",
  },
]

const STATUS_COLOR: Record<Status, Tone> = {
  Active: "foreground",
  Suspended: "destructive",
  Invited: "mutedForeground",
}

const styles = StyleSheet.create({
  other: { flex: 1, gap: 8, paddingHorizontal: 16, paddingTop: 52 },
  otherTitle: { ...fontSize["3xl"], fontWeight: "700" },
  row: {
    minWidth: 0,
    flex: 1,
    flexDirection: "row",
    alignItems: "center",
    gap: 12,
    paddingHorizontal: 16,
    paddingVertical: 10,
  },
  avatar: { width: 32, height: 32 },
  rowText: { minWidth: 0, flex: 1 },
  name: { ...fontSize.sm, fontWeight: "600" },
  meta: { alignItems: "flex-end", gap: 4 },
  badge: { paddingHorizontal: 8 },
  fill: { flex: 1 },
  body: { gap: 12, paddingTop: 4, paddingBottom: 112 },
  search: { paddingHorizontal: 16 },
  group: { height: 36, borderRadius: 10 },
  chips: { paddingHorizontal: 16 },
  list: { marginHorizontal: 16, overflow: "hidden", borderRadius: radius.xl },
  empty: { ...fontSize.sm, padding: 24, textAlign: "center" },
})

const initials = (name: string) =>
  name
    .split(" ")
    .map((part) => part[0])
    .join("")

function AdminTabBar({
  value,
  onValueChange,
}: {
  value: string
  onValueChange: (value: string) => void
}) {
  return (
    <TabBar value={value} onValueChange={(next) => onValueChange(next)}>
      <TabBarList>
        {SECTIONS.map((section) => (
          <TabBarItem
            key={section.value}
            value={section.value}
            icon={section.icon}
          >
            {section.label}
          </TabBarItem>
        ))}
      </TabBarList>
    </TabBar>
  )
}

function OtherSection({ value, home }: { value: string; home: string }) {
  const { colors } = useTheme()
  const label = SECTIONS.find((section) => section.value === value)?.label
  return (
    <View style={styles.other}>
      <Text role="heading" style={styles.otherTitle}>
        {label}
      </Text>
      <Text style={[fontSize.lg, { color: colors.mutedForeground }]}>
        {`This section isn't part of the sample. Choose ${home}.`}
      </Text>
    </View>
  )
}

function UserRow({ user, first }: { user: User; first: boolean }) {
  const { colors } = useTheme()
  return (
    <SelectionListItem
      value={user.email}
      accessibilityLabel={`${user.name}, ${user.role}, ${user.status}`}
      style={
        first ? undefined : { borderTopWidth: 1, borderColor: colors.border }
      }
    >
      <View style={styles.row}>
        <Avatar size="sm" style={styles.avatar}>
          <AvatarFallback style={{ backgroundColor: tone(colors, user.tint) }}>
            {initials(user.name)}
          </AvatarFallback>
        </Avatar>
        <View style={styles.rowText}>
          <Text numberOfLines={1} style={styles.name}>
            {user.name}
          </Text>
          <Text
            numberOfLines={1}
            style={[fontSize.xs, { color: colors.mutedForeground }]}
          >
            {user.email}
          </Text>
        </View>
        <View style={styles.meta}>
          <Badge
            variant="secondary"
            style={[styles.badge, { backgroundColor: colors.secondary }]}
          >
            <Text>{user.role}</Text>
          </Badge>
          <Text
            style={[
              fontSize.xs,
              { color: tone(colors, STATUS_COLOR[user.status]) },
            ]}
          >
            {user.status === "Active" ? user.seen : user.status}
          </Text>
        </View>
      </View>
    </SelectionListItem>
  )
}

function UsersTable({
  onInvite,
  onFilterChange,
  onBulkUpdate,
  onNavigate,
}: {
  onInvite?: () => void
  onFilterChange?: (role: string) => void
  onBulkUpdate?: (
    emails: string[],
    patch: { role?: string; status?: Status }
  ) => void
  onNavigate?: (section: string) => void
}) {
  const { colors } = useTheme()
  const [section, setSection] = React.useState("users")
  const [users, setUsers] = React.useState(INITIAL_USERS)
  const [query, setQuery] = React.useState("")
  const [role, setRole] = React.useState("All")
  const [editing, setEditing] = React.useState(false)
  const [selected, setSelected] = React.useState<string[]>([])
  const scroll = useHeaderScroll()
  const list = users.filter(
    (user) =>
      (role === "All" || user.role === role) &&
      (!query ||
        `${user.name} ${user.email}`
          .toLowerCase()
          .includes(query.toLowerCase()))
  )

  const bulk = (patch: { role?: string; status?: Status }) => {
    onBulkUpdate?.(selected, patch)
    setUsers((current) =>
      current.map((user) =>
        selected.includes(user.email) ? { ...user, ...patch } : user
      )
    )
    setSelected([])
    setEditing(false)
  }

  let summary = `${users.length} people`
  if (editing) {
    summary = selected.length
      ? `${selected.length} selected`
      : "Tap rows to select"
  }

  if (section !== "users") {
    return (
      <Screen edges={["top", "right", "left"]}>
        <OtherSection value={section} home="Users" />
        <AdminTabBar
          value={section}
          onValueChange={(value) => {
            setSection(value)
            onNavigate?.(value)
          }}
        />
      </Screen>
    )
  }

  return (
    <Screen edges={["top", "right", "left"]}>
      <SelectionList
        editing={editing}
        onEditingChange={setEditing}
        value={selected}
        onValueChange={setSelected}
        style={styles.fill}
      >
        <Header scroll={scroll}>
          <HeaderTitle>Users</HeaderTitle>
          <HeaderActions>
            <SelectionListTrigger />
            {editing ? null : (
              <Button
                variant="ghost"
                size="icon"
                accessibilityLabel="Invite"
                onPress={onInvite}
              >
                <Icon
                  as={PlusIcon}
                  weight="bold"
                  size={20}
                  color={colors.primary}
                />
              </Button>
            )}
          </HeaderActions>
        </Header>
        <HeaderScrollView
          scroll={scroll}
          keyboardShouldPersistTaps="handled"
          keyboardDismissMode="on-drag"
        >
          <HeaderLargeTitle
            subtitle={
              <Text
                accessibilityLiveRegion="polite"
                style={[fontSize.sm, { color: colors.mutedForeground }]}
              >
                {summary}
              </Text>
            }
          >
            Users
          </HeaderLargeTitle>
          <View style={styles.body}>
            <View style={styles.search}>
              <InputGroup style={styles.group}>
                <InputGroupAddon>
                  <Icon
                    as={MagnifyingGlassIcon}
                    weight="bold"
                    size={16}
                    color={colors.mutedForeground}
                  />
                </InputGroupAddon>
                <InputGroupInput
                  value={query}
                  onChangeText={setQuery}
                  placeholder="Search"
                  accessibilityLabel="Search users"
                  returnKeyType="search"
                  autoCorrect={false}
                  autoCapitalize="none"
                  clearButtonMode="while-editing"
                />
              </InputGroup>
            </View>
            <ChipGroup
              scrollable
              value={[role]}
              onValueChange={(value) => {
                if (!value[0]) return
                setRole(value[0])
                setSelected([])
                onFilterChange?.(value[0])
              }}
              accessibilityLabel="Role"
              style={styles.chips}
            >
              {["All", ...ROLES].map((item) => (
                <Chip key={item} value={item}>
                  {item}
                </Chip>
              ))}
            </ChipGroup>
            <View style={[styles.list, { backgroundColor: colors.muted }]}>
              {list.map((user, index) => (
                <UserRow key={user.email} user={user} first={index === 0} />
              ))}
              {list.length === 0 ? (
                <Text style={[styles.empty, { color: colors.mutedForeground }]}>
                  No matching users.
                </Text>
              ) : null}
            </View>
          </View>
        </HeaderScrollView>
        <SelectionListBar accessibilityLabel="Selection actions">
          <Button
            variant="plain"
            size="sm"
            disabled={selected.length === 0}
            onPress={() => bulk({ role: "Editor" })}
          >
            <Text>Make Editor</Text>
          </Button>
          <Button
            variant="plain"
            size="sm"
            disabled={selected.length === 0}
            onPress={() => bulk({ status: "Active" })}
          >
            <Text>Activate</Text>
          </Button>
          <Button
            variant="plain"
            size="sm"
            disabled={selected.length === 0}
            onPress={() => bulk({ status: "Suspended" })}
          >
            <Text style={{ color: colors.destructive }}>Suspend</Text>
          </Button>
        </SelectionListBar>
      </SelectionList>
      {editing ? null : (
        <AdminTabBar
          value={section}
          onValueChange={(value) => {
            setSection(value)
            onNavigate?.(value)
          }}
        />
      )}
    </Screen>
  )
}

export { UsersTable }
```

4. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Add the Swift package to your app:

```swift title="Package.swift"
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
```

3. Copy the theme files every view uses:

- `swift/Theme/Theme.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Theme.swift)

- `swift/Theme/Tokens.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Tokens.swift)

4. Copy the source and every file it uses:

- `swift/Blocks/UsersTable.swift`: shown above

- `swift/Components/Badge.swift`: see [Badge](https://ui.suiss.app/docs/components/badge.md)

- `swift/Components/Button.swift`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `swift/Components/ButtonGroup.swift`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `swift/Components/Checkbox.swift`: see [Checkbox](https://ui.suiss.app/docs/components/checkbox.md)

- `swift/Components/Chip.swift`: see [Chip](https://ui.suiss.app/docs/components/chip.md)

- `swift/Components/Combobox.swift`: see [Combobox](https://ui.suiss.app/docs/components/combobox.md)

- `swift/Components/DropdownMenu.swift`: see [Dropdown Menu](https://ui.suiss.app/docs/components/dropdown-menu.md)

- `swift/Components/Empty.swift`: see [Empty](https://ui.suiss.app/docs/components/empty.md)

- `swift/Components/Field.swift`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `swift/Components/Haptics.swift`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `swift/Components/Header.swift`: see [Header](https://ui.suiss.app/docs/components/header.md)

- `swift/Components/InputGroup.swift`: see [Input Group](https://ui.suiss.app/docs/components/input-group.md)

- `swift/Components/Item.swift`: see [Item](https://ui.suiss.app/docs/components/item.md)

- `swift/Components/KeyboardScrollView.swift`: see [Keyboard Scroll View](https://ui.suiss.app/docs/components/keyboard-scroll-view.md)

- `swift/Components/Overlay.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/Overlay.swift)

- `swift/Components/Screen.swift`: see [Screen](https://ui.suiss.app/docs/components/screen.md)

- `swift/Components/Select.swift`: see [Select](https://ui.suiss.app/docs/components/select.md)

- `swift/Components/Separator.swift`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

- `swift/Components/TabBar.swift`: see [Tab Bar](https://ui.suiss.app/docs/components/tab-bar.md)

- `swift/Components/TextVariant.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/TextVariant.swift)

- `swift/Interactions/SelectionList.swift`: see [Selection List](https://ui.suiss.app/docs/interactions/selection-list.md)

5. Add the files to your app target.

**Jetpack Compose**

1. Set `minSdk` to 24 or higher and add the dependencies:

```kotlin title="build.gradle.kts"
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
implementation("com.adamglin:phosphor-icon-android:1.0.0")
```

2. Copy the theme files every view uses:

- `kotlin/theme/Theme.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Theme.kt)

- `kotlin/theme/Tokens.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Tokens.kt)

3. Copy the source and every file it uses:

- `kotlin/blocks/UsersTable.kt`: shown above

- `kotlin/components/Badge.kt`: see [Badge](https://ui.suiss.app/docs/components/badge.md)

- `kotlin/components/Button.kt`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `kotlin/components/ButtonGroup.kt`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `kotlin/components/Checkbox.kt`: see [Checkbox](https://ui.suiss.app/docs/components/checkbox.md)

- `kotlin/components/Chip.kt`: see [Chip](https://ui.suiss.app/docs/components/chip.md)

- `kotlin/components/Content.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Content.kt)

- `kotlin/components/Field.kt`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `kotlin/components/FormSupport.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/FormSupport.kt)

- `kotlin/components/Haptics.kt`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `kotlin/components/Header.kt`: see [Header](https://ui.suiss.app/docs/components/header.md)

- `kotlin/components/Icon.kt`: see [Icon](https://ui.suiss.app/docs/components/icon.md)

- `kotlin/components/InputGroup.kt`: see [Input Group](https://ui.suiss.app/docs/components/input-group.md)

- `kotlin/components/Label.kt`: see [Label](https://ui.suiss.app/docs/components/label.md)

- `kotlin/components/Overlay.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Overlay.kt)

- `kotlin/components/Screen.kt`: see [Screen](https://ui.suiss.app/docs/components/screen.md)

- `kotlin/components/Separator.kt`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

- `kotlin/components/TabBar.kt`: see [Tab Bar](https://ui.suiss.app/docs/components/tab-bar.md)

- `kotlin/interactions/FloatingBar.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/interactions/FloatingBar.kt)

- `kotlin/interactions/SelectionList.kt`: see [Selection List](https://ui.suiss.app/docs/interactions/selection-list.md)

4. Update the `com.esuiss.ui` package names to match your project.

## Usage

```tsx
import { UsersTable } from "@/components/blocks/users-table"
```

```tsx
<UsersTable />
```

Replace `INITIAL_USERS` and `ROLES` with your data. Use `onInvite`, `onFilterChange`, `onBulkUpdate` and `onNavigate` to connect the screen to your API.

