Permissions Matrix React component for Tailwind CSS v4
v1.0.0Added in 1.0.0
A React permissions matrix of roles by permissions, grouped by module, with dependencies, unsaved-change dots and a per-role list on phones.
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.
Permissions Matrix is a Pro React component in the React table and list components category, written in TypeScript for React 19 and Tailwind CSS v4. It installs as source with npx wingo-ui@latest add permissions-matrix, together with Accordion, Badge, Button, Checkbox, Empty State and 11 more and the npm packages motion and lucide-react.
Add to a project
Requirements: React 19 and Tailwind CSS v4, in Next.js (App Router or Pages), Vite or React Router. Installation guide · Next.js setup · Vite setup
npx wingo-ui@latest add permissions-matrixPermissions Matrix is part of Pro, with 298 more Pro items: $8 a month or $150 once. 14-day refund before you download any Pro item. See plans
Usage
Import PermissionsMatrix from @/components/ui/permissions-matrix.
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
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? }[] }.
valuePermissionsValueControlled 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).
defaultValuedefault"{}"PermissionsValueThe grants to start with while uncontrolled.
onValueChange(value: PermissionsValue, change: { roleId: string; permissionIds: string[]; granted: boolean | string }) => voidEvery 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.
compareToPermissionsValueThe 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.
modedefault"boolean"PermissionsMatrixMode = "boolean" | "levels"levels: every cell is a compact ghost select of levels (None / View / Edit / Full), the module row sets all at once.
levelsdefault"[{ value: "none", label: "None" }, { value: "view", label: "View" }, { value: "edit", label: "Edit" }, { value: "full", label: "Full" }]"PermissionLevel[]{ 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.
defaultSearchdefault""stringThe search to start with, uncontrolled. Pair search / onSearchChange to drive it.
searchstringControlled search text.
onSearchChange(query: string) => voidCalled as the search text changes.
searchPlaceholderdefault"Search permissions"stringThe search field's placeholder and accessible name.
labelsPartial<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.
densitydefault"default""compact" | "default"Rows of 40 or 48px; the compact row grows to 44px on touch.
tonedefault"neutral""neutral" | "brand" | "success" | "warning" | "danger" | "info"The checkbox, switch, role chip and changed-dot color.
colordefault""stringAny CSS color; replaces the tone. The check turns black or white by lightness.
showDescriptionsdefaulttruebooleanPermission descriptions under their labels (two lines at most).
showCountsdefaulttruebooleanThe 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.
readOnlydefaultfalsebooleanCells become check and dash icons (levels show their name); module rows show a count. All roles locked means read-only too.
searchabledefaulttruebooleanA search field that keeps only the matching rows (modules with no match hide) and marks the matches.
enforceDependenciesdefaulttruebooleanGranting adds what a permission requires, revoking a requirement revokes its dependents; the cells that changed on their own flash and the change is announced.
collapsibleGroupsdefaulttruebooleanModule rows fold their permissions away; adds Expand all / Collapse all to the toolbar.
defaultCollapsedGroupsdefault"[]"string[]Module ids folded at first.
stickyHeaderdefaulttruebooleanThe 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).
maxHeightdefault0numberCaps the grid's height in px so it scrolls inside; 0 grows with the page.
mobileLayoutdefault"accordion""accordion" | "role-picker"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.
layoutdefault"auto""auto" | "grid" | "list"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) => voidTurns the role headers into buttons (edit the role); they join the grid's arrow navigation.
classNamesPartial<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.
classNamestringExtra classes for the root; the remaining props spread onto it.
Parts
resolvePermissionDependencies
functionresolvePermissionDependencies(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
functioncountPermissionChanges(value, compareTo, { emptyLevel?, ignoreRoles? }) counts the (role, permission) cells that differ, for a save bar or a server check.
DEFAULT_PERMISSIONS_MATRIX_LABELS
constantEvery English string of the grid, the toolbar and the phone layouts, to spread under a translation.
DEFAULT_PERMISSION_LEVELS
constantThe levels used in levels mode when you pass none: None, View, Edit and Full ("none", "view", "edit", "full").
PermissionGroup
typeA module row group: { id, label, description?, icon?, permissions }, each permission { id, label, description?, requires?, dangerous? }.
PermissionRole
typeA column: { id, name, description?, locked?, tone?, color?, memberCount? }; a locked role has everything and cannot be edited.
PermissionsValue
typeThe 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.
Dependencies
Files
- components/ui/permissions-matrix.tsx
FAQ
Is the React Permissions Matrix component free?
The preview, props and docs are free; copying the source needs a Pro plan. See plans.
How do I install Permissions Matrix?
Run npx wingo-ui@latest add permissions-matrix. It also adds the Wingo UI items it builds on (Accordion, Badge, Button, Checkbox, Empty State and 11 more) and the npm packages motion and lucide-react. With shadcn, add the @wingo-ui registry with your API key, then run npx shadcn@latest add @wingo-ui/permissions-matrix.
How does Permissions Matrix behave on phones?
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.
See also
Used in
More React table and list components
Articles using this component
- Role Management UI in React: Members, Roles, Audit Log
Build it: screens and apps9 min read