Permission Matrix
Data Display

Permission Matrix

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.

Install

npx shadcn@latest add @paragon/permission-matrix

Also installs: checkbox

permission-matrix.tsx

"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>
  );
}