# Permissions Matrix

> A React permissions matrix of roles by permissions, grouped by module, with dependencies, unsaved-change dots and a per-role list on phones.

- Type: React component (Tables & Lists)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.0
- Install: `npx wingo-ui@latest add permissions-matrix`
- Docs: https://wingo-ui.com/components/permissions-matrix

## Interaction

Tick a cell to grant a permission to a role; the group row's box grants or clears the whole module and shows a dash while only some are on. Granting a permission that needs others (Issue invoices needs View invoices) ticks them too and the cells that changed on their own flash once; revoking a requirement clears what depends on it, and both are announced. Cells that differ from the saved state get a dot, a faint tint and a count in the toolbar. The Owner column is locked (hover, focus or long-press a cell for why), and a role name cut by a narrow column shows whole on hover or a long press. Type in the search field to keep only the matching rows, with the letters marked and the number of matches at the end of the field; a matching module name keeps the whole module. Click a module name (or press Enter or Space on it) to fold it, during a search too (the next query opens it again); Expand all / Collapse all does every module. The grid is one Tab stop: arrows move between cells, module rows and labels included, Home / End jump to the first / last role, Ctrl or Cmd + Home / End to the corners, PageUp / PageDown move eight rows, Space toggles the focused box or opens the level select. The head sticks while you scroll and the label column stays pinned while wide grids scroll sideways, each gaining a hairline shadow only while something slides under it. Below 768px the grid becomes one accordion card per role with a switch per permission and a switch per module (or role chips over one list with mobileLayout="role-picker"); level selects open as bottom sheets.

## Installation

```bash
npx wingo-ui@latest add permissions-matrix
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/permissions-matrix` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/permissions-matrix.tsx`

npm dependencies: `npm i motion lucide-react`

Wingo UI dependencies (installed automatically):

- [Accordion](https://wingo-ui.com/components/accordion) ([Markdown](https://wingo-ui.com/components/accordion.md))
- [Badge](https://wingo-ui.com/components/badge) ([Markdown](https://wingo-ui.com/components/badge.md))
- [Button](https://wingo-ui.com/components/button) ([Markdown](https://wingo-ui.com/components/button.md))
- [Checkbox](https://wingo-ui.com/components/checkbox) ([Markdown](https://wingo-ui.com/components/checkbox.md))
- [Empty State](https://wingo-ui.com/components/empty-state) ([Markdown](https://wingo-ui.com/components/empty-state.md))
- [Search Input](https://wingo-ui.com/components/search-input) ([Markdown](https://wingo-ui.com/components/search-input.md))
- [Select](https://wingo-ui.com/components/select) ([Markdown](https://wingo-ui.com/components/select.md))
- [Switch](https://wingo-ui.com/components/switch) ([Markdown](https://wingo-ui.com/components/switch.md))
- [Tooltip](https://wingo-ui.com/components/tooltip) ([Markdown](https://wingo-ui.com/components/tooltip.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [useMediaQuery](https://wingo-ui.com/components/use-media-query) ([Markdown](https://wingo-ui.com/components/use-media-query.md))
- [useScrollEdges](https://wingo-ui.com/components/use-scroll-edges) ([Markdown](https://wingo-ui.com/components/use-scroll-edges.md))
- [Text match](https://wingo-ui.com/components/text-match) ([Markdown](https://wingo-ui.com/components/text-match.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Tones](https://wingo-ui.com/components/tones) ([Markdown](https://wingo-ui.com/components/tones.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
import { useState } from "react"
import { FileText, Users } from "lucide-react"
import {
  PermissionsMatrix,
  countPermissionChanges,
  type PermissionGroup,
  type PermissionRole,
  type PermissionsValue,
} from "@/components/ui/permissions-matrix"

const roles: PermissionRole[] = [
  { id: "owner", name: "Owner", locked: true, memberCount: 1 },
  { id: "sales", name: "Sales", memberCount: 3 },
  { id: "accounting", name: "Accounting", memberCount: 1 },
]

const groups: PermissionGroup[] = [
  {
    id: "clients",
    label: "Clients",
    icon: <Users />,
    permissions: [
      { id: "clients.view", label: "View clients" },
      { id: "clients.delete", label: "Delete clients", requires: ["clients.view"], dangerous: true },
    ],
  },
  {
    id: "invoices",
    label: "Invoices",
    icon: <FileText />,
    permissions: [
      { id: "invoices.view", label: "View invoices" },
      { id: "invoices.issue", label: "Issue invoices", requires: ["invoices.view"] },
    ],
  },
]

const saved: PermissionsValue = { sales: ["clients.view"], accounting: ["clients.view", "invoices.view", "invoices.issue"] }

// 1 change, 3 changes
const changesLabel = (n: number) => (n === 1 ? "1 change" : `${n} changes`)

export function RolePermissions() {
  const [value, setValue] = useState<PermissionsValue>(saved)
  const changes = countPermissionChanges(value, saved, { ignoreRoles: ["owner"] })
  return (
    <div className="flex flex-col gap-3">
      <PermissionsMatrix
        roles={roles}
        groups={groups}
        value={value}
        onValueChange={setValue}
        compareTo={saved}
        searchPlaceholder="Search permissions"
        labels={{ changes: changesLabel, permission: "Permission", caption: "Permissions by role" }}
      />
      {changes > 0 && <p className="text-sm text-muted-foreground">{changesLabel(changes)} to save</p>}
    </div>
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `roles` | `PermissionRole[]` |  | The columns: { id, name, description?, locked? (every permission, not editable), tone? / color? (a dot and the granted bar), memberCount? }. Zero roles shows an empty state. |
| `groups` | `PermissionGroup[]` |  | The rows, by module: { id, label, description?, icon?, permissions: { id, label, description?, requires?: string[], dangerous? }[] }. |
| `value?` | `PermissionsValue` |  | Controlled grants. Boolean mode: Record<roleId, string[]>. Levels mode: Record<roleId, Record<permissionId, level>>. Ids that are not in groups are kept untouched; locked roles are ignored (always everything). |
| `defaultValue?` | `PermissionsValue` | `"{}"` | The grants to start with while uncontrolled. |
| `onValueChange?` | `(value: PermissionsValue, change: { roleId: string; permissionIds: string[]; granted: boolean \| string }) => void` |  | Every change with the next value. permissionIds lists every cell that changed, dependencies included (the one you pressed first). The grid resolves them with resolvePermissionDependencies (see Parts), which you can reuse to validate a change on the server. |
| `compareTo?` | `PermissionsValue` |  | The saved state. Cells that differ get data-changed, a dot (with a screen reader "changed") and a faint tint, and the toolbar shows the count. countPermissionChanges (see Parts) gives the same count for a save bar, on the server too. |
| `mode?` | `PermissionsMatrixMode = "boolean" \| "levels"` | `"boolean"` | levels: every cell is a compact ghost select of levels (None / View / Edit / Full), the module row sets all at once. |
| `levels?` | `PermissionLevel[]` | `"[{ value: \"none\", label: \"None\" }, { value: \"view\", label: \"View\" }, { value: \"edit\", label: \"Edit\" }, { value: \"full\", label: \"Full\" }]"` | { value, label, short? } for levels mode; the cell shows short or the label. The first level means not granted, the second is what a requirement is raised to. An unknown value in the data shows as is. |
| `defaultSearch?` | `string` | `""` | The search to start with, uncontrolled. Pair search / onSearchChange to drive it. |
| `search?` | `string` |  | Controlled search text. |
| `onSearchChange?` | `(query: string) => void` |  | Called as the search text changes. |
| `searchPlaceholder?` | `string` | `"Search permissions"` | The search field's placeholder and accessible name. |
| `labels?` | `Partial<PermissionsMatrixLabels>` |  | Every visible and announced string, English by default (see Parts): caption, permission, changes(n), changed, locked, requires(names), dangerous, granted(permission, role), revoked(permission, role), levelSet(permission, role, level), alsoGranted(names), alsoRevoked(names), expandAll, collapseAll, noResults, noResultsDescription, clearSearch, results(n) (the match count in the search field, announced), noRoles, noRolesDescription, members(n), count(granted, total), group(label, role), mixed, yes, no, role. |
| `density?` | `"compact" \| "default"` | `"default"` | Rows of 40 or 48px; the compact row grows to 44px on touch. |
| `tone?` | `"neutral" \| "brand" \| "success" \| "warning" \| "danger" \| "info"` | `"neutral"` | The checkbox, switch, role chip and changed-dot color. |
| `color?` | `string` | `""` | Any CSS color; replaces the tone. The check turns black or white by lightness. |
| `showDescriptions?` | `boolean` | `true` | Permission descriptions under their labels (two lines at most). |
| `showCounts?` | `boolean` | `true` | The granted count ("12/18") and a small bar under every role; on phones the count badge of each role and the count beside each module switch. |
| `readOnly?` | `boolean` | `false` | Cells become check and dash icons (levels show their name); module rows show a count. All roles locked means read-only too. |
| `searchable?` | `boolean` | `true` | A search field that keeps only the matching rows (modules with no match hide) and marks the matches. |
| `enforceDependencies?` | `boolean` | `true` | Granting adds what a permission requires, revoking a requirement revokes its dependents; the cells that changed on their own flash and the change is announced. |
| `collapsibleGroups?` | `boolean` | `true` | Module rows fold their permissions away; adds Expand all / Collapse all to the toolbar. |
| `defaultCollapsedGroups?` | `string[]` | `"[]"` | Module ids folded at first. |
| `stickyHeader?` | `boolean` | `true` | The role row sticks to the top of the scroller (or of the page at --topbar-offset when maxHeight is 0 and the grid fits sideways). |
| `maxHeight?` | `number` | `0` | Caps the grid's height in px so it scrolls inside; 0 grows with the page. |
| `mobileLayout?` | `"accordion" \| "role-picker"` | `"accordion"` | Below 768px: one accordion card per role with switches, or a row of role chips over one list. The grid never scrolls sideways on a phone. |
| `layout?` | `"auto" \| "grid" \| "list"` | `"auto"` | auto: the grid from 768px, the phone list below. grid or list force one, e.g. list in a narrow side panel. |
| `onRoleClick?` | `(role: PermissionRole) => void` |  | Turns the role headers into buttons (edit the role); they join the grid's arrow navigation. |
| `classNames?` | `Partial<Record<"toolbar" \| "search" \| "changes" \| "expandAll" \| "panel" \| "scroller" \| "table" \| "head" \| "role" \| "group" \| "groupToggle" \| "row" \| "permission" \| "cell" \| "mobile" \| "mobileRole" \| "empty", string>>` |  | Extra classes for the inner parts. |
| `className?` | `string` |  | Extra classes for the root; the remaining props spread onto it. |

## Parts

### resolvePermissionDependencies (function)

resolvePermissionDependencies(groups, value, roleId, permissionId | ids, checked, { mode?, levels?, enforce? }) grants or revokes for one role and follows requires both ways. Returns { value, permissionIds, alsoGranted, alsoRevoked }; pure, so it can validate a change on the server.

### countPermissionChanges (function)

countPermissionChanges(value, compareTo, { emptyLevel?, ignoreRoles? }) counts the (role, permission) cells that differ, for a save bar or a server check.

### DEFAULT_PERMISSIONS_MATRIX_LABELS (constant)

Every English string of the grid, the toolbar and the phone layouts, to spread under a translation.

### DEFAULT_PERMISSION_LEVELS (constant)

The levels used in levels mode when you pass none: None, View, Edit and Full ("none", "view", "edit", "full").

### PermissionGroup (type)

A module row group: { id, label, description?, icon?, permissions }, each permission { id, label, description?, requires?, dangerous? }.

### PermissionRole (type)

A column: { id, name, description?, locked?, tone?, color?, memberCount? }; a locked role has everything and cannot be edited.

### PermissionsValue (type)

The value: role id to granted permission ids (boolean mode), or role id to permission id to level (levels mode).

## Examples

- **Levels per module**: mode="levels": each cell is a compact select (None, View, Edit, Full).
- **Read-only**: An auditor's view: checks and dashes, nothing to press.
- **Search with highlights**: Searching "invoice" keeps the matching rows and marks the letters.
- **Dependencies**: Granting Void invoices also grants Issue and View; the change is announced.
- **On a phone**: One accordion item per role with switches, or role chips over one list.
- **Compact, 10 roles**: The label column stays pinned while the roles scroll sideways; the head sticks inside a 420px box.

## See also

- [Members table](https://wingo-ui.com/components/members-table) ([Markdown](https://wingo-ui.com/components/members-table.md))
- [Settings Layout](https://wingo-ui.com/components/settings-layout) ([Markdown](https://wingo-ui.com/components/settings-layout.md))
- [Checkbox](https://wingo-ui.com/components/checkbox) ([Markdown](https://wingo-ui.com/components/checkbox.md))
- [Accordion](https://wingo-ui.com/components/accordion) ([Markdown](https://wingo-ui.com/components/accordion.md))
- [Table](https://wingo-ui.com/components/table) ([Markdown](https://wingo-ui.com/components/table.md))
- [Tree View](https://wingo-ui.com/components/tree-view) ([Markdown](https://wingo-ui.com/components/tree-view.md))

---

Canonical: https://wingo-ui.com/components/permissions-matrix
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
