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.
"use client"
import * as React from "react"
import { AutonomyDial } from "@/components/ballmac/autonomy-dial"
export default function AutonomyDialDemo() {
const [value, setValue] = React.useState("auto-edit")
return (
<div className="w-full max-w-2xl">
<AutonomyDial value={value} onValueChange={setValue} />
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/autonomy-dialInstall 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/autonomy-dial.tsx// Ballmac UI: Autonomy Dial. https://ui.ballmac.com/components/autonomy-dial "use client" import * as React from "react" import { Check, HelpCircle } from "lucide-react" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" type AutonomyLevel = { /** Stable value reported to onValueChange. */ value: string /** Short name shown under the dial, for example "Ask first". */ label: string /** One sentence on what this setting means. */ summary: string /** What the agent does without asking at this level. */ does: string[] /** What it stops to ask about at this level. */ asks: string[] } /** Four settings from "suggest only" to "full auto", written to be edited. */ const defaultAutonomyLevels: AutonomyLevel[] = [ { value: "suggest", label: "Suggest", summary: "Reads and proposes. Nothing changes until you do it yourself.", does: ["Reads files and searches", "Explains and proposes changes"], asks: ["Every edit", "Every command", "Anything on the network"], }, { value: "ask", label: "Ask first", summary: "Does the work after you approve each step.", does: ["Reads files and searches", "Prepares edits and commands"], asks: ["Before each edit", "Before each command", "Before using the network"], }, { value: "auto-edit", label: "Auto-edit", summary: "Edits files freely, and asks before anything that runs or reaches out.", does: ["Reads and edits files in the project", "Runs checks you have allowed"], asks: ["Other commands", "Installing packages", "Using the network"], }, { value: "full", label: "Full auto", summary: "Works on its own inside its sandbox and stops only for what cannot be undone.", does: ["Edits files and runs commands", "Installs packages inside the sandbox"], asks: ["Deleting data", "Anything outside the sandbox", "Spending past the budget"], }, ] type AutonomyDialProps = Omit<React.ComponentProps<"fieldset">, "onChange" | "defaultValue"> & { /** The settings, from least to most autonomous. Defaults to four general ones. */ levels?: AutonomyLevel[] /** The selected value. Controlled. */ value?: string /** The selected value for uncontrolled use. */ defaultValue?: string /** Called with the new value. */ onValueChange?: (value: string) => void /** Heading above the dial. */ label?: string } /** * How much an agent may do on its own, as a stepped dial with what each step means written out underneath. The steps are a native radio group, so * the arrow keys, Home, End and the browser's own right-to-left handling all work. The track is decoration; the words carry the meaning. */ function AutonomyDial({ levels = defaultAutonomyLevels, value: valueProp, defaultValue, onValueChange, label, className, ...props }: AutonomyDialProps) { const msg = useMessages() const name = React.useId() label ??= msg("autonomy-dial.label", "How much can the agent do on its own?") const [inner, setInner] = React.useState(defaultValue ?? levels[0]?.value) const value = valueProp ?? inner const index = Math.max(0, levels.findIndex((l) => l.value === value)) const current = levels[index] const last = Math.max(1, levels.length - 1) function pick(v: string) { if (valueProp === undefined) setInner(v) onValueChange?.(v) } if (!current) return null return ( <fieldset data-slot="autonomy-dial" className={cn("grid w-full min-w-0 gap-6 rounded-xl border bg-card p-5 text-card-foreground sm:p-6", className)} {...props}> <legend className="sr-only">{label}</legend> <div className="flex items-baseline justify-between gap-3" aria-hidden="true"> <p className="text-sm font-medium text-foreground">{label}</p> <p className="font-mono text-xs text-muted-foreground tabular-nums"> {index + 1} / {levels.length} </p> </div> <div className="relative px-4"> {/* The track and its fill. Decoration: each step below is a labelled radio. */} <div aria-hidden="true" className="absolute inset-x-4 top-[0.9rem] h-1.5 -translate-y-1/2 rounded-full bg-muted"> <div className="h-full rounded-full bg-foreground transition-[width] duration-300 ease-out motion-reduce:transition-none" style={{ width: `${(index / last) * 100}%` }} /> </div> <div className="relative flex justify-between"> {levels.map((l, i) => { const on = l.value === current.value const passed = i <= index return ( <label key={l.value} className="group/step relative flex w-0 cursor-pointer flex-col items-center"> <input type="radio" name={name} value={l.value} checked={on} onChange={() => pick(l.value)} className="peer sr-only" /> <span aria-hidden="true" className={cn( "flex size-[1.8rem] items-center justify-center rounded-full border-2 bg-card transition-[transform,background-color,border-color] duration-200 motion-reduce:transition-none", passed ? "border-foreground" : "border-border group-hover/step:border-foreground/50", on && "scale-110 bg-foreground text-background", "peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50 peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-card" )} > {passed ? <Check className="size-3.5" /> : <span className="size-1.5 rounded-full bg-muted-foreground/60" />} </span> <span className={cn("mt-2.5 w-24 text-center text-[13px] leading-tight text-balance", on ? "font-semibold text-foreground" : "text-muted-foreground")}>{l.label}</span> </label> ) })} </div> </div> <div aria-live="polite" className="grid gap-4 rounded-lg border bg-background/60 p-4"> <p className="text-sm leading-relaxed text-foreground"> <span className="font-semibold">{current.label}.</span> {current.summary} </p> <div className="grid gap-4 sm:grid-cols-2"> <div> <p className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> <Check aria-hidden="true" className="size-3.5" /> {msg("autonomy-dial.does", "Does on its own")} </p> <ul className="mt-2 grid gap-1.5 text-[13px] leading-snug"> {current.does.map((t) => ( <li key={t} className="flex gap-2"> <span aria-hidden="true" className="mt-[0.45rem] size-1 shrink-0 rounded-full bg-foreground" /> {t} </li> ))} </ul> </div> <div> <p className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> <HelpCircle aria-hidden="true" className="size-3.5" /> {msg("autonomy-dial.asks", "Stops to ask about")} </p> <ul className="mt-2 grid gap-1.5 text-[13px] leading-snug"> {current.asks.map((t) => ( <li key={t} className="flex gap-2"> <span aria-hidden="true" className="mt-[0.45rem] size-1 shrink-0 rounded-full border border-foreground" /> {t} </li> ))} </ul> </div> </div> </div> </fieldset> ) } export { AutonomyDial, defaultAutonomyLevels, type AutonomyDialProps, type AutonomyLevel }Update the import paths to match your project setup.
Usage
import { AutonomyDial, defaultAutonomyLevels } from "@/components/ballmac/autonomy-dial"The full example is in the Code tab above.
Examples
Your own levels
import { AutonomyDial, type AutonomyLevel } from "@/components/ballmac/autonomy-dial"
const levels: AutonomyLevel[] = [
{ value: "review", label: "Review only", summary: "Drafts replies and leaves every one for you to send.", does: ["Reads the thread", "Drafts a reply"], asks: ["Sending anything"] },
{ value: "routine", label: "Routine replies", summary: "Sends replies to common questions on its own and asks about the rest.", does: ["Answers shipping and order-status questions", "Tags and files the thread"], asks: ["Refunds", "Anything angry or unusual"] },
{ value: "inbox", label: "Run the inbox", summary: "Handles the whole inbox and reports back each evening.", does: ["Replies, tags and escalates", "Issues refunds up to a limit you set"], asks: ["Refunds above the limit", "Legal or press requests"] },
]
export default function AutonomyDialCustom() {
return (
<div className="w-full max-w-xl">
<AutonomyDial levels={levels} defaultValue="routine" label="How much should the support agent handle?" />
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
levelsThe settings, from least to most autonomous. Defaults to four general ones. | AutonomyLevel[] | defaultAutonomyLevels |
valueThe selected value. Controlled. | string | — |
defaultValueThe selected value for uncontrolled use. | string | — |
onValueChangeCalled with the new value. | (value: string) => void | — |
labelHeading above the dial. | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Tab | Moves into the dial once, onto the selected step |
| ArrowRight / ArrowDown | Next level (native radio behaviour; the browser mirrors it in right-to-left) |
| ArrowLeft / ArrowUp | Previous level |
| Screen readers | A fieldset with a legend, one radio per level, and a polite live region that reads the new meaning when it changes |
| Meaning | Carried by the words under the dial, not by the track or its colour |
Use with AI
levels is [{ value, label, summary, does: string[], asks: string[] }] from least to most autonomous (defaultAutonomyLevels has four). value / defaultValue / onValueChange. A native radio group, so arrow keys work; the panel under the dial says in words what the chosen level allows. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Autonomy Dial (@ballmac/autonomy-dial) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Letting people decide how far an agent may go before asking
- A settings row for an assistant's permissions
Not for
- A single yes or no approval; use approval-card
- A numeric value; use slider
Registry JSON: https://ui.ballmac.com/r/autonomy-dial.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
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.
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.
Agent Plan
A live task plan for agents: a vertical timeline with pending, running, done, failed and skipped steps, nested substeps, expandable output, segmented progress and retry on failure.
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.
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.