Data Display

Permission Matrix

A role by resource grid of checkboxes; toggling a column header cascades the grant down the column with a small top-to-bottom stagger.

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