# Roles Matrix

> A permissions matrix with a checkbox for every permission and role, grouped by area, with a save action.

A shadcn/ui block from the [@esuiss](https://suiss.app/ui?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/esuiss/roles-matrix).

- Page, with a live preview: https://shadcn.exchange/r/esuiss/roles-matrix
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/esuiss/roles-matrix-light-600e31b0e24e0a6a.png
- Registry item: https://suiss.app/ui/r/new-york-v4/roles-matrix.json
- Categories: blocks, roles
- Listed: 2026-10-20, source last changed 2026-10-20

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/esuiss/roles-matrix.json
```

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

## Dependencies

- Registry items: `@esuiss/button`, `@esuiss/checkbox`, `@esuiss/sidebar`

## Files

### `components/blocks/roles-matrix.tsx`

```tsx
"use client"

import * as React from "react"

import { IconPlaceholder } from "@/app/(create)/components/icon-placeholder"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
  Sidebar,
  SidebarContent,
  SidebarFooter,
  SidebarGroup,
  SidebarGroupContent,
  SidebarGroupLabel,
  SidebarHeader,
  SidebarInset,
  SidebarMenu,
  SidebarMenuBadge,
  SidebarMenuButton,
  SidebarMenuItem,
  SidebarProvider,
  SidebarRail,
  SidebarTrigger,
  useSidebar,
} from "@/components/ui/sidebar"

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: (
      <IconPlaceholder
        lucide="UsersIcon"
        tabler="IconUsers"
        hugeicons="UserGroupIcon"
        phosphor="UsersIcon"
        remixicon="RiGroupLine"
      />
    ),
  },
  {
    value: "roles",
    label: "Roles",
    icon: (
      <IconPlaceholder
        lucide="ShieldIcon"
        tabler="IconShield"
        hugeicons="Shield01Icon"
        phosphor="ShieldIcon"
        remixicon="RiShieldLine"
      />
    ),
  },
  {
    value: "log",
    label: "Activity",
    icon: (
      <IconPlaceholder
        lucide="ChartColumnIcon"
        tabler="IconChartBar"
        hugeicons="ChartColumnIcon"
        phosphor="ChartBarIcon"
        remixicon="RiBarChartLine"
      />
    ),
  },
  {
    value: "settings",
    label: "Settings",
    icon: (
      <IconPlaceholder
        lucide="SettingsIcon"
        tabler="IconSettings"
        hugeicons="Settings01Icon"
        phosphor="GearIcon"
        remixicon="RiSettingsLine"
      />
    ),
  },
]

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

const groups = [
  {
    name: "Content",
    permissions: [
      { name: "View", grants: [1, 1, 1, 1] },
      { name: "Create", grants: [1, 1, 0, 0] },
      { name: "Publish", grants: [1, 1, 0, 0] },
      { name: "Delete", grants: [1, 0, 0, 0] },
    ],
  },
  {
    name: "Orders",
    permissions: [
      { name: "View", grants: [1, 1, 1, 1] },
      { name: "Issue refunds", grants: [1, 0, 1, 0] },
      { name: "Change prices", grants: [1, 0, 0, 0] },
    ],
  },
  {
    name: "Management",
    permissions: [
      { name: "Invite users", grants: [1, 0, 0, 0] },
      { name: "Billing", grants: [1, 0, 0, 0] },
    ],
  },
]

const gridClass =
  "grid grid-cols-[minmax(7rem,1.5fr)_repeat(4,minmax(3.5rem,1fr))] items-center"

export function RolesMatrix() {
  const [matrix, setMatrix] = React.useState(() =>
    groups.map((group) =>
      group.permissions.map((permission) => [...permission.grants])
    )
  )
  const [dirty, setDirty] = React.useState(false)
  const [saved, setSaved] = React.useState(false)

  const toggle = (group: number, permission: number, role: number) => {
    setMatrix((current) =>
      current.map((rows, g) =>
        rows.map((grants, p) =>
          g === group && p === permission
            ? grants.map((value, r) => (r === role ? Number(!value) : value))
            : grants
        )
      )
    )
    setDirty(true)
    setSaved(false)
  }

  return (
    <SidebarProvider>
      <NavSidebar
        items={nav}
        defaultValue="roles"
        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 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">
                Permissions
              </h1>
              <p className="text-sm text-muted-foreground">
                Admin permissions can&apos;t be changed.
              </p>
            </div>
            <Button
              size="sm"
              disabled={!dirty}
              onClick={() => {
                setDirty(false)
                setSaved(true)
              }}
            >
              {saved ? "Saved" : "Save"}
            </Button>
          </div>
          <div className="-mx-5 overflow-x-auto px-5 md:mx-0 md:px-0">
            <div className="flex min-w-120 flex-col gap-4.5">
              <div className={`${gridClass} -mb-3 px-4`} aria-hidden>
                <span />
                {roles.map((role) => (
                  <span
                    key={role}
                    className="text-center text-xs font-semibold text-muted-foreground"
                  >
                    {role}
                  </span>
                ))}
              </div>
              {groups.map((group, g) => (
                <section key={group.name} className="flex flex-col gap-2">
                  <h2 className="px-1 text-sm font-semibold">{group.name}</h2>
                  <ul className="overflow-hidden rounded-2xl bg-muted">
                    {group.permissions.map((permission, p) => (
                      <li
                        key={permission.name}
                        className={`${gridClass} min-h-10 px-4 not-first:border-t not-first:border-border`}
                      >
                        <span className="text-sm">{permission.name}</span>
                        {roles.map((role, r) => (
                          <span key={role} className="flex justify-center">
                            <Checkbox
                              checked={!!matrix[g]?.[p]?.[r]}
                              disabled={r === 0}
                              onCheckedChange={() => toggle(g, p, r)}
                              aria-label={`${group.name} ${permission.name} for ${role}`}
                              className="size-4.5 data-disabled:data-checked:border-transparent data-disabled:data-checked:text-white data-disabled:opacity-50"
                            />
                          </span>
                        ))}
                      </li>
                    ))}
                  </ul>
                </section>
              ))}
            </div>
          </div>
        </div>
      </SidebarInset>
    </SidebarProvider>
  )
}
```
