A role by resource grid of checkboxes; role and resource headers cascade their whole line with a small stagger, and hovering cross-highlights the row and column.
npx shadcn@latest add @paragon/permission-matrixAlso installs: checkbox
"use client";
import * as React from "react";
import { useReducedMotion } from "motion/react";
import { Checkbox } from "@/registry/paragon/ui/checkbox";
import { cn } from "@/lib/utils";
const usePrefersReducedMotion = () => !!useReducedMotion();
export interface PermissionMatrixProps
extends Omit<React.ComponentProps<"div">, "onChange"> {
/** Column headers (roles). */
roles?: string[];
/** Row headers (resources / scopes). */
resources?: string[];
/**
* Initial grants, indexed [resourceIndex][roleIndex]. Defaults to a
* realistic starter matrix. Deterministic — no random seeding.
*/
defaultGrants?: boolean[][];
/** ms between adjacent cells when a header cascades a whole line. */
stagger?: number;
onChange?: (grants: boolean[][]) => void;
}
const DEFAULT_ROLES = ["Owner", "Admin", "Developer", "Viewer"];
const DEFAULT_RESOURCES = [
"Billing",
"Members",
"API keys",
"Deploys",
"Audit log",
];
// Owner: all. Admin: all but nothing. Developer: technical scopes. Viewer: read-only-ish.
const DEFAULT_GRANTS: boolean[][] = [
[true, true, false, false], // Billing
[true, true, false, false], // Members
[true, true, true, false], // API keys
[true, true, true, false], // Deploys
[true, true, true, true], // Audit log
];
type TriState = "checked" | "unchecked" | "indeterminate";
/**
* A role × resource permission grid. Each cell is a real checkbox. Clicking a
* column header toggles the whole column — the cascade sweeps top-to-bottom
* with a small per-row stagger — and clicking a resource row header sweeps
* the row left-to-right the same way. Headers reflect all / none / mixed as
* checked / unchecked / indeterminate. Hovering any cell cross-highlights its
* row and column so you never lose your place in a dense grid. Cascade
* timers are cleared on re-trigger and unmount, and the sweep collapses to a
* single flip under reduced motion.
*/
export function PermissionMatrix({
roles = DEFAULT_ROLES,
resources = DEFAULT_RESOURCES,
defaultGrants,
stagger = 45,
onChange,
className,
...props
}: PermissionMatrixProps) {
const seed = React.useMemo(
() =>
defaultGrants ??
resources.map((_, r) =>
roles.map((_, c) => DEFAULT_GRANTS[r]?.[c] ?? false),
),
[defaultGrants, resources, roles],
);
const [grants, setGrants] = React.useState<boolean[][]>(seed);
// Column index under the pointer — drives the cross-highlight tint.
const [hoverCol, setHoverCol] = React.useState<number | null>(null);
const reduced = usePrefersReducedMotion();
// Timers driving a staggered cascade; cleared on unmount / re-trigger.
const timers = React.useRef<Array<ReturnType<typeof setTimeout>>>([]);
const grantsRef = React.useRef(grants);
grantsRef.current = grants;
const clearTimers = React.useCallback(() => {
timers.current.forEach(clearTimeout);
timers.current = [];
}, []);
React.useEffect(() => clearTimers, [clearTimers]);
const emit = (next: boolean[][]) => onChange?.(next);
const setCell = (r: number, c: number, value: boolean) =>
setGrants((prev) => {
const next = prev.map((row) => row.slice());
next[r][c] = value;
emit(next);
return next;
});
const toggleCell = (r: number, c: number) => {
clearTimers();
setCell(r, c, !(grantsRef.current[r]?.[c] ?? false));
};
const columnState = (c: number): TriState => {
const on = grants.reduce((n, row) => n + (row[c] ? 1 : 0), 0);
if (on === 0) return "unchecked";
if (on === grants.length) return "checked";
return "indeterminate";
};
const rowState = (r: number): TriState => {
const on = grants[r]?.reduce((n, v) => n + (v ? 1 : 0), 0) ?? 0;
if (on === 0) return "unchecked";
if (on === roles.length) return "checked";
return "indeterminate";
};
// Cascade a line of cells with a per-step delay; reduced motion flips at once.
const cascade = (
cells: Array<[r: number, c: number]>,
target: boolean,
) => {
clearTimers();
cells.forEach(([r, c], step) => {
if (reduced) {
setCell(r, c, target);
return;
}
timers.current.push(
setTimeout(() => setCell(r, c, target), step * stagger),
);
});
};
const toggleColumn = (c: number) => {
const target = columnState(c) !== "checked";
cascade(
resources.map((_, r) => [r, c]),
target,
);
};
const toggleRow = (r: number) => {
const target = rowState(r) !== "checked";
cascade(
roles.map((_, c) => [r, c]),
target,
);
};
const triChecked = (state: TriState) =>
state === "indeterminate" ? ("indeterminate" as const) : state === "checked";
return (
<div
data-slot="permission-matrix"
className={cn(
"w-full overflow-x-auto rounded-xl bg-card text-card-foreground shadow-border",
className,
)}
{...props}
>
<table
className="w-full border-separate border-spacing-0 text-sm"
onPointerLeave={() => setHoverCol(null)}
>
<thead>
<tr>
<th
className="sticky left-0 z-10 border-b bg-card px-4 py-2.5 text-left text-xs font-medium text-muted-foreground"
onPointerEnter={() => setHoverCol(null)}
>
Resource
</th>
{roles.map((role, c) => (
<th
key={role}
onPointerEnter={() => setHoverCol(c)}
className={cn(
"border-b px-3 py-2 text-center align-bottom transition-colors duration-(--duration-fast) ease-(--ease-out)",
hoverCol === c ? "bg-muted/40" : "bg-card",
)}
>
<button
type="button"
onClick={() => toggleColumn(c)}
className={cn(
"mx-auto flex flex-col items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium",
"transition-colors duration-(--duration-fast) hover:bg-accent",
"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
)}
aria-label={`Toggle all ${role} permissions`}
>
<span>{role}</span>
<Checkbox
checked={triChecked(columnState(c))}
// The header button owns the interaction; keep the box visual only.
tabIndex={-1}
aria-hidden
className="pointer-events-none"
/>
</button>
</th>
))}
</tr>
</thead>
<tbody className="[&>tr:last-child>*]:border-b-0">
{resources.map((resource, r) => (
<tr
key={resource}
className="group/row transition-colors duration-(--duration-fast) ease-(--ease-out) hover:bg-muted/30"
>
<th
scope="row"
className="sticky left-0 z-10 border-b bg-card p-0 text-left font-normal"
onPointerEnter={() => setHoverCol(null)}
>
<button
type="button"
onClick={() => toggleRow(r)}
aria-label={`Toggle all ${resource} permissions`}
className={cn(
"flex w-full items-center gap-2 px-4 py-2.5 text-left text-[13px] whitespace-nowrap",
"transition-colors duration-(--duration-fast) hover:bg-accent",
"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
)}
>
<span className="min-w-0 truncate" title={resource}>
{resource}
</span>
<span
aria-hidden
className={cn(
"text-[10px] text-muted-foreground/70 tabular-nums transition-opacity duration-(--duration-fast)",
rowState(r) === "indeterminate" ? "opacity-100" : "opacity-0",
)}
>
{grants[r]?.filter(Boolean).length}/{roles.length}
</span>
</button>
</th>
{roles.map((role, c) => (
<td
key={role}
onPointerEnter={() => setHoverCol(c)}
className={cn(
"border-b px-3 py-2.5 text-center transition-colors duration-(--duration-fast) ease-(--ease-out)",
hoverCol === c && "bg-muted/40",
)}
>
<span className="inline-flex">
<Checkbox
checked={grants[r]?.[c] ?? false}
onCheckedChange={() => toggleCell(r, c)}
aria-label={`${role} can access ${resource}`}
/>
</span>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
}