The role × permission grid: rows are roles, columns are the things they may do, and every crossing is a checkbox. Reach for it on the screen where somebody decides who can do what — an admin role editor, a team or workspace permissions page, the scope list on an API key or personal access token, an RBAC or access-control settings pane, group policy for an org, a sharing dialog that has outgrown three radio buttons, per-plan feature entitlements, moderation or staff roles in a community tool, and the permissions step of an onboarding or provisioning wizard. Common asks it answers: “permission matrix react”, “role permission table component”, “RBAC UI react”, “role based access control component”, “permissions grid checkbox react”, “shadcn permission matrix”, “shadcn roles and permissions table”, “scopes checkbox grid”, “api key scopes ui”, “access control matrix component”, “checkbox matrix react”, “table of checkboxes rows and columns”, “select all row and column checkboxes”, “permission dependencies ui”, “write implies read permission”, “implied permissions checkbox”, “role inheritance ui”, “inherited permission greyed out”, “show inherited vs explicit permission”, “why can I not untick this permission”, “permission checkbox will not uncheck”, “unchecking a box that stays checked”, “cascade permissions on checkbox”, “tri-state select all column”, “aria-checked mixed grid”, “role=grid roving tabindex react”, “too many tab stops table checkboxes”, “arrow key navigation table react”, “accessible checkbox grid”, “checkbox needs row and column name screen reader”, “sticky first column permission table”, “border disappears on sticky column”, “casbin ui”, “oso / permit.io permissions screen”, 権限マトリクス react, ロール 権限 表 コンポーネント, 権限設定 管理画面, 継承した権限 表示, 行と列の全選択, チェックボックス 中間状態 表. Official shadcn/ui has nothing of the kind, measured today rather than remembered. Fetching every entry in its registry — 65 listed, 62 fetchable (code-block, questionnaire and trace 404 on both style tracks) — and searching all 223,287 bytes of their source, permission, grant, matrix, indeterminate, scope, privilege, capability, acl and rbac are every one of them a zero hit. inherit is not zero and is worth unfolding rather than rounding: it returns exactly one, a rounded-[inherit] on the scroll-area viewport, which is a Tailwind value and not a permission that comes from somewhere. The name list settles the rest — nothing among the 65 is a role, permission or access control of any kind, and its table is 2,859 bytes of six presentational wrappers. The decision everything else follows from: the value is the grants you ticked, and only those. A cell can be granted four ways — ticked here, implied by another action on the same row (write requires read), inherited from a parent role, or some combination — and three of them are derived at render time from the roles and actions you pass in. The one-line alternative is to write the closure into the value when a box is ticked, and it is wrong in both directions. Forwards: untick write and read stays ticked, with nothing on screen saying why, because by then it is indistinguishable from a grant somebody made on purpose. Backwards: the day a deploy says write also requires list, nobody who already holds write gets list, because their stored value was closed over the old graph — so the matrix disagrees with whatever the server computes at check time, which is the thing actually being enforced. Keeping only explicit grants also means there is no cascade to write. “Can you untick the read that write pulled in” is a question that only exists once the closure is in the value; here read is not in the value, so there is nothing to untick and nothing to unwind. Cycle safety is two requirements and almost everyone implements one. Terminating is the easy half: a visited set does it. The half that bites is that a cycle must not report a key as requiring itself — follow a → b → a and a naive closure adds a to the set of things a requires, so the cell is permanently “required by itself”, which makes it derived, which refuses every press, and the grant can never be removed by anyone. requires: ["self"] is the one-line version of the same bug, and a role that inherits itself is the same bug one axis over. So origins are added back at the end rather than walked into, and a role is never its own ancestor even if the config says so. Requirements are then followed only from root causes — the grants ticked on the row — and never from other requirements, which is what keeps two cells in a cycle from pinning each other down, and which also makes the reason useful: a read held by a chain reports the delete that started it rather than the write in the middle, so what is on screen is the box you have to go and clear. A derived cell is aria-checked="true" and aria-disabled. It is granted, so saying otherwise would be a lie about what is enforced; there is nothing explicit on it to remove, so the press is refused and the refusal names what is holding it. aria-disabled rather than the disabled attribute, so the cell stays reachable and somebody on a keyboard can find out that the grant exists and where it comes from — which is the whole job of the row they cannot change. A cell that is both explicit and derived stays pressable, and that produces the press this component exists to narrate: the explicit grant goes, the cell is still granted, and the tick does not move. Reported as a plain revoke that reads as a broken control, so the live region says it, and the change object hands the caller the same list under stillGranted. The select-alls — one per row, one per column, one in the corner — split what they show from what they do, and the split is load-bearing. What is displayed counts every cell in scope, derived and locked ones included, because a row claiming “all of it” while a cell nobody can change sits underneath would be lying about what is enforced. What a press does is decided only from the cells it may actually change. Read the direction off the displayed state instead and a row held at “mixed” by a single inherited cell is never “checked”, so every press reads as “grant the rest” and the row can be pressed forever without clearing — the same trap nested-checkbox has to avoid one dimension down, and it is reachable here without inheritance at all, through one locked column that is off. One tab stop, arrow keys inside. A table of checkboxes where every cell is a tab stop costs eight roles times ten permissions equals eighty presses of Tab to get past it, which is why this is role="grid" with a roving tabindex: Tab reaches the grid, arrows move within it, Home and End run along the row and with the modifier go to the corners, and movement clamps at the edges rather than wrapping, as the grid pattern specifies. The select-alls are cells of the same grid — the header line and the first column — so there is one way to move and no separate tab stops hiding above the table. Each cell is named by both of its headers through aria-labelledby, role first, so it reads as a sentence about the role and the two names come from the rendered labels and translate with them. The header row and the role column pin on scroll by composing sticky-table-header, because a cell in the middle of a wide matrix is meaningless without both of its names on screen. One thing that component cannot do from the outside, and it was measured in a browser rather than assumed: the pinned column's right edge cannot be a border. Tailwind's preflight puts border-collapse: collapse on every table, and in the collapsed model a border belongs to the table's grid rather than to the cell — the cell is what sticks, the grid is not — so a border-r on the role column slides out from under it on the first sideways scroll and the column floats over the cells with no line down its side. It is drawn as a pseudo-element instead, which travels because it belongs to the cell. The row separators looked like they had to go the same way and they do not: a separator is horizontal and spans the whole row, so scrolling never carries it out from under the column, and collapsed borders paint above cell backgrounds rather than beneath them. Drawing those too would have been a second line on top of the real one, invisible until a theme gave the two different colours. They stay ordinary borders on the tr. Within this registry the neighbours are each a different shape. nested-checkbox is a hierarchy you select — parents, children and a third state down a tree — where this is the crossing of two independent lists, and the two are routinely wanted on the same screen. sort-header, column-visibility, column-resizer, sticky-table-header and table-row-expander are the table's other axes and none of them decides what the cells mean. transfer-list moves items between two panes, multi-select puts chips in one field, and neither has a second dimension. The API: roles is { key, label, inherits?, locked?, description? }[], actions is { key, label, requires?, locked?, description? }[], and grants is Record<roleKey, actionKey[]> — controlled with onGrantsChange, which is handed the next value and a change object naming what was granted, what was revoked and which of those are still granted anyway, or uncontrolled with defaultGrants. maxHeight and offset go through to the sticky wrapper, the three select-alls can each be turned off, announce turns the live region off, and every label is replaceable one at a time for another language. The logic is exported as pure functions so a server route, an audit view or a test can have the answers without mounting anything: effectivePermissions is the set an authorization check enforces for one role, and resolvePermissionMatrix, expandRequiredActions, inheritedRoleKeys, togglePermissionCell, togglePermissionRole, togglePermissionAction, togglePermissionMatrix, permissionCellReason and nextMatrixFocus are the rest. Styled entirely with shadcn tokens (primary, primary-foreground, input, background, border, muted-foreground, ring) so it follows light and dark mode; lucide-react for three icons is the only npm dependency, and there is no table library and no state library.
pnpm dlx shadcn@latest add "https://pulld.pages.dev/r/permission-matrix.json"