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.

Usage

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

import { PunchPatch, type PunchPatchPermission, type PunchPatchRole } from "./component";

const ROLES: PunchPatchRole[] = [
  { id: "editors", name: "Editors" },
  { id: "reviewers", name: "Reviewers" },
  { id: "viewers", name: "Viewers" },
];

// "delete" depends on "edit" — granting it cascades a grant of edit first,
// riding one shared punch bar rather than two separate animations.
const PERMISSIONS: PunchPatchPermission[] = [
  { id: "read", name: "Read projects" },
  { id: "edit", name: "Edit projects" },
  { id: "delete", name: "Delete projects", requires: "edit" },
  { id: "manage", name: "Manage members" },
];

export default function PunchPatchDemo() {
  return (
    <main className="flex min-h-screen items-center justify-center bg-background p-6">
      <div className="w-full max-w-3xl">
        <p className="mb-4 font-mono text-xs tracking-widest text-ns-muted">
          ns-ui / punch-patch
        </p>
        <PunchPatch
          label="Project access"
          roles={ROLES}
          permissions={PERMISSIONS}
          defaultGranted={[
            { roleId: "editors", permissionId: "read" },
            { roleId: "editors", permissionId: "edit" },
          ]}
          defaultRevoked={[{ roleId: "reviewers", permissionId: "manage" }]}
          inherited={[{ roleId: "reviewers", permissionId: "read", from: "Viewer" }]}
        />
        <p className="mt-3 font-mono text-[11px] text-ns-muted">
          grant punches a hole; revoke patches over it and the patch never comes off —
          re-granting punches straight through, torn corners and all.
        </p>
      </div>
    </main>
  );
}

Component

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

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/punch-patch.tsx, so point the import there.

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.

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[]) => void—fires with the current directly-held (non-inherited) grants after any toggle
className?string—extra classes merged onto the rendered root element