Skip to main content

ns-ui

Punch Patch

A role x permission matrix rendered as a Jacquard card where authority is asymmetric material: granting punches a clean hole through the card stock, revoking never un-punches — it pastes a visible patch over the hole instead, so a cell that was ever revoked stays permanently distinguishable from one that was never touched.

Install

npx shadcn add https://design.helpmarq.com/r/punch-patch.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first — paste it in once the chat opens.

Source
registry/core/punch-patch/component.tsx
"use client";

import { useId, useMemo, useRef, useState } from "react";
import type { KeyboardEvent as ReactKeyboardEvent } from "react";

// ---------------------------------------------------------------------------
// PunchPatch — a role x permission matrix rendered as a Jacquard card where
// authority is asymmetric material. Granting a permission punches a clean
// circular hole through the card stock; revoking never un-punches — it pastes
// a visible patch over the hole instead, leaving the hole's rim peeking 1px
// around the patch and its torn corners permanent for the session. Re-granting
// punches straight through the existing patch (a smaller hole cut through its
// centre), so a cell that was ever revoked stays visibly distinct from one
// that was never touched, forever, regardless of its current state. Inherited
// grants render as sealed eyelets — solid rim + inner ring, non-interactive,
// with a tooltip naming the parent role. Dependencies (grant X requires Y)
// share one punch-bar motion across both cells instead of two animations run
// back to back. Pure DOM + SVG, tokens only, reduced motion swaps instantly.
// ---------------------------------------------------------------------------

export interface PunchPatchRole {
  id: string;
  name: string;
}

export interface PunchPatchPermission {
  id: string;
  name: string;
  /** id of another permission in the same set this one depends on; granting
   *  this one cascades a grant of the dependency first, if it isn't held. */
  requires?: string;
}

export interface PunchPatchGrant {
  roleId: string;
  permissionId: string;
}

export interface PunchPatchInherited extends PunchPatchGrant {
  /** name of the parent role this grant is inherited from */
  from: string;
}

export interface PunchPatchProps {
  /** accessible name for the whole matrix */
  label?: string;
  /** row headers */
  roles: PunchPatchRole[];
  /** column headers */
  permissions: PunchPatchPermission[];
  /** cells punched (granted) at mount */
  defaultGranted?: PunchPatchGrant[];
  /** cells with a punch-then-patch history at mount: currently off, previously granted */
  defaultRevoked?: PunchPatchGrant[];
  /** cells granted via role inheritance — rendered as sealed, non-interactive eyelets */
  inherited?: PunchPatchInherited[];
  /** fires with the current directly-held (non-inherited) grants after any toggle */
  onChange?: (grants: PunchPatchGrant[]) => void;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

interface CellState {
  granted: boolean;
  /** sticky for the session once true: this cell has been revoked at least once */
  patched: boolean;
}

const key = (roleId: string, permId: string) => `${roleId}::${permId}`;

function useReducedMotion() {
  return useRef(
    typeof window !== "undefined" &&
      window.matchMedia("(prefers-reduced-motion: reduce)").matches,
  ).current;
}

function buildInitialCells(
  granted: PunchPatchGrant[],
  revoked: PunchPatchGrant[],
): Map<string, CellState> {
  const m = new Map<string, CellState>();
  for (const g of granted) m.set(key(g.roleId, g.permissionId), { granted: true, patched: false });
  for (const r of revoked) m.set(key(r.roleId, r.permissionId), { granted: false, patched: true });
  return m;
}

interface PunchBar {
  id: number;
  left: number;
  top: number;
  width: number;
}

export function PunchPatch({
  label = "Role permissions",
  roles,
  permissions,
  defaultGranted = [],
  defaultRevoked = [],
  inherited = [],
  onChange,
  className = "",
}: PunchPatchProps) {
  const reduced = useReducedMotion();
  const baseId = useId();
  const patchedDescId = `${baseId}-patched-desc`;

  const [cells, setCells] = useState<Map<string, CellState>>(() =>
    buildInitialCells(defaultGranted, defaultRevoked),
  );
  const [focusedR, setFocusedR] = useState(0);
  const [focusedP, setFocusedP] = useState(0);
  const [punchBar, setPunchBar] = useState<PunchBar | null>(null);

  const chadEverRef = useRef<Set<string>>(new Set());
  const gridWrapRef = useRef<HTMLDivElement>(null);
  const cellRefs = useRef<Map<string, HTMLButtonElement>>(new Map());
  const liveRef = useRef<HTMLDivElement>(null);
  const barIdRef = useRef(0);
  const barTimeoutRef = useRef<number | null>(null);

  const inheritedMap = useMemo(() => {
    const m = new Map<string, string>();
    for (const i of inherited) m.set(key(i.roleId, i.permissionId), i.from);
    return m;
  }, [inherited]);

  const permById = useMemo(() => {
    const m = new Map<string, PunchPatchPermission>();
    for (const p of permissions) m.set(p.id, p);
    return m;
  }, [permissions]);

  const announce = (text: string) => {
    if (liveRef.current) liveRef.current.textContent = text;
  };

  const emitChange = (next: Map<string, CellState>) => {
    if (!onChange) return;
    const out: PunchPatchGrant[] = [];
    for (const role of roles) {
      for (const perm of permissions) {
        if (next.get(key(role.id, perm.id))?.granted) {
          out.push({ roleId: role.id, permissionId: perm.id });
        }
      }
    }
    onChange(out);
  };

  const triggerPunchBar = (roleId: string, fromPermId: string, toPermId: string) => {
    if (reduced) return;
    const wrap = gridWrapRef.current;
    const fromEl = cellRefs.current.get(key(roleId, fromPermId));
    const toEl = cellRefs.current.get(key(roleId, toPermId));
    if (!wrap || !fromEl || !toEl) return;
    const wr = wrap.getBoundingClientRect();
    const fr = fromEl.getBoundingClientRect();
    const tr = toEl.getBoundingClientRect();
    const left = Math.min(fr.left, tr.left) - wr.left;
    const right = Math.max(fr.right, tr.right) - wr.left;
    if (barTimeoutRef.current) window.clearTimeout(barTimeoutRef.current);
    barIdRef.current += 1;
    setPunchBar({
      id: barIdRef.current,
      left,
      top: fr.top - wr.top + fr.height / 2 - 1.5,
      width: right - left,
    });
    barTimeoutRef.current = window.setTimeout(() => setPunchBar(null), 340);
  };

  // Reads `cells` from the render closure rather than a functional setState
  // updater on purpose: toggle only ever runs from a discrete click/keydown,
  // one at a time, each committed before the next can fire — so there's no
  // batching hazard here. That keeps onChange/announce/triggerPunchBar (each
  // itself a state update) out of the updater function, where React would
  // otherwise double-invoke them under StrictMode and warn about setState
  // during another component's render.
  const toggle = (roleId: string, roleName: string, permId: string) => {
    const k = key(roleId, permId);
    if (inheritedMap.has(k)) return;

    const cur = cells.get(k) ?? { granted: false, patched: false };
    const next = new Map(cells);
    const perm = permById.get(permId);
    const permName = perm?.name ?? permId;

    if (cur.granted) {
      next.set(k, { granted: false, patched: true });
      setCells(next);
      announce(`${permName} revoked for ${roleName}. Previously granted.`);
      emitChange(next);
      return;
    }

    next.set(k, { granted: true, patched: cur.patched });
    chadEverRef.current.add(k);

    let cascadeName: string | null = null;
    let cascadeReqId: string | null = null;
    const reqId = perm?.requires;
    if (reqId) {
      const reqKey = key(roleId, reqId);
      const reqCur = cells.get(reqKey) ?? { granted: false, patched: false };
      if (!reqCur.granted) {
        next.set(reqKey, { granted: true, patched: reqCur.patched });
        chadEverRef.current.add(reqKey);
        cascadeName = permById.get(reqId)?.name ?? reqId;
        cascadeReqId = reqId;
      }
    }

    setCells(next);
    if (cascadeReqId) triggerPunchBar(roleId, permId, cascadeReqId);
    announce(
      cascadeName
        ? `${permName} granted for ${roleName}. Also granted: ${cascadeName}.`
        : `${permName} granted for ${roleName}.`,
    );
    emitChange(next);
  };

  const colTemplate = `10rem repeat(${permissions.length}, minmax(3.25rem, 1fr))`;

  const moveFocus = (nr: number, np: number) => {
    const r = Math.min(Math.max(nr, 0), roles.length - 1);
    const p = Math.min(Math.max(np, 0), permissions.length - 1);
    setFocusedR(r);
    setFocusedP(p);
    cellRefs.current.get(key(roles[r].id, permissions[p].id))?.focus();
  };

  const onGridKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
    switch (e.key) {
      case "ArrowRight":
        e.preventDefault();
        moveFocus(focusedR, focusedP + 1);
        return;
      case "ArrowLeft":
        e.preventDefault();
        moveFocus(focusedR, focusedP - 1);
        return;
      case "ArrowDown":
        e.preventDefault();
        moveFocus(focusedR + 1, focusedP);
        return;
      case "ArrowUp":
        e.preventDefault();
        moveFocus(focusedR - 1, focusedP);
        return;
      case "Home":
        e.preventDefault();
        moveFocus(focusedR, 0);
        return;
      case "End":
        e.preventDefault();
        moveFocus(focusedR, permissions.length - 1);
        return;
      default:
        return;
    }
  };

  return (
    <div className={`ns-pp-root w-full ${className}`}>
      <style>{`
        @keyframes ns-pp-patch-in {
          from { transform: translateX(7px); opacity: 0; }
          to { transform: translateX(0); opacity: 1; }
        }
        .ns-pp-patch-in { animation: ns-pp-patch-in 200ms cubic-bezier(0,0,0.2,1) both; }
        @keyframes ns-pp-chad {
          from { transform: translateY(0); opacity: 1; }
          to { transform: translateY(10px); opacity: 0; }
        }
        .ns-pp-chad { animation: ns-pp-chad 200ms cubic-bezier(0.4,0,1,1) both; }
        @keyframes ns-pp-bar {
          from { transform: scaleX(0); opacity: 0.9; }
          65% { opacity: 0.9; }
          to { transform: scaleX(1); opacity: 0; }
        }
        .ns-pp-bar { animation: ns-pp-bar 320ms cubic-bezier(0.16,1,0.3,1) both; transform-origin: left center; }
        @media (prefers-reduced-motion: reduce) {
          .ns-pp-patch-in, .ns-pp-chad, .ns-pp-bar { animation: none !important; }
        }
      `}</style>

      <p className="mb-2 font-mono text-xs tracking-widest text-ns-muted uppercase">{label}</p>

      <div className="w-full overflow-x-auto rounded-md border border-border">
        <div
          ref={gridWrapRef}
          role="grid"
          aria-label={label}
          onKeyDown={onGridKeyDown}
          className="relative min-w-max"
        >
          {punchBar && (
            <div
              key={punchBar.id}
              aria-hidden
              className="ns-pp-bar pointer-events-none absolute z-10 h-[3px] rounded-full bg-ns-accent/50"
              style={{ left: punchBar.left, top: punchBar.top, width: punchBar.width }}
            />
          )}

          <div role="row" className="grid border-b border-border" style={{ gridTemplateColumns: colTemplate }}>
            <div role="columnheader" className="border-r border-border">
              <span className="sr-only">Role</span>
            </div>
            {permissions.map((perm) => (
              <div
                key={perm.id}
                role="columnheader"
                className="border-r border-border px-2 py-2 text-center font-mono text-[10px] leading-tight tracking-wide text-ns-muted uppercase last:border-r-0"
              >
                {perm.name}
              </div>
            ))}
          </div>

          {roles.map((role, r) => (
            <div
              key={role.id}
              role="row"
              className="grid border-b border-border last:border-b-0"
              style={{ gridTemplateColumns: colTemplate }}
            >
              <div
                role="rowheader"
                className="flex items-center border-r border-border px-3 py-2 text-sm text-foreground"
              >
                {role.name}
              </div>
              {permissions.map((perm, p) => {
                const k = key(role.id, perm.id);
                const from = inheritedMap.get(k);
                const isInherited = from !== undefined;
                const cell = cells.get(k) ?? { granted: false, patched: false };
                const isTabbable = r === focusedR && p === focusedP;
                const tipId = `${baseId}-tip-${role.id}-${perm.id}`;
                const showChad = !reduced && cell.granted && chadEverRef.current.has(k);

                if (isInherited) {
                  return (
                    <div
                      key={perm.id}
                      role="gridcell"
                      className="border-r border-border last:border-r-0"
                    >
                      <button
                        ref={(el) => {
                          if (el) cellRefs.current.set(k, el);
                          else cellRefs.current.delete(k);
                        }}
                        type="button"
                        role="checkbox"
                        aria-checked="true"
                        aria-disabled="true"
                        aria-label={`${role.name}: ${perm.name}`}
                        aria-describedby={tipId}
                        className="group relative flex h-12 w-full cursor-default items-center justify-center focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ns-accent"
                        tabIndex={isTabbable ? 0 : -1}
                        onFocus={() => {
                          setFocusedR(r);
                          setFocusedP(p);
                        }}
                      >
                        <svg width={26} height={26} viewBox="0 0 40 40" aria-hidden className="shrink-0" style={{ pointerEvents: "none" }}>
                          <circle cx={20} cy={20} r={9} fill="var(--background)" stroke="var(--foreground)" strokeWidth={1.5} opacity={0.85} />
                          <circle cx={20} cy={20} r={3} fill="none" stroke="var(--foreground)" strokeWidth={1} opacity={0.5} />
                        </svg>
                        <span
                          id={tipId}
                          role="tooltip"
                          className="pointer-events-none absolute top-full left-1/2 z-20 mt-1 w-max max-w-[9rem] -translate-x-1/2 rounded-sm border border-border bg-background px-1.5 py-1 text-left text-[10px] leading-tight text-foreground opacity-0 shadow-sm transition-opacity duration-150 group-hover:opacity-100 group-focus-visible:opacity-100"
                        >
                          Inherited from {from}
                        </span>
                      </button>
                    </div>
                  );
                }

                const describedBy = !cell.granted && cell.patched ? patchedDescId : undefined;

                return (
                  <div
                    key={perm.id}
                    role="gridcell"
                    className="border-r border-border last:border-r-0"
                  >
                    <button
                      ref={(el) => {
                        if (el) cellRefs.current.set(k, el);
                        else cellRefs.current.delete(k);
                      }}
                      type="button"
                      role="checkbox"
                      aria-checked={cell.granted}
                      aria-label={`${role.name}: ${perm.name}`}
                      aria-describedby={describedBy}
                      data-pp-idx={r * permissions.length + p}
                      tabIndex={isTabbable ? 0 : -1}
                      onFocus={() => {
                        setFocusedR(r);
                        setFocusedP(p);
                      }}
                      onClick={() => toggle(role.id, role.name, perm.id)}
                      className="flex h-12 w-full items-center justify-center transition-colors duration-150 hover:bg-border/30 focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-ns-accent"
                    >
                      <svg width={26} height={26} viewBox="0 0 40 40" aria-hidden className="shrink-0" style={{ pointerEvents: "none" }}>
                        <circle
                          cx={20}
                          cy={20}
                          r={10}
                          fill="none"
                          stroke="var(--foreground)"
                          strokeWidth={1}
                          strokeDasharray="2.5 3"
                          opacity={0.28}
                        />
                        {cell.patched && (
                          <g className={reduced ? undefined : "ns-pp-patch-in"}>
                            <rect x={12} y={12} width={18} height={18} rx={3} fill="var(--foreground)" opacity={0.22} />
                            <rect x={11} y={11} width={18} height={18} rx={3} fill="var(--ns-muted)" />
                            <path d="M12.5 16 L12.5 12 L16.5 12" fill="none" stroke="var(--foreground)" strokeWidth={1} strokeLinecap="round" opacity={0.4} />
                            <path d="M27.5 24 L27.5 28 L23.5 28" fill="none" stroke="var(--foreground)" strokeWidth={1} strokeLinecap="round" opacity={0.4} />
                          </g>
                        )}
                        <circle
                          data-pp-hole={cell.granted ? "" : undefined}
                          cx={20}
                          cy={20}
                          r={cell.patched ? 6 : 8}
                          fill="var(--background)"
                          // A hole cut in bare card stock is background-on-
                          // background: without an edge, a granted cell renders
                          // pixel-identical to an ungranted one at rest (the
                          // patched case reads only because the patch behind it
                          // is --ns-muted). The rim is what makes the punch a
                          // punch on every cell, not just the patched ones.
                          stroke="var(--foreground)"
                          strokeWidth={1.5}
                          style={{
                            transformBox: "fill-box",
                            transformOrigin: "center",
                            transform: cell.granted ? "scale(1)" : "scale(0)",
                            opacity: cell.granted ? 1 : 0,
                            transition: reduced
                              ? "none"
                              : "transform 160ms cubic-bezier(0.3,0.9,0.4,1), opacity 130ms linear",
                          }}
                        />
                        {showChad && (
                          <circle key="chad" className="ns-pp-chad" cx={20} cy={20} r={3} fill="var(--ns-muted)" />
                        )}
                      </svg>
                    </button>
                  </div>
                );
              })}
            </div>
          ))}
        </div>
      </div>

      <span id={patchedDescId} className="sr-only">
        Previously granted, later revoked.
      </span>
      <div ref={liveRef} role="status" aria-live="polite" className="sr-only" />
    </div>
  );
}
Build spec

Build a role x permission access matrix styled as a punch card whose material remembers its own history. DATA: roles (rows) x permissions (columns, some carrying a `requires` id naming another permission in the same set), plus defaultGranted, defaultRevoked (cells that start already patched — off, but previously held) and inherited (roleId+permissionId+from, rendered as sealed non-interactive eyelets) props. STRUCTURE: real ARIA grid — role=grid on the wrapper, role=row per row, role=columnheader for each permission, role=rowheader for each role name, role=gridcell wrapping each control. Every directly-editable cell is a real button role=checkbox aria-checked, accessible name '{role}: {permission}' (e.g. 'Editors: delete projects'). STATE PER CELL: two booleans, granted (current) and patched (sticky for the session — true forever once the cell has ever been revoked). RENDER LAYERS (all SVG, back to front): a faint dashed punch-site rim in --border; if patched, a patch group (a 1px-offset shadow rect in --foreground at low opacity, a solid --ns-muted patch rect 1px smaller than the rim on every side so the rim peeks through, and two small torn-corner tick marks in --foreground) that mounts once, ever, animating in with a 200ms ease-out translateX+opacity entrance the first time patched flips true, and stays mounted permanently after — this is what a re-grant punches through; a hole circle, always mounted, radius 8 on bare stock or 6 when patched (so it reads as a smaller void cut through the patch's centre), filled var(--background), scaled 0->1 and faded 0->1 on every grant/revoke via a 160ms cubic-bezier(0.3,0.9,0.4,1) transform transition with no overshoot (percussive, hard stop, not springy) — this single scale/opacity pair is what animates both a fresh punch and a punch-through-patch identically; a transient chad piece (small --ns-muted disc) that mounts only on a live user-triggered grant (never for seeded defaultGranted state) and plays a 200ms translateY(10px)+fade-out keyframe animation once via natural conditional mount/unmount, never replayed while the cell stays granted. TOGGLE: click or Space on a cell flips granted; going true->false sets patched=true (revoke, sticky) and announces '{permission} revoked for {role}. Previously granted.'; going false->true is a grant — if the permission has `requires` and the role doesn't yet hold that dependency, cascade-grant it in the SAME state update (both cells' hole transitions start together, not sequenced) and additionally fire one shared 'punch bar' — a thin --ns-accent bar measured from the two cells' real getBoundingClientRect()s via a ref map, positioned absolutely over the row, playing one 320ms scaleX(0->1)+fade keyframe connecting them — then announce '{permission} granted for {role}. Also granted: {dependency}.' via a single aria-live=polite status region; a plain grant with no cascade announces '{permission} granted for {role}.'. INHERITED CELLS: role=checkbox aria-checked=true aria-disabled=true aria-describedby pointing at a per-cell tooltip node reading 'Inherited from {parent role}' (kept in the accessibility tree at all times via opacity, not visibility/display, so assistive tech always gets it; shown visually on hover/focus via group-hover/group-focus-visible opacity), rendered as a sealed eyelet — solid rim circle stroke var(--foreground) plus a smaller inner ring, no punch/patch mechanics, not clickable (no onClick), but still reachable by grid navigation and Tab. PATCHED-CELL DESCRIPTION: every currently-off, ever-patched cell carries aria-describedby pointing at one shared sr-only node reading 'Previously granted, later revoked.' — the accessible description that keeps history honest even though aria-checked reads false. NAVIGATION: roving tabindex over the interactive+eyelet cells in a 2D roles x permissions array — only the focused cell has tabIndex 0, arrow keys move focus row/column-wise (clamped, no wrap), Home/End jump to the row's first/last column; real buttons already give Space/Enter the native click, no extra key handling needed for toggling. TOKENS: --background --foreground --ns-muted --border --ns-accent only, --ns-accent used exclusively for the transient cascade punch bar and focus rings (interaction-only). Reduced motion: every keyframe animation and the punch/patch CSS transition collapse to none via a top-level @media query plus a JS matchMedia read at mount, so grant/revoke/cascade apply their end states instantly with no chad and no bar. No canvas.

Props

PropTypeDefaultDescription
label?string"Role permissions"accessible name for the whole matrix
rolesPunchPatchRole[]row headers
permissionsPunchPatchPermission[]column headers
defaultGranted?PunchPatchGrant[][]cells punched (granted) at mount
defaultRevoked?PunchPatchGrant[][]cells with a punch-then-patch history at mount: currently off, previously granted
inherited?PunchPatchInherited[][]cells granted via role inheritance — rendered as sealed, non-interactive eyelets
onChange?(grants: PunchPatchGrant[]) => voidfires with the current directly-held (non-inherited) grants after any toggle
className?stringextra classes merged onto the rendered root element