Overview
An editor for role-based access. Each cell cycles between Inherit, Allow and Deny, with the label swapping in place and a dot marking unsaved edits. Column and row headers set a whole action or resource at once. Beside the grid, Effective access shows every role for one resource, so when you change a parent role you can watch the roles below it follow, and a short trail explains why the selected role ends up allowed or denied. Save shows a saving state if onSave returns a promise, then a drawn check.
Installation
Install dependencies:
$ pnpm add motionAdd the utility function for class merging:
lib/utils.ts
tsx
import { ClassValue, clsx } from "clsx";import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs));}
Copy the component code into your project:
tsx
"use client"; import { useEffect, useId, useMemo, useRef, useState } from "react";import type { KeyboardEvent } from "react";import { AnimatePresence, LayoutGroup, motion, useReducedMotion } from "motion/react";import { cn } from "@/lib/utils"; export type PermissionAction = "read" | "create" | "update" | "delete";export type PermissionValue = "inherit" | "allow" | "deny"; export type PermissionRole = { id: string; name: string; description?: string; inherits?: string;}; export type PermissionResource = { id: string; name: string; description?: string };export type PermissionOverrides = Record<string, Record<string, Partial<Record<PermissionAction, PermissionValue>>>>;export type PermissionMatrixProps = { roles: PermissionRole[]; resources: PermissionResource[]; initialOverrides?: PermissionOverrides; /** Called with a snapshot on save. Return a promise to show a saving state. */ onSave?: (overrides: PermissionOverrides) => void | Promise<void>; className?: string;}; const ACTIONS: { id: PermissionAction; label: string }[] = [ { id: "read", label: "View" }, { id: "create", label: "Create" }, { id: "update", label: "Edit" }, { id: "delete", label: "Delete" },];const CYCLE: PermissionValue[] = ["inherit", "allow", "deny"];const VERB: Record<PermissionValue, string> = { inherit: "Inherit", allow: "Allow", deny: "Deny" };const EASE_OUT = [0.23, 1, 0.32, 1] as const;const POP = { type: "spring" as const, bounce: 0.35, duration: 0.4 }; const clone = (v: PermissionOverrides): PermissionOverrides => Object.fromEntries( Object.entries(v).map(([role, res]) => [role, Object.fromEntries(Object.entries(res).map(([r, p]) => [r, { ...p }]))]), ); const read = (o: PermissionOverrides, role: string, res: string, action: PermissionAction): PermissionValue => o[role]?.[res]?.[action] ?? "inherit"; type Step = { role: PermissionRole; value: PermissionValue };type Resolved = { value: "allow" | "deny"; from: string | null; inherited: boolean; trail: Step[] }; /** Walk up the inheritance chain until a role sets the action; otherwise the default is deny. */function resolve(roleId: string, res: string, action: PermissionAction, roles: PermissionRole[], o: PermissionOverrides): Resolved { const trail: Step[] = []; const seen = new Set<string>(); let role = roles.find((r) => r.id === roleId); while (role && !seen.has(role.id)) { seen.add(role.id); const value = read(o, role.id, res, action); trail.push({ role, value }); if (value !== "inherit") return { value, from: role.name, inherited: trail.length > 1, trail }; role = role.inherits ? roles.find((r) => r.id === role!.inherits) : undefined; } return { value: "deny", from: null, inherited: true, trail };} function Glyph({ value, className }: { value: "allow" | "deny"; className?: string }) { return ( <svg aria-hidden viewBox="0 0 16 16" className={cn("size-3.5 shrink-0", className)} fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"> {value === "allow" ? <path d="M3.5 8.5 6.5 11.5 12.5 4.5" /> : <path d="M4.5 4.5l7 7M11.5 4.5l-7 7" />} </svg> );} export function PermissionMatrix({ roles, resources, initialOverrides = {}, onSave, className }: PermissionMatrixProps) { const reduce = !!useReducedMotion(); const uid = useId(); const [roleId, setRoleId] = useState(roles[0]?.id ?? ""); const [overrides, setOverrides] = useState<PermissionOverrides>(() => clone(initialOverrides)); const [saved, setSaved] = useState<PermissionOverrides>(() => clone(initialOverrides)); const [focus, setFocus] = useState<{ r: number; c: number }>({ r: 0, c: 0 }); const [inspect, setInspect] = useState<{ res: string; action: PermissionAction }>({ res: resources[0]?.id ?? "", action: "read" }); const [saveState, setSaveState] = useState<"idle" | "saving" | "saved">("idle"); const cellRefs = useRef(new Map<string, HTMLButtonElement>()); const tabRefs = useRef(new Map<string, HTMLButtonElement>()); const savedTimer = useRef<number | null>(null); useEffect(() => () => void (savedTimer.current && window.clearTimeout(savedTimer.current)), []); const role = roles.find((r) => r.id === roleId); const chain = useMemo(() => { const out: PermissionRole[] = []; let r = role?.inherits ? roles.find((x) => x.id === role.inherits) : undefined; while (r && !out.includes(r)) { out.push(r); r = r.inherits ? roles.find((x) => x.id === r!.inherits) : undefined; } return out; }, [role, roles]); // Every cell that differs from the last save, per role. const changes = useMemo(() => { const byRole: Record<string, number> = {}; let total = 0; for (const r of roles) for (const res of resources) for (const a of ACTIONS) if (read(overrides, r.id, res.id, a.id) !== read(saved, r.id, res.id, a.id)) { byRole[r.id] = (byRole[r.id] ?? 0) + 1; total++; } return { total, byRole }; }, [overrides, saved, roles, resources]); const set = (cells: { res: string; action: PermissionAction }[], value: PermissionValue) => { setSaveState("idle"); setOverrides((cur) => { const next = clone(cur); next[roleId] ??= {}; for (const { res, action } of cells) next[roleId][res] = { ...next[roleId][res], [action]: value }; return next; }); }; const cycle = (v: PermissionValue) => CYCLE[(CYCLE.indexOf(v) + 1) % CYCLE.length]; // Bulk: if every cell already matches, move them all to the next value; otherwise align them to "allow". const bulk = (cells: { res: string; action: PermissionAction }[]) => { const values = cells.map(({ res, action }) => read(overrides, roleId, res, action)); const same = values.every((v) => v === values[0]); set(cells, same ? cycle(values[0]) : "allow"); }; const bulkLabel = (cells: { res: string; action: PermissionAction }[]) => { const values = cells.map(({ res, action }) => read(overrides, roleId, res, action)); return VERB[values.every((v) => v === values[0]) ? cycle(values[0]) : "allow"].toLowerCase(); }; const save = async () => { const snapshot = clone(overrides); setSaveState("saving"); try { await onSave?.(snapshot); setSaved(snapshot); setSaveState("saved"); if (savedTimer.current) window.clearTimeout(savedTimer.current); savedTimer.current = window.setTimeout(() => setSaveState("idle"), 1600); } catch { setSaveState("idle"); } }; const discard = () => setOverrides(clone(saved)); const moveFocus = (r: number, c: number) => { const nr = Math.max(0, Math.min(resources.length - 1, r)); const nc = Math.max(0, Math.min(ACTIONS.length - 1, c)); setFocus({ r: nr, c: nc }); cellRefs.current.get(`${nr}:${nc}`)?.focus(); }; const onCellKey = (e: KeyboardEvent<HTMLButtonElement>, r: number, c: number) => { const res = resources[r].id; const action = ACTIONS[c].id; const k = e.key.toLowerCase(); const moves: Record<string, [number, number]> = { arrowup: [r - 1, c], arrowdown: [r + 1, c], arrowleft: [r, c - 1], arrowright: [r, c + 1], home: [r, 0], end: [r, ACTIONS.length - 1], }; if (k in moves) { e.preventDefault(); moveFocus(...moves[k]); } else if (k === "a" || k === "d" || k === "i") { e.preventDefault(); set([{ res, action }], k === "a" ? "allow" : k === "d" ? "deny" : "inherit"); } }; const onTabKey = (e: KeyboardEvent<HTMLButtonElement>) => { const i = roles.findIndex((r) => r.id === roleId); const map: Record<string, number> = { ArrowRight: (i + 1) % roles.length, ArrowLeft: (i - 1 + roles.length) % roles.length, Home: 0, End: roles.length - 1, }; if (!(e.key in map)) return; e.preventDefault(); const next = roles[map[e.key]]; setRoleId(next.id); tabRefs.current.get(next.id)?.focus(); }; const why = resolve(roleId, inspect.res, inspect.action, roles, overrides); const inspectRes = resources.find((r) => r.id === inspect.res); const inspectAction = ACTIONS.find((a) => a.id === inspect.action); const dirty = changes.total > 0; return ( <section aria-label="Permission matrix editor" className={cn( "overflow-hidden rounded-[20px] bg-(--color-paper) text-(--color-ink)", "shadow-[0_0_0_1px_var(--color-rule),0_1px_2px_rgba(0,0,0,0.04),0_16px_40px_-24px_rgba(0,0,0,0.25)]", "[--pm-allow:oklch(50%_0.12_155)] [--pm-deny:oklch(54%_0.17_25)] dark:[--pm-allow:oklch(80%_0.13_155)] dark:[--pm-deny:oklch(75%_0.14_25)]", className, )} > {/* Header */} <div className="flex flex-wrap items-center justify-between gap-3 border-b border-(--color-rule) px-5 py-4 sm:px-6"> <div> <div className="text-[15px] font-semibold tracking-tight">Access policies</div> <div className="mt-0.5 text-[12.5px] text-(--color-neutral)">Set what each role can do. Roles inherit from the one above them.</div> </div> <div className="flex items-center gap-1.5"> <AnimatePresence initial={false}> {dirty ? ( <motion.button key="discard" type="button" onClick={discard} initial={{ opacity: 0, x: 8, filter: "blur(4px)" }} animate={{ opacity: 1, x: 0, filter: "blur(0px)" }} exit={{ opacity: 0, x: 8, filter: "blur(4px)" }} transition={{ duration: 0.25, ease: EASE_OUT }} className="h-8 rounded-full px-3 text-[12.5px] font-medium text-(--color-ink-2) outline-none transition-colors hover:bg-(--color-paper-2) hover:text-(--color-ink) focus-visible:ring-2 focus-visible:ring-(--color-focus)" > Discard </motion.button> ) : null} </AnimatePresence> <motion.button type="button" layout disabled={(!dirty && saveState !== "saved") || saveState === "saving"} onClick={save} transition={{ layout: { type: "spring", bounce: 0.2, duration: 0.4 } }} whileTap={reduce ? undefined : { scale: 0.96 }} className="relative flex h-8 items-center gap-2 overflow-hidden rounded-full bg-(--color-ink) px-3.5 text-[12.5px] font-semibold text-(--color-paper) outline-none focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-2 focus-visible:ring-offset-(--color-paper) disabled:cursor-not-allowed disabled:opacity-40 data-[state=saved]:disabled:opacity-100" data-state={saveState} > <AnimatePresence initial={false} mode="popLayout"> <motion.span key={saveState} initial={{ opacity: 0, y: 10, filter: "blur(4px)" }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} exit={{ opacity: 0, y: -10, filter: "blur(4px)" }} transition={{ duration: 0.25, ease: EASE_OUT }} className="flex items-center gap-1.5 whitespace-nowrap" > {saveState === "saving" ? ( <> <motion.svg viewBox="0 0 16 16" className="size-3.5" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" animate={reduce ? undefined : { rotate: 360 }} transition={{ duration: 0.8, repeat: Infinity, ease: "linear" }}> <path d="M8 2a6 6 0 1 1-6 6" /> </motion.svg> Saving </> ) : saveState === "saved" ? ( <> <svg viewBox="0 0 16 16" className="size-3.5" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"> <motion.path d="M3.5 8.5 6.5 11.5 12.5 4.5" initial={{ pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: 0.35, delay: 0.1 }} /> </svg> Saved </> ) : ( <> Save <AnimatePresence initial={false}> {dirty ? ( <motion.span initial={{ opacity: 0, scale: 0.5, width: 0 }} animate={{ opacity: 1, scale: 1, width: "auto" }} exit={{ opacity: 0, scale: 0.5, width: 0 }} transition={POP} className="inline-grid h-[18px] min-w-[18px] place-items-center rounded-full bg-(--color-paper)/20 px-1 text-[11px] tabular-nums" > <AnimatePresence initial={false} mode="popLayout"> <motion.span key={changes.total} initial={{ y: 8, opacity: 0 }} animate={{ y: 0, opacity: 1 }} exit={{ y: -8, opacity: 0 }} transition={{ duration: 0.2 }}> {changes.total} </motion.span> </AnimatePresence> </motion.span> ) : null} </AnimatePresence> </> )} </motion.span> </AnimatePresence> </motion.button> </div> </div> <div className="grid lg:grid-cols-[minmax(0,1fr)_280px]"> <div className="min-w-0"> {/* Role tabs */} <LayoutGroup id={`${uid}-roles`}> <div role="tablist" aria-label="Roles" className="flex gap-1 overflow-x-auto border-b border-(--color-rule) px-3 [scrollbar-width:none] sm:px-4"> {roles.map((r) => { const on = r.id === roleId; return ( <button key={r.id} ref={(el) => { if (el) tabRefs.current.set(r.id, el); else tabRefs.current.delete(r.id); }} type="button" role="tab" aria-selected={on} tabIndex={on ? 0 : -1} onClick={() => setRoleId(r.id)} onKeyDown={onTabKey} className={cn( "relative flex h-11 shrink-0 items-center gap-1.5 rounded-t-[10px] px-3 text-[13px] font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-inset", on ? "text-(--color-ink)" : "text-(--color-neutral) hover:text-(--color-ink-2)", )} > {r.name} <AnimatePresence initial={false}> {changes.byRole[r.id] ? ( <motion.span aria-label="unsaved changes" initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }} transition={POP} className="size-1.5 rounded-full bg-(--color-ink)" /> ) : null} </AnimatePresence> {on ? ( <motion.span layoutId="tab-underline" aria-hidden className="absolute inset-x-2 bottom-0 h-[2px] rounded-full bg-(--color-ink)" transition={reduce ? { duration: 0 } : { type: "spring", bounce: 0.2, duration: 0.45 }} /> ) : null} </button> ); })} </div> </LayoutGroup> <div className="p-4 sm:p-5"> {/* Role summary */} <AnimatePresence initial={false} mode="popLayout"> <motion.div key={roleId} initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: "blur(4px)" }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: "blur(4px)" }} transition={{ duration: 0.25, ease: EASE_OUT }} className="mb-4 flex flex-wrap items-center justify-between gap-x-4 gap-y-2" > <div className="text-[13px] text-(--color-ink-2)">{role?.description}</div> {chain.length ? ( <div className="flex flex-wrap items-center gap-1 text-[11.5px] text-(--color-neutral)"> Inherits {chain.map((r, i) => ( <span key={r.id} className="flex items-center gap-1"> {i > 0 ? <span aria-hidden>→</span> : null} <button type="button" onClick={() => setRoleId(r.id)} className="rounded-full bg-(--color-paper-2) px-2 py-0.5 font-medium text-(--color-ink-2) shadow-[inset_0_0_0_1px_var(--color-rule)] outline-none transition-colors hover:text-(--color-ink) focus-visible:ring-2 focus-visible:ring-(--color-focus)" > {r.name} </button> </span> ))} </div> ) : ( <div className="text-[11.5px] text-(--color-neutral)">Top-level role</div> )} </motion.div> </AnimatePresence> {/* Grid */} <div className="overflow-x-auto rounded-[14px] shadow-[inset_0_0_0_1px_var(--color-rule)]"> <div role="grid" aria-label={`Permissions for ${role?.name ?? "role"}`} className="min-w-[560px]"> <div role="row" className="grid grid-cols-[minmax(9rem,1.3fr)_repeat(4,minmax(5.5rem,1fr))] items-center border-b border-(--color-rule) bg-(--color-paper-2)/60 px-2 py-1.5"> <div role="columnheader" className="px-2 text-[11.5px] font-medium text-(--color-neutral)"> Resource </div> {ACTIONS.map((a) => { const cells = resources.map((res) => ({ res: res.id, action: a.id })); return ( <div role="columnheader" key={a.id} className="flex justify-center"> <button type="button" onClick={() => bulk(cells)} title={`Set every ${a.label} to ${bulkLabel(cells)}`} aria-label={`${a.label}: set column to ${bulkLabel(cells)}`} className="rounded-full px-2.5 py-1 text-[11.5px] font-medium text-(--color-neutral) outline-none transition-colors hover:bg-(--color-paper) hover:text-(--color-ink) focus-visible:ring-2 focus-visible:ring-(--color-focus)" > {a.label} </button> </div> ); })} </div> {resources.map((res, r) => { const rowCells = ACTIONS.map((a) => ({ res: res.id, action: a.id })); return ( <div role="row" key={res.id} className={cn( "grid grid-cols-[minmax(9rem,1.3fr)_repeat(4,minmax(5.5rem,1fr))] items-center px-2 py-1.5 transition-colors", r > 0 && "border-t border-(--color-rule)", inspect.res === res.id && "bg-(--color-paper-2)/40", )} > <div role="rowheader" className="min-w-0"> <button type="button" onClick={() => bulk(rowCells)} title={`Set every ${res.name} action to ${bulkLabel(rowCells)}`} aria-label={`${res.name}: set row to ${bulkLabel(rowCells)}`} className="group w-full rounded-[10px] px-2 py-1.5 text-left outline-none transition-colors hover:bg-(--color-paper-2) focus-visible:ring-2 focus-visible:ring-(--color-focus)" > <span className="block truncate text-[13px] font-medium text-(--color-ink)">{res.name}</span> {res.description ? <span className="block truncate text-[11.5px] text-(--color-neutral)">{res.description}</span> : null} </button> </div> {ACTIONS.map((a, c) => { const value = read(overrides, roleId, res.id, a.id); const effective = resolve(roleId, res.id, a.id, roles, overrides).value; const changed = value !== read(saved, roleId, res.id, a.id); const isFocus = focus.r === r && focus.c === c; return ( <div role="gridcell" key={a.id} className="flex justify-center px-1"> <Cell value={value} effective={effective} changed={changed} reduce={reduce} tabIndex={isFocus ? 0 : -1} label={`${res.name}, ${a.label}`} buttonRef={(el) => { if (el) cellRefs.current.set(`${r}:${c}`, el); else cellRefs.current.delete(`${r}:${c}`); }} onClick={() => set([{ res: res.id, action: a.id }], cycle(value))} onKeyDown={(e) => onCellKey(e, r, c)} onFocus={() => { setFocus({ r, c }); setInspect({ res: res.id, action: a.id }); }} onHover={() => setInspect({ res: res.id, action: a.id })} /> </div> ); })} </div> ); })} </div> </div> </div> </div> {/* Inspector */} <aside aria-label="Effective access" className="border-t border-(--color-rule) bg-(--color-paper-2)/50 p-4 sm:p-5 lg:border-t-0 lg:border-l"> <div className="text-[10.5px] font-semibold tracking-[0.14em] text-(--color-neutral) uppercase">Effective access</div> <LayoutGroup id={`${uid}-res`}> <div role="group" aria-label="Resource" className="mt-3 flex flex-wrap gap-1"> {resources.map((res) => { const on = res.id === inspect.res; return ( <button key={res.id} type="button" aria-pressed={on} onClick={() => setInspect((s) => ({ ...s, res: res.id }))} className={cn( "relative h-7 rounded-full px-2.5 text-[12px] font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-(--color-focus)", on ? "text-(--color-ink)" : "text-(--color-neutral) hover:text-(--color-ink-2)", )} > {on ? ( <motion.span layoutId="res-pill" className="absolute inset-0 rounded-full bg-(--color-paper) shadow-[0_0_0_1px_var(--color-rule),0_1px_3px_rgba(0,0,0,0.08)]" transition={reduce ? { duration: 0 } : { type: "spring", bounce: 0.2, duration: 0.4 }} /> ) : null} <span className="relative">{res.name}</span> </button> ); })} </div> </LayoutGroup> {/* Every role for this resource, so inherited changes ripple visibly. */} <div role="table" aria-label={`Effective access for ${inspectRes?.name ?? "resource"}`} className="mt-3 rounded-[12px] bg-(--color-paper) p-1.5 shadow-[0_0_0_1px_var(--color-rule)]"> <div role="row" className="grid grid-cols-[minmax(0,1fr)_repeat(4,1.5rem)] gap-1 px-1.5 pb-1"> <span role="columnheader"> <span className="sr-only">Role</span> </span> {ACTIONS.map((a) => ( <span role="columnheader" key={a.id} title={a.label} className="text-center text-[10px] font-medium text-(--color-neutral)"> {a.label[0]} <span className="sr-only">{a.label.slice(1)}</span> </span> ))} </div> {roles.map((r) => ( <div role="row" key={r.id} className={cn("grid grid-cols-[minmax(0,1fr)_repeat(4,1.5rem)] items-center gap-1 rounded-[8px] px-1.5 py-1 transition-colors", r.id === roleId && "bg-(--color-paper-2)")} > <span role="rowheader" className={cn("truncate text-[12px]", r.id === roleId ? "font-semibold text-(--color-ink)" : "text-(--color-ink-2)")}> {r.name} </span> {ACTIONS.map((a) => { const res = resolve(r.id, inspect.res, a.id, roles, overrides); const focused = r.id === roleId && a.id === inspect.action; return ( <span role="cell" key={a.id} className="grid place-items-center"> <span className="sr-only">{`${res.value === "allow" ? "Allowed" : "Denied"}${res.inherited ? ", inherited" : ""}`}</span> <motion.span aria-hidden key={`${res.value}-${res.inherited}`} initial={reduce ? false : { scale: 0.4, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} transition={POP} className={cn( "grid size-5 place-items-center rounded-full", res.value === "allow" ? "text-(--pm-allow)" : "text-(--pm-deny)", res.inherited ? "bg-transparent shadow-[inset_0_0_0_1px_currentColor] opacity-70" : res.value === "allow" ? "bg-[color-mix(in_oklch,var(--pm-allow)_16%,transparent)]" : "bg-[color-mix(in_oklch,var(--pm-deny)_14%,transparent)]", focused && "ring-2 ring-(--color-ink) ring-offset-1 ring-offset-(--color-paper)", )} > <Glyph value={res.value} className="size-3" /> </motion.span> </span> ); })} </div> ))} </div> {/* Why: the resolution trail for the inspected cell */} <div className="mt-4"> <div className="text-[12px] font-medium text-(--color-ink)"> Why {role?.name} {why.value === "allow" ? "can" : "can’t"} {inspectAction?.label.toLowerCase()} {inspectRes?.name.toLowerCase()} </div> <AnimatePresence initial={false} mode="popLayout"> <motion.div key={`${roleId}-${inspect.res}-${inspect.action}-${why.trail.map((s) => s.value).join()}`} role="list" initial={reduce ? { opacity: 0 } : { opacity: 0, y: 6, filter: "blur(4px)" }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} exit={reduce ? { opacity: 0 } : { opacity: 0, y: -6, filter: "blur(4px)" }} transition={{ duration: 0.25, ease: EASE_OUT }} className="relative mt-2 flex flex-col gap-1.5 pl-4" > <span aria-hidden className="absolute top-2 bottom-2 left-[5px] w-px bg-(--color-rule)" /> {why.trail.map((s, i) => { const decides = s.value !== "inherit"; return ( <motion.div role="listitem" key={s.role.id} initial={reduce ? false : { opacity: 0, x: -4 }} animate={{ opacity: 1, x: 0 }} transition={{ delay: reduce ? 0 : i * 0.06, duration: 0.25 }} className="relative flex items-center justify-between gap-2 text-[12px]" > <span aria-hidden className={cn("absolute top-1/2 -left-4 size-[9px] -translate-y-1/2 rounded-full shadow-[0_0_0_2px_var(--color-paper-2)]", decides ? "bg-(--color-ink)" : "bg-(--color-paper) shadow-[0_0_0_2px_var(--color-paper-2),inset_0_0_0_1.5px_var(--color-neutral)]")} /> <span className={decides ? "font-medium text-(--color-ink)" : "text-(--color-neutral)"}>{s.role.name}</span> <span className={cn( "flex items-center gap-1 rounded-full px-1.5 py-0.5 text-[11px] font-medium", s.value === "allow" && "bg-[color-mix(in_oklch,var(--pm-allow)_14%,transparent)] text-(--pm-allow)", s.value === "deny" && "bg-[color-mix(in_oklch,var(--pm-deny)_12%,transparent)] text-(--pm-deny)", s.value === "inherit" && "text-(--color-neutral)", )} > {s.value === "inherit" ? "passes up" : ( <> <Glyph value={s.value} className="size-3" /> {s.value === "allow" ? "allows" : "denies"} </> )} </span> </motion.div> ); })} {why.from === null ? ( <div role="listitem" className="relative flex items-center justify-between gap-2 text-[12px]"> <span aria-hidden className="absolute top-1/2 -left-4 size-[9px] -translate-y-1/2 rounded-full bg-(--color-ink) shadow-[0_0_0_2px_var(--color-paper-2)]" /> <span className="font-medium text-(--color-ink)">Default</span> <span className="flex items-center gap-1 rounded-full bg-[color-mix(in_oklch,var(--pm-deny)_12%,transparent)] px-1.5 py-0.5 text-[11px] font-medium text-(--pm-deny)"> <Glyph value="deny" className="size-3" /> denies </span> </div> ) : null} </motion.div> </AnimatePresence> </div> </aside> </div> {/* Footer */} <div className="flex flex-wrap items-center justify-between gap-2 border-t border-(--color-rule) px-5 py-2.5 text-[11.5px] text-(--color-neutral) sm:px-6"> <span className="relative flex h-4 items-center overflow-hidden"> <AnimatePresence initial={false} mode="popLayout"> <motion.span key={dirty ? "dirty" : "clean"} initial={{ opacity: 0, y: 8, filter: "blur(3px)" }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} exit={{ opacity: 0, y: -8, filter: "blur(3px)" }} transition={{ duration: 0.25, ease: EASE_OUT }} className="flex items-center gap-1.5" > {dirty ? ( <> <span className="relative grid size-2 place-items-center"> {!reduce ? ( <motion.span className="absolute inset-0 rounded-full bg-(--color-ink)" animate={{ scale: [1, 2.2], opacity: [0.4, 0] }} transition={{ duration: 1.6, repeat: Infinity }} /> ) : null} <span className="relative size-1.5 rounded-full bg-(--color-ink)" /> </span> {changes.total} unsaved {changes.total === 1 ? "change" : "changes"} </> ) : ( <> <Glyph value="allow" className="size-3" /> All changes saved </> )} </motion.span> </AnimatePresence> </span> <span className="hidden items-center gap-1.5 sm:flex"> Click to cycle <span aria-hidden>·</span> <kbd className="rounded-[5px] bg-(--color-paper-2) px-1 font-mono text-[10.5px] shadow-[inset_0_0_0_1px_var(--color-rule)]">A</kbd> allow <kbd className="rounded-[5px] bg-(--color-paper-2) px-1 font-mono text-[10.5px] shadow-[inset_0_0_0_1px_var(--color-rule)]">D</kbd> deny <kbd className="rounded-[5px] bg-(--color-paper-2) px-1 font-mono text-[10.5px] shadow-[inset_0_0_0_1px_var(--color-rule)]">I</kbd> inherit </span> </div> </section> );} function Cell({ value, effective, changed, reduce, tabIndex, label, buttonRef, onClick, onKeyDown, onFocus, onHover,}: { value: PermissionValue; effective: "allow" | "deny"; changed: boolean; reduce: boolean; tabIndex: number; label: string; buttonRef: (el: HTMLButtonElement | null) => void; onClick: () => void; onKeyDown: (e: KeyboardEvent<HTMLButtonElement>) => void; onFocus: () => void; onHover: () => void;}) { const state = value === "inherit" ? `inherited, currently ${effective === "allow" ? "allowed" : "denied"}` : value === "allow" ? "allowed" : "denied"; return ( <motion.button ref={buttonRef} type="button" tabIndex={tabIndex} onClick={onClick} onKeyDown={onKeyDown} onFocus={onFocus} onPointerEnter={onHover} aria-label={`${label}: ${state}${changed ? ", unsaved" : ""}`} whileTap={reduce ? undefined : { scale: 0.92 }} transition={{ type: "spring", bounce: 0.4, duration: 0.3 }} className={cn( "relative flex h-8 w-full max-w-[5.5rem] items-center justify-center overflow-visible rounded-full text-[12px] font-medium outline-none", "transition-[background-color,color,box-shadow] duration-200 focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-1 focus-visible:ring-offset-(--color-paper)", value === "allow" && "bg-[color-mix(in_oklch,var(--pm-allow)_14%,transparent)] text-(--pm-allow) shadow-[inset_0_0_0_1px_color-mix(in_oklch,var(--pm-allow)_28%,transparent)]", value === "deny" && "bg-[color-mix(in_oklch,var(--pm-deny)_12%,transparent)] text-(--pm-deny) shadow-[inset_0_0_0_1px_color-mix(in_oklch,var(--pm-deny)_26%,transparent)]", value === "inherit" && "text-(--color-neutral) [background-image:repeating-linear-gradient(-45deg,transparent_0_4px,color-mix(in_oklch,var(--color-rule)_55%,transparent)_4px_5px)] shadow-[inset_0_0_0_1px_var(--color-rule)] hover:text-(--color-ink-2)", )} > <AnimatePresence initial={false} mode="popLayout"> <motion.span key={value} initial={reduce ? { opacity: 0 } : { opacity: 0, y: 8, scale: 0.9, filter: "blur(3px)" }} animate={{ opacity: 1, y: 0, scale: 1, filter: "blur(0px)" }} exit={reduce ? { opacity: 0 } : { opacity: 0, y: -8, scale: 0.9, filter: "blur(3px)" }} transition={{ duration: 0.22, ease: EASE_OUT }} className="flex items-center gap-1" > <Glyph value={value === "inherit" ? effective : value} className={value === "inherit" ? "size-3 opacity-60" : "size-3"} /> {VERB[value]} </motion.span> </AnimatePresence> <AnimatePresence> {changed ? ( <motion.span aria-hidden initial={{ scale: 0 }} animate={{ scale: 1 }} exit={{ scale: 0 }} transition={POP} className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-(--color-ink) shadow-[0_0_0_2px_var(--color-paper)]" /> ) : null} </AnimatePresence> </motion.button> );}
Adjust import paths according to your folder structure.
Basic Usage
tsx
import { PermissionMatrix } from "@/components/ui/permission-matrix"; const roles = [ { id: "owner", name: "Owner", description: "Full control." }, { id: "admin", name: "Admin", inherits: "owner" }, { id: "analyst", name: "Analyst", inherits: "admin" },]; const resources = [ { id: "projects", name: "Projects" }, { id: "members", name: "Members" },]; <PermissionMatrix roles={roles} resources={resources} initialOverrides={{ admin: { members: { delete: "deny" } } }} onSave={(overrides) => api.savePolicy(overrides)}/>
Props
| Prop | Type | Required | Default | Description |
|---|---|---|---|---|
| roles | { id, name, description?, inherits? }[] | Yes | — | Roles. inherits names the role this one falls back to. |
| resources | { id, name, description? }[] | Yes | — | Rows of the grid. |
| initialOverrides | Record<role, Record<resource, Partial<Record<action, value>>>> | No | {} | Values set directly on roles. Anything missing inherits. |
| onSave | (overrides) => void | Promise<void> | No | — | Called with a snapshot. Return a promise to show a saving state; a rejection keeps the changes unsaved. |
| className | string | No | — | Classes for the card. |
Best Practices
- Keep inheritance shallow (two or three levels) so the trail stays short and easy to follow.
- Anything not allowed anywhere up the chain is denied, so only set Allow where it's needed.
- Keyboard: arrow keys move between cells, Enter or Space cycles, and A, D and I set Allow, Deny and Inherit directly.
- Validate the saved policy on the server too; this component only edits it.