A grid of everything an agent could do, each row set to Allow, Ask or Deny with native radios, risk written out, a warning when a high-risk action is allowed, grouped rows and one-click presets.
What the agent may do
3 allowed, 3 ask first, 0 denied
| Permission | Allow | Ask | Deny |
|---|---|---|---|
| Files | |||
| Read filesLow riskOpen any file in the project | |||
| Edit filesMedium riskChange or create files in the project | |||
| Delete filesHigh riskRemove files and folders | |||
| Commands | |||
| Run shell commandsHigh riskAnything you could type in a terminal | |||
| Run the test suiteLow riskOnly the commands in package.json | |||
| Network | |||
| Browse the webMedium riskFetch pages to read | |||
"use client"
import * as React from "react"
import { PermissionGrid, type Decision, type Permission, type PermissionPreset } from "@/components/ballmac/permission-grid"
const permissions: Permission[] = [
{ id: "read", group: "Files", label: "Read files", description: "Open any file in the project", risk: "low" },
{ id: "edit", group: "Files", label: "Edit files", description: "Change or create files in the project", risk: "medium" },
{ id: "delete", group: "Files", label: "Delete files", description: "Remove files and folders", risk: "high" },
{ id: "shell", group: "Commands", label: "Run shell commands", description: "Anything you could type in a terminal", risk: "high" },
{ id: "tests", group: "Commands", label: "Run the test suite", description: "Only the commands in package.json", risk: "low" },
{ id: "web", group: "Network", label: "Browse the web", description: "Fetch pages to read", risk: "medium" },
]
const presets: PermissionPreset[] = [
{ id: "cautious", label: "Cautious", values: { read: "allow", edit: "ask", delete: "deny", shell: "deny", tests: "ask", web: "ask" } },
{ id: "balanced", label: "Balanced", values: { read: "allow", edit: "allow", delete: "ask", shell: "ask", tests: "allow", web: "ask" } },
{ id: "hands-off", label: "Hands-off", values: { read: "allow", edit: "allow", delete: "ask", shell: "allow", tests: "allow", web: "allow" } },
]
export default function PermissionGridDemo() {
const [value, setValue] = React.useState<Record<string, Decision>>(presets[1].values)
return (
<div className="w-full max-w-2xl">
<PermissionGrid permissions={permissions} presets={presets} value={value} onValueChange={setValue} />
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/permission-gridInstall the dependencies.
$ pnpm add lucide-reactAdd the Ballmac items it builds on.
$ pnpm dlx shadcn@latest add @ballmac/i18nCopy the source into your project.
components/ballmac/permission-grid.tsx// Ballmac UI: Permission Grid. https://ui.ballmac.com/components/permission-grid "use client" import * as React from "react" import { Ban, Check, HelpCircle, ShieldAlert, type LucideIcon } from "lucide-react" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" type Decision = "allow" | "ask" | "deny" type Risk = "low" | "medium" | "high" type Permission = { id: string /** What the agent can do, for example "Run shell commands". */ label: string /** One line on what it means in practice. */ description?: string /** How much harm a mistake could do. Shown in words. */ risk?: Risk /** A heading to group related permissions under, such as "Files". */ group?: string } type PermissionPreset = { id: string /** Button text, for example "Cautious". */ label: string /** The decision for each permission id. Ids it leaves out are not changed. */ values: Record<string, Decision> } const DECISIONS: { value: Decision; icon: LucideIcon; fallback: string }[] = [ { value: "allow", icon: Check, fallback: "Allow" }, { value: "ask", icon: HelpCircle, fallback: "Ask" }, { value: "deny", icon: Ban, fallback: "Deny" }, ] type PermissionGridProps = Omit<React.ComponentProps<"div">, "onChange" | "defaultValue"> & { /** The things the agent could do. */ permissions: Permission[] /** The decision for each id. Controlled. */ value?: Record<string, Decision> /** The starting decisions for uncontrolled use. Ids it leaves out start on Ask. */ defaultValue?: Record<string, Decision> /** Called with the full set of decisions after any change. */ onValueChange?: (value: Record<string, Decision>) => void /** One-click settings that fill the grid. */ presets?: PermissionPreset[] /** Heading. */ title?: string } /** * What an agent may do, one row per permission with Allow, Ask or Deny. Each row is a native radio group, so the arrow keys move between the three * answers and Tab moves to the next row. Risk is written out, and a risky permission set to Allow says it runs without asking. */ function PermissionGrid({ permissions, value: valueProp, defaultValue, onValueChange, presets, title, className, ...props }: PermissionGridProps) { const msg = useMessages() const uid = React.useId() title ??= msg("permission-grid.title", "What the agent may do") const [inner, setInner] = React.useState<Record<string, Decision>>(defaultValue ?? {}) const value = valueProp ?? inner const decision = (id: string): Decision => value[id] ?? "ask" function commit(next: Record<string, Decision>) { if (valueProp === undefined) setInner(next) onValueChange?.(next) } const counts = { allow: 0, ask: 0, deny: 0 } for (const p of permissions) counts[decision(p.id)]++ const groups: { name?: string; items: Permission[] }[] = [] for (const p of permissions) { const last = groups[groups.length - 1] if (last && last.name === p.group) last.items.push(p) else groups.push({ name: p.group, items: [p] }) } const riskText = (r: Risk) => msg(`permission-grid.risk.${r}`, r === "low" ? "Low risk" : r === "medium" ? "Medium risk" : "High risk") return ( <div data-slot="permission-grid" className={cn("grid w-full min-w-0 gap-4 rounded-xl border bg-card p-4 text-card-foreground sm:p-5", className)} {...props}> <div className="flex flex-wrap items-center justify-between gap-3"> <div> <p className="text-sm font-medium text-foreground">{title}</p> <p aria-live="polite" className="mt-0.5 text-xs text-muted-foreground tabular-nums"> {msg("permission-grid.summary", "{allow} allowed, {ask} ask first, {deny} denied", counts)} </p> </div> {presets && presets.length > 0 && ( <div role="group" aria-label={msg("permission-grid.presets", "Presets")} className="flex flex-wrap gap-1.5"> {presets.map((p) => ( <button key={p.id} type="button" onClick={() => commit({ ...Object.fromEntries(permissions.map((x) => [x.id, decision(x.id)])), ...p.values })} className="h-8 rounded-full border bg-background px-3 text-xs font-medium outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50"> {p.label} </button> ))} </div> )} </div> <div className="overflow-x-auto rounded-lg border"> <table className="w-full min-w-[30rem] border-collapse text-sm"> <caption className="sr-only">{title}</caption> <thead className="bg-muted/40 text-xs text-muted-foreground"> <tr> <th scope="col" className="px-3 py-2 text-start font-medium">{msg("permission-grid.permission", "Permission")}</th> {DECISIONS.map((d) => ( <th key={d.value} scope="col" className="w-20 px-2 py-2 text-center font-medium">{msg(`permission-grid.${d.value}`, d.fallback)}</th> ))} </tr> </thead> {groups.map((g, gi) => ( <tbody key={g.name ?? gi} className="divide-y border-t"> {g.name && ( <tr className="bg-muted/20"> <th colSpan={4} scope="colgroup" className="px-3 py-1.5 text-start text-xs font-medium text-muted-foreground">{g.name}</th> </tr> )} {g.items.map((p) => { const d = decision(p.id) return ( <tr key={p.id} data-decision={d}> <th scope="row" className="px-3 py-3 text-start font-normal"> <span className="flex flex-wrap items-center gap-x-2 gap-y-0.5"> <span className="font-medium text-foreground">{p.label}</span> {p.risk && ( <span className={cn("inline-flex items-center gap-1 rounded-full border px-1.5 py-px text-[11px]", p.risk === "high" ? "border-destructive/50 text-destructive" : "text-muted-foreground")}> {p.risk === "high" && <ShieldAlert aria-hidden="true" className="size-3" />} {riskText(p.risk)} </span> )} </span> {p.description && <span className="mt-0.5 block text-xs text-muted-foreground">{p.description}</span>} {p.risk === "high" && d === "allow" && ( <span className="mt-1 flex items-center gap-1 text-xs font-medium text-destructive"> <ShieldAlert aria-hidden="true" className="size-3" /> {msg("permission-grid.noAsk", "Runs without asking")} </span> )} </th> {DECISIONS.map((x) => ( <td key={x.value} className="px-2 py-3 text-center"> <label className="relative inline-flex size-8 cursor-pointer items-center justify-center"> <input type="radio" name={`${uid}-${p.id}`} value={x.value} checked={d === x.value} onChange={() => commit({ ...Object.fromEntries(permissions.map((y) => [y.id, decision(y.id)])), [p.id]: x.value })} aria-label={msg("permission-grid.radio", "{label}: {decision}", { label: p.label, decision: msg(`permission-grid.${x.value}`, x.fallback) })} className="peer sr-only" /> <span aria-hidden="true" className={cn("flex size-7 items-center justify-center rounded-full border transition-colors peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50 motion-reduce:transition-none", d === x.value ? (x.value === "deny" ? "border-destructive bg-destructive text-white" : "border-foreground bg-foreground text-background") : "text-muted-foreground/60 hover:bg-accent")}> <x.icon className="size-3.5" /> </span> </label> </td> ))} </tr> ) })} </tbody> ))} </table> </div> </div> ) } export { PermissionGrid, type Decision, type Permission, type PermissionGridProps, type PermissionPreset, type Risk }Update the import paths to match your project setup.
Usage
import { PermissionGrid } from "@/components/ballmac/permission-grid"The full example is in the Code tab above.
Examples
With an Autonomy Dial
What the agent may do
0 allowed, 3 ask first, 0 denied
| Permission | Allow | Ask | Deny |
|---|---|---|---|
| Edit filesMedium risk | |||
| Run shell commandsHigh risk | |||
| Browse the webMedium risk |
"use client"
import * as React from "react"
import { AutonomyDial } from "@/components/ballmac/autonomy-dial"
import { PermissionGrid, type Decision, type Permission } from "@/components/ballmac/permission-grid"
const permissions: Permission[] = [
{ id: "edit", label: "Edit files", risk: "medium" },
{ id: "shell", label: "Run shell commands", risk: "high" },
{ id: "web", label: "Browse the web", risk: "medium" },
]
const byLevel: Record<string, Record<string, Decision>> = {
suggest: { edit: "deny", shell: "deny", web: "deny" },
ask: { edit: "ask", shell: "ask", web: "ask" },
"auto-edit": { edit: "allow", shell: "ask", web: "ask" },
full: { edit: "allow", shell: "allow", web: "allow" },
}
/** The dial sets the broad level; the grid lets someone fine-tune a single permission after. */
export default function PermissionGridDial() {
const [level, setLevel] = React.useState("ask")
const [value, setValue] = React.useState<Record<string, Decision>>(byLevel.ask)
return (
<div className="grid w-full max-w-2xl gap-4">
<AutonomyDial
value={level}
onValueChange={(v) => {
setLevel(v)
setValue(byLevel[v])
}}
/>
<PermissionGrid permissions={permissions} value={value} onValueChange={setValue} />
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
permissions*The things the agent could do. | Permission[] | — |
valueThe decision for each id. Controlled. | Record<string, Decision> | — |
defaultValueThe starting decisions for uncontrolled use. Ids it leaves out start on Ask. | Record<string, Decision> | — |
onValueChangeCalled with the full set of decisions after any change. | (value: Record<string, Decision>) => void | — |
presetsOne-click settings that fill the grid. | PermissionPreset[] | — |
titleHeading. | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Tab | One stop per row: the chosen answer, or the first if none is |
| ArrowRight / ArrowLeft / ArrowDown / ArrowUp | Moves between Allow, Ask and Deny in the row (native radios) |
| Screen readers | A real table; each radio is named 'Run shell commands: Allow'; the counts are a polite live region |
| Risk and decisions | Written as words and icons; colour only reinforces them |
Use with AI
permissions is [{ id, label, description?, risk?: low | medium | high, group? }]; value / defaultValue / onValueChange hold { [id]: 'allow' | 'ask' | 'deny' } (missing ids mean ask). presets is [{ id, label, values }] and fills the grid in one click. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Permission Grid (@ballmac/permission-grid) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Letting people decide which tools an agent may use without asking
- A permissions page for an assistant or a bot
Not for
- One overall level of freedom; use autonomy-dial
- A single approval in the moment; use approval-card
- User roles in a workspace; use the access review block
Registry JSON: https://ui.ballmac.com/r/permission-grid.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
Autonomy Dial
A stepped dial for how much an agent may do on its own, from suggest-only to full auto, with what it does alone and what it stops to ask about written out for every step.
Approval Card
A human-in-the-loop confirmation for agent actions: what will happen, risk level in words, the exact command, Approve, Deny and Always allow, a timeout countdown and a recorded decision.
Tool Call Card
Shows one agent tool call: the tool name, a pending, running, success or error status with icon and text, duration, and collapsible pretty-printed input and result.
Settings Panel
A sectioned settings card with label-and-control rows and a save bar that slides in when something changes, with a status announced to screen readers.
Ballmac UI Pro
Need the whole screen, not one piece?
150 premium blocks (dashboards, settings, calendars, mail, shops, onboarding), a private shadcn registry and Figma tokens. Blocks that show data come with loading, empty and error states built in.
Not ready? Try three Pro blocks free
Leave your email and the blocks appear right here, ready to copy. Yours to use in any project.