Every agent on a team of agents as a card: name and role, a status in words (working, waiting, blocked, idle, offline), what it is doing, a load meter and when it was last active, with status counts that filter the grid.
Agents
6 agents shown
import { AgentRoster, type RosterAgent } from "@/components/ballmac/agent-roster"
const agents: RosterAgent[] = [
{ id: "plan", name: "Planner", role: "Breaks down the task", status: "idle", task: "Finished the plan for the invoice export.", load: 4, lastActive: "2 min ago" },
{ id: "research", name: "Researcher", role: "Finds what exists", status: "working", task: "Reading the payment client to see where the failure surfaces.", load: 72, lastActive: "just now" },
{ id: "code", name: "Coder", role: "Writes the change", status: "working", task: "Editing the export view and four tests.", load: 88, lastActive: "just now" },
{ id: "review", name: "Reviewer", role: "Checks the result", status: "waiting", task: "Waiting for the coder to finish before reading the diff.", load: 0, lastActive: "1 min ago" },
{ id: "deploy", name: "Releaser", role: "Ships approved changes", status: "blocked", task: "Needs a person to approve the production deploy.", load: 0, lastActive: "6 min ago" },
{ id: "support", name: "Support triage", role: "Sorts new tickets", status: "offline", task: "Paused for the night.", lastActive: "8 h ago" },
]
export default function AgentRosterDemo() {
return (
<div className="w-full max-w-4xl">
<AgentRoster agents={agents} defaultSelected="code" />
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/agent-rosterInstall 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/agent-roster.tsx// Ballmac UI: Agent Roster. https://ui.ballmac.com/components/agent-roster "use client" import * as React from "react" import { AlertOctagon, CheckCircle2, CircleSlash, Hourglass, Loader2, type LucideIcon } from "lucide-react" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" type AgentStatus = "working" | "idle" | "waiting" | "blocked" | "offline" type RosterAgent = { id: string name: string /** What it is for, for example "Writes the code". */ role?: string status: AgentStatus /** What it is doing right now, or last did. */ task?: string /** How busy it is, 0 to 100. */ load?: number /** When it was last active, already formatted, such as "just now". */ lastActive?: string } const STATUS: Record<AgentStatus, { icon: LucideIcon; fallback: string; tone: string }> = { working: { icon: Loader2, fallback: "Working", tone: "text-foreground" }, idle: { icon: CheckCircle2, fallback: "Idle", tone: "text-muted-foreground" }, waiting: { icon: Hourglass, fallback: "Waiting", tone: "text-foreground" }, blocked: { icon: AlertOctagon, fallback: "Blocked", tone: "text-destructive" }, offline: { icon: CircleSlash, fallback: "Offline", tone: "text-muted-foreground" }, } const ORDER: AgentStatus[] = ["working", "waiting", "blocked", "idle", "offline"] const initials = (n: string) => n.split(/\s+/).map((w) => w[0]).slice(0, 2).join("").toUpperCase() type AgentRosterProps = Omit<React.ComponentProps<"section">, "onSelect" | "title"> & { /** The agents to show. */ agents: RosterAgent[] /** The selected agent id. Controlled. */ selected?: string | null /** The selected agent id for uncontrolled use. */ defaultSelected?: string | null /** Called when an agent is picked, or null when it is cleared. */ onSelectedChange?: (id: string | null) => void /** Heading. */ title?: string } /** * Every agent on a team of agents as a card: who it is, whether it is working, waiting or blocked, what it is doing and how busy it is. Status counts * are filters, a card is a toggle button, and a blocked agent is called out in words. A selection is yours to use, for example to open its run. */ function AgentRoster({ agents, selected: selectedProp, defaultSelected = null, onSelectedChange, title, className, ...props }: AgentRosterProps) { const msg = useMessages() const uid = React.useId() title ??= msg("agent-roster.title", "Agents") const [inner, setInner] = React.useState<string | null>(defaultSelected) const selected = selectedProp !== undefined ? selectedProp : inner const [filter, setFilter] = React.useState<AgentStatus | "all">("all") const count = (s: AgentStatus) => agents.filter((a) => a.status === s).length const shown = filter === "all" ? agents : agents.filter((a) => a.status === filter) const text = (s: AgentStatus) => msg(`agent-roster.${s}`, STATUS[s].fallback) function pick(id: string) { const next = id === selected ? null : id if (selectedProp === undefined) setInner(next) onSelectedChange?.(next) } return ( <section data-slot="agent-roster" aria-labelledby={`${uid}-t`} className={cn("grid w-full min-w-0 gap-4", className)} {...props}> <div className="flex flex-wrap items-center justify-between gap-3"> <h3 id={`${uid}-t`} className="text-sm font-medium text-foreground">{title}</h3> <div role="group" aria-label={msg("agent-roster.filter", "Filter by status")} className="flex max-w-full flex-wrap gap-1.5"> {(["all", ...ORDER] as const).map((k) => { const n = k === "all" ? agents.length : count(k) if (k !== "all" && n === 0) return null return ( <button key={k} type="button" aria-pressed={filter === k} onClick={() => setFilter(k)} className={cn("h-8 rounded-full border px-3 text-xs font-medium outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none", filter === k ? "border-foreground bg-foreground text-background" : "hover:bg-accent")}> {k === "all" ? msg("agent-roster.all", "All") : text(k)} <span className={cn("tabular-nums", filter === k ? "opacity-70" : "text-muted-foreground")}>{n}</span> </button> ) })} </div> </div> <p aria-live="polite" className="sr-only">{msg("agent-roster.count", "{count} agents shown", { count: shown.length })}</p> <ul className="grid gap-3 [grid-template-columns:repeat(auto-fill,minmax(15rem,1fr))]"> {shown.map((a) => { const s = STATUS[a.status] const Icon = s.icon const on = selected === a.id return ( <li key={a.id}> <button type="button" data-status={a.status} aria-pressed={on} onClick={() => pick(a.id)} className={cn("flex h-full w-full flex-col gap-3 rounded-xl border bg-card p-4 text-start text-card-foreground outline-none transition-[border-color,background-color] duration-150 hover:bg-accent/40 focus-visible:ring-[3px] focus-visible:ring-ring/50 motion-reduce:transition-none", on && "border-foreground bg-accent/40", a.status === "blocked" && !on && "border-destructive/50")} > <span className="flex items-start gap-3"> <span aria-hidden="true" className="flex size-9 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold">{initials(a.name)}</span> <span className="min-w-0 flex-1"> <span className="block truncate text-sm font-semibold text-foreground">{a.name}</span> {a.role && <span className="block truncate text-xs text-muted-foreground">{a.role}</span>} </span> <span className={cn("inline-flex shrink-0 items-center gap-1.5 text-xs font-medium", s.tone)}> <Icon aria-hidden="true" className={cn("size-3.5", a.status === "working" && "motion-safe:animate-spin")} style={a.status === "working" ? { animationPlayState: "var(--bm-ambient-state, running)" } : undefined} /> {text(a.status)} </span> </span> {a.task && <span className="line-clamp-2 text-[13px] leading-snug text-muted-foreground">{a.task}</span>} <span className="mt-auto flex items-center gap-3"> {typeof a.load === "number" && ( <span className="flex flex-1 items-center gap-2"> <span role="meter" aria-label={msg("agent-roster.load", "{name} load", { name: a.name })} aria-valuemin={0} aria-valuemax={100} aria-valuenow={Math.round(a.load)} className="h-1.5 flex-1 overflow-hidden rounded-full bg-muted"> <span className="block h-full rounded-full bg-foreground" style={{ width: `${Math.min(100, Math.max(0, a.load))}%` }} /> </span> <span className="font-mono text-[11px] text-muted-foreground tabular-nums">{Math.round(a.load)}%</span> </span> )} {a.lastActive && <span className="ms-auto text-[11px] text-muted-foreground">{a.lastActive}</span>} </span> </button> </li> ) })} </ul> </section> ) } export { AgentRoster, type AgentRosterProps, type AgentStatus, type RosterAgent }Update the import paths to match your project setup.
Usage
import { AgentRoster } from "@/components/ballmac/agent-roster"The full example is in the Code tab above.
Examples
Select to open a run
Agents
3 agents shown
Pick an agent to open its run.
"use client"
import * as React from "react"
import { AgentRoster, type RosterAgent } from "@/components/ballmac/agent-roster"
const agents: RosterAgent[] = [
{ id: "a", name: "Billing agent", role: "Answers billing questions", status: "working", task: "Replying to 3 tickets.", load: 54, lastActive: "just now" },
{ id: "b", name: "Onboarding agent", role: "Guides new accounts", status: "idle", task: "Sent 12 welcome emails today.", load: 6, lastActive: "4 min ago" },
{ id: "c", name: "Refund agent", role: "Handles refund requests", status: "blocked", task: "One request is over its limit and needs a person.", load: 0, lastActive: "11 min ago" },
]
export default function AgentRosterSelect() {
const [picked, setPicked] = React.useState<string | null>(null)
const agent = agents.find((a) => a.id === picked)
return (
<div className="grid w-full max-w-3xl gap-4">
<AgentRoster agents={agents} selected={picked} onSelectedChange={setPicked} />
<p aria-live="polite" className="rounded-lg border bg-card px-4 py-3 text-sm text-muted-foreground">
{agent ? <>Opening the run for <strong className="text-foreground">{agent.name}</strong>.</> : "Pick an agent to open its run."}
</p>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
agents*The agents to show. | RosterAgent[] | — |
selectedThe selected agent id. Controlled. | string | null | — |
defaultSelectedThe selected agent id for uncontrolled use. | string | null | null |
onSelectedChangeCalled when an agent is picked, or null when it is cleared. | (id: string | null) => void | — |
titleHeading. | string | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Tab, Enter / Space | Filters and cards are buttons; a card is a toggle (aria-pressed) |
| Screen readers | Status is written as a word beside its icon; the load is a labelled meter; the shown count is a polite live region |
| Blocked | Called out in words and an icon, with a red border only as reinforcement |
Use with AI
agents is [{ id, name, role?, status: working | idle | waiting | blocked | offline, task?, load?: 0..100, lastActive? }]. selected / defaultSelected / onSelectedChange track the picked agent (a second click clears it). Status counts are filters; statuses with no agents are hidden. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Agent Roster (@ballmac/agent-roster) to this project with the shadcn MCP, then use it where it fits.
Use it for
- An overview of the agents in a multi-agent product and what each is doing
- Picking an agent to open its run, logs or settings
Not for
- One agent's steps; use trace
- A table of users; use data-table
- Who handed what to whom; use agent-lanes or handoff-card
Registry JSON: https://ui.ballmac.com/r/agent-roster.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
Agent Lanes
A swimlane timeline for multi-agent runs: one lane per agent, steps coloured by what they are doing, dashed handoffs between lanes, a Now line and a detail panel, all reachable with the arrow keys.
Handoff Card
The packet one agent passes to another: who hands over to whom, the task, the context that travels with it and its size, the questions still open and the rules to keep, with a status and room for actions.
Run Ledger
What an agent run cost, step by step: sortable time, tokens and money with each row's share, a total, the most expensive step marked, and an optional budget meter that warns in words.
Trace
The one row behind reasoning, tool calls, sources and plans: an icon, a title and optional detail, with running, error and pending states, rows that expand, link or open a dialog, and shadcn's Trace API.
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.