A role by resource grid of checkboxes; toggling a column header cascades the grant down the column with a small top-to-bottom stagger.
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 column header cascades. */
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
];
/**
* A role × resource permission grid. Each cell is a real checkbox; clicking a
* column header toggles the whole column, and the cascade runs top-to-bottom
* with a small per-row stagger so you can see the grant sweep down the column.
* The header itself reflects all / none / mixed as checked / unchecked /
* indeterminate. The stagger is a CSS transition-delay on the cell wrapper —
* decorative only, and it collapses to zero 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);
const reduced = usePrefersReducedMotion();
// Timers driving a staggered column 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) => {
const on = grants.reduce((n, row) => n + (row[c] ? 1 : 0), 0);
if (on === 0) return "unchecked" as const;
if (on === grants.length) return "checked" as const;
return "indeterminate" as const;
};
const toggleColumn = (c: number) => {
const target = columnState(c) !== "checked";
clearTimers();
// Cascade the grant down the column so the checks draw top-to-bottom; under
// reduced motion the whole column flips at once (movement removed).
resources.forEach((_, r) => {
if (reduced) {
setCell(r, c, target);
return;
}
timers.current.push(
setTimeout(() => setCell(r, c, target), r * stagger),
);
});
};
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">
<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">
Resource
</th>
{roles.map((role, c) => (
<th
key={role}
className="border-b bg-card px-3 py-2 text-center align-bottom"
>
<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={
columnState(c) === "indeterminate"
? "indeterminate"
: columnState(c) === "checked"
}
// 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="transition-colors hover:bg-muted/30">
<th
scope="row"
className="sticky left-0 z-10 border-b bg-card px-4 py-2.5 text-left text-[13px] font-normal whitespace-nowrap"
>
{resource}
</th>
{roles.map((role, c) => (
<td key={role} className="border-b px-3 py-2.5 text-center">
<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>
);
}