Skip to content
rvctv0.2.0

Examples

Permissions matrix

An admin edits what a support agent is allowed to do, across three capability groups. Granting all of Billing and half of Users has to look different at a glance, because approving the wrong one is a security incident rather than a typo.

Why a tree here. Permissions are already a hierarchy, and the state a reviewer most needs to see is the one a flat list cannot express: this group is partially granted. The indeterminate parent is not decoration here — it is the only thing distinguishing “everything under Users” from “two of the three things under Users”.

The demo

Tick one capability inside a group and watch the group's own box: it goes dashed and amber, not blue. Tick all three and it fills in.

Role: support-agent
grants
0 of 8
high risk
0

What gets POSTed

{
  "grants": [],
  "role": "support-agent"
}
The payload underneath is the real value of onCheck, re-rendered on every click.

The source

Self-contained, including the tri-state checkbox. Colors are inlined so it does not depend on this site's tokens.

permissions-matrix.tsx
"use client";

import { useState } from "react";
import { CheckedState, Tree, type TreeDefinition } from "react-virtual-checkbox-tree";

const permissions: TreeDefinition = {
  __root__:   { id: "__root__", label: "root", children: ["billing", "users", "content"] },
  billing:    { id: "billing", label: "Billing", children: ["b-read", "b-write", "b-refund"] },
  "b-read":   { id: "b-read", label: "View invoices", data: { risk: "low" } },
  "b-write":  { id: "b-write", label: "Edit invoices", data: { risk: "medium" } },
  "b-refund": { id: "b-refund", label: "Issue refunds", data: { risk: "high" } },
  users:      { id: "users", label: "Users", children: ["u-read", "u-invite", "u-delete"] },
  "u-read":   { id: "u-read", label: "View users", data: { risk: "low" } },
  "u-invite": { id: "u-invite", label: "Invite users", data: { risk: "medium" } },
  "u-delete": { id: "u-delete", label: "Delete users", data: { risk: "high" } },
  content:    { id: "content", label: "Content", children: ["c-read", "c-publish"] },
  "c-read":   { id: "c-read", label: "View drafts", data: { risk: "low" } },
  "c-publish":{ id: "c-publish", label: "Publish", data: { risk: "medium" } },
};

const GROUPS = ["billing", "users", "content"];

export function PermissionsMatrix() {
  const [grants, setGrants] = useState<string[]>([]);

  return (
    <div>
      <Tree
        aria-label="Role permissions"
        data={permissions}
        estimateSize={34}
        expandedItems={GROUPS}
        height={374}
        onCheck={setGrants}
        renderCheckbox={({ a11yProps, checkedState }) => (
          <TriStateBox a11yProps={a11yProps} state={checkedState} />
        )}
        renderItem={({ checkedState, isFolder, item }) => (
          <span style={{ alignItems: "center", display: "flex", gap: 8 }}>
            <span style={{ fontWeight: isFolder ? 500 : 400 }}>{item.label}</span>
            {item.data?.risk === "high" && <Badge>high risk</Badge>}
            {isFolder && checkedState === CheckedState.Indeterminate && (
              <span style={{ color: "#e0a83a", fontSize: 11 }}>partial</span>
            )}
          </span>
        )}
      />

      <button onClick={() => save(grants)} type="button">
        Save {grants.length} grants
      </button>
    </div>
  );
}

async function save(grants: string[]) {
  // Leaf IDs only — the shape onCheck already gives you.
  await fetch("/api/roles/support-agent", {
    body: JSON.stringify({ grants }),
    headers: { "content-type": "application/json" },
    method: "PUT",
  });
}

/**
 * Checked is a filled box with a tick. Indeterminate is a hollow box with a
 * dashed border and a dash. They must not be two shades of the same thing: in a
 * permissions editor, mistaking one for the other grants capabilities nobody
 * approved.
 */
function TriStateBox({
  a11yProps,
  state,
}: {
  a11yProps: { "aria-hidden": true; tabIndex: -1 };
  state: CheckedState;
}) {
  const checked = state === CheckedState.Checked;
  const mixed = state === CheckedState.Indeterminate;

  return (
    <span
      {...a11yProps}
      style={{
        alignItems: "center",
        background: checked ? "#4a8cf7" : "transparent",
        border: "1px " + (mixed ? "dashed" : "solid") + " " +
          (checked ? "#4a8cf7" : mixed ? "#e0a83a" : "#555"),
        borderRadius: 4,
        display: "inline-flex",
        height: 15,
        justifyContent: "center",
        width: 15,
      }}
    >
      {checked && <Tick />}
      {mixed && <span style={{ background: "#e0a83a", height: 2, width: 7 }} />}
    </span>
  );
}

function Tick() {
  return (
    <svg fill="none" height="10" stroke="#fff" strokeWidth="3" viewBox="0 0 24 24" width="10">
      <path d="M20 6 9 17l-5-5" />
    </svg>
  );
}

function Badge({ children }: { children: React.ReactNode }) {
  return (
    <span style={{ border: "1px solid #e0a83a", borderRadius: 999, color: "#e0a83a", fontSize: 10, padding: "1px 6px" }}>
      {children}
    </span>
  );
}

Props exercised

  • renderCheckbox

    Receives { a11yProps, checkedState, id, isActive, isExpanded, isFolder, item, level, onChange }. Spread a11yProps or the row is announced twice.

  • checkedState

    The CheckedState enum: "checked", "indeterminate" or "unchecked". Compare against CheckedState.Indeterminate rather than a boolean.

  • renderItem

    Reads item.data.risk to badge each capability. Anything you put in data comes back here untouched.

  • expandedItems

    A module-level array of the three group IDs, so every capability is visible on load — a permissions editor with collapsed groups hides exactly what needs review.

  • onCheck

    Leaf grant IDs only. This is the payload; nothing else needs to be serialized.

  • estimateSize

    34px rows to fit the risk badges without clipping. Row height is a number or a function of the row index.

The gotcha

app/api/roles/[slug]/route.ts
// app/api/roles/[slug]/route.ts — derive the folder answer, never store it
import { CheckedState, Engine } from "react-virtual-checkbox-tree/engine";

import { db } from "@/lib/db";
import { permissions } from "@/lib/permissions-tree";

export async function PUT(request: Request) {
  const { grants } = (await request.json()) as { grants: string[] };

  // The engine runs in Node: no React, no virtualizer, no "use client".
  const engine = new Engine(permissions);
  engine.setChecked(grants);

  // Unknown IDs and folder IDs were dropped by setChecked, so this is the
  // validated set — persist it, and compute the tier instead of trusting one.
  const stored = engine.getAllChecked();
  const billingIsWholesale = engine.getState("billing") === CheckedState.Checked;

  await db.role.update({
    data: { grants: stored, tier: billingIsWholesale ? "full" : "scoped" },
    where: { slug: "support-agent" },
  });

  return Response.json({ grants: stored });
}