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.
Waiting to be picked up
Add a retry with backoff to the invoice export
Context passed
- The plan · four steps, one open1.2k tokens
- Relevant files · export.ts, client.ts, three tests8.4k tokens
- Ticket thread · customer reports a timeout2.1k tokens
Open questions
- Should a failed export be retried for large accounts too?
Rules to keep
- Do not change the public API
- Keep the change under 200 lines
- No new dependencies
import { HandoffCard } from "@/components/ballmac/handoff-card"
export default function HandoffCardDemo() {
return (
<div className="w-full max-w-xl">
<HandoffCard
from={{ name: "Planner", role: "Breaks down the task" }}
to={{ name: "Coder", role: "Writes the change" }}
title="Add a retry with backoff to the invoice export"
at="2:14 pm"
context={[
{ label: "The plan", detail: "four steps, one open", size: "1.2k tokens" },
{ label: "Relevant files", detail: "export.ts, client.ts, three tests", size: "8.4k tokens" },
{ label: "Ticket thread", detail: "customer reports a timeout", size: "2.1k tokens" },
]}
questions={["Should a failed export be retried for large accounts too?"]}
constraints={["Do not change the public API", "Keep the change under 200 lines", "No new dependencies"]}
/>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/handoff-cardInstall 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/handoff-card.tsx// Ballmac UI: Handoff Card. https://ui.ballmac.com/components/handoff-card "use client" import * as React from "react" import { ArrowRight, CircleHelp, CornerUpLeft, FileText, Lock } from "lucide-react" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" type HandoffAgent = { name: string /** What it is for, for example "Writes the code". */ role?: string } type HandoffContext = { /** What was passed, for example "The plan". */ label: string /** More about it, in a few words. */ detail?: string /** Size or length, already formatted, such as "1.2k tokens". */ size?: string } type HandoffStatus = "pending" | "accepted" | "returned" const STATUS: Record<HandoffStatus, string> = { pending: "Waiting to be picked up", accepted: "Accepted", returned: "Sent back" } function initials(name: string) { return name .split(/\s+/) .map((w) => w[0]) .slice(0, 2) .join("") .toUpperCase() } type HandoffCardProps = Omit<React.ComponentProps<"article">, "title"> & { /** The agent handing the work over. */ from: HandoffAgent /** The agent receiving it. */ to: HandoffAgent /** What the work is. */ title: string /** Everything passed along. */ context?: HandoffContext[] /** Questions the sender could not settle. */ questions?: string[] /** Rules the receiver must keep to, shown as chips. */ constraints?: string[] status?: HandoffStatus /** Text for the status, if you want your own words. */ statusLabel?: string /** Time or place of the handoff, already formatted, such as "2:14 pm". */ at?: string /** Buttons or other actions under the card. */ actions?: React.ReactNode } /** * The packet one agent passes to another: who to who, what the work is, what context travels with it, the questions still open and the rules to * keep. It is a plain server-friendly card, so a transcript or a dashboard can render a hundred of them. */ function HandoffCard({ from, to, title, context = [], questions = [], constraints = [], status = "pending", statusLabel, at, actions, className, ...props }: HandoffCardProps) { const id = React.useId() const msg = useMessages() return ( <article data-slot="handoff-card" data-status={status} aria-labelledby={`${id}-t`} className={cn("grid w-full gap-4 rounded-xl border bg-card p-4 text-card-foreground sm:p-5", className)} {...props}> <div className="flex flex-wrap items-center gap-x-3 gap-y-2"> <div className="flex min-w-0 items-center gap-2.5"> <span aria-hidden="true" className="flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-semibold">{initials(from.name)}</span> <span className="min-w-0"> <span className="block truncate text-sm font-medium text-foreground">{from.name}</span> {from.role && <span className="block truncate text-xs text-muted-foreground">{from.role}</span>} </span> </div> <ArrowRight aria-label={msg("handoff-card.handsOver", "hands over to")} role="img" className="size-4 shrink-0 text-muted-foreground rtl:rotate-180" /> <div className="flex min-w-0 items-center gap-2.5"> <span aria-hidden="true" className="flex size-8 shrink-0 items-center justify-center rounded-full bg-foreground text-xs font-semibold text-background">{initials(to.name)}</span> <span className="min-w-0"> <span className="block truncate text-sm font-medium text-foreground">{to.name}</span> {to.role && <span className="block truncate text-xs text-muted-foreground">{to.role}</span>} </span> </div> <p className="ms-auto flex items-center gap-2 text-xs text-muted-foreground"> <span className="rounded-full border px-2 py-0.5 font-medium text-foreground">{statusLabel ?? msg(`handoff-card.status.${status}`, STATUS[status])}</span> {at && <time className="tabular-nums">{at}</time>} </p> </div> <h3 id={`${id}-t`} className="text-base leading-snug font-semibold tracking-tight text-balance"> {title} </h3> {context.length > 0 && ( <section aria-label={msg("handoff-card.context", "Context passed")}> <p className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> <FileText aria-hidden="true" className="size-3.5" /> {msg("handoff-card.context", "Context passed")} </p> <ul className="mt-2 divide-y rounded-lg border"> {context.map((c) => ( <li key={c.label} className="flex items-baseline justify-between gap-3 px-3 py-2 text-[13px]"> <span className="min-w-0"> <span className="font-medium text-foreground">{c.label}</span> {c.detail && <span className="text-muted-foreground"> · {c.detail}</span>} </span> {c.size && <span className="shrink-0 font-mono text-xs text-muted-foreground tabular-nums">{c.size}</span>} </li> ))} </ul> </section> )} {questions.length > 0 && ( <section aria-label={msg("handoff-card.questions", "Open questions")}> <p className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> <CircleHelp aria-hidden="true" className="size-3.5" /> {msg("handoff-card.questions", "Open questions")} </p> <ul className="mt-2 grid gap-1.5 text-[13px] leading-snug"> {questions.map((q) => ( <li key={q} className="flex gap-2"> <CornerUpLeft aria-hidden="true" className="mt-0.5 size-3.5 shrink-0 text-muted-foreground rtl:-scale-x-100" /> {q} </li> ))} </ul> </section> )} {constraints.length > 0 && ( <section aria-label={msg("handoff-card.rules", "Rules to keep")}> <p className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground"> <Lock aria-hidden="true" className="size-3.5" /> {msg("handoff-card.rules", "Rules to keep")} </p> <ul className="mt-2 flex flex-wrap gap-1.5"> {constraints.map((c) => ( <li key={c} className="rounded-full border bg-background px-2.5 py-1 text-xs">{c}</li> ))} </ul> </section> )} {actions && <div className="flex flex-wrap justify-end gap-2 border-t pt-4">{actions}</div>} </article> ) } export { HandoffCard, type HandoffAgent, type HandoffCardProps, type HandoffContext, type HandoffStatus }Update the import paths to match your project setup.
Usage
import { HandoffCard } from "@/components/ballmac/handoff-card"The full example is in the Code tab above.
Examples
Sent back with a question
Sent back
Retry with backoff, ready for review
Context passed
- The diff · 7 files+142 −38
Open questions
- The new test fails on the CI runner but passes locally. Which runner should it be tuned for?
"use client"
import { HandoffCard } from "@/components/ballmac/handoff-card"
export default function HandoffCardReturned() {
return (
<div className="w-full max-w-xl">
<HandoffCard
from={{ name: "Coder", role: "Writes the change" }}
to={{ name: "Reviewer", role: "Checks the result" }}
title="Retry with backoff, ready for review"
status="returned"
statusLabel="Sent back"
at="2:41 pm"
context={[{ label: "The diff", detail: "7 files", size: "+142 −38" }]}
questions={["The new test fails on the CI runner but passes locally. Which runner should it be tuned for?"]}
actions={
<>
<button type="button" className="h-9 rounded-full border bg-background px-4 text-sm font-medium outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50">Answer</button>
<button type="button" className="h-9 rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-[3px] focus-visible:ring-ring/50">Send it back again</button>
</>
}
/>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
from*The agent handing the work over. | HandoffAgent | — |
to*The agent receiving it. | HandoffAgent | — |
title*What the work is. | string | — |
contextEverything passed along. | HandoffContext[] | [] |
questionsQuestions the sender could not settle. | string[] | [] |
constraintsRules the receiver must keep to, shown as chips. | string[] | [] |
status | HandoffStatus | "pending" |
statusLabelText for the status, if you want your own words. | string | — |
atTime or place of the handoff, already formatted, such as "2:14 pm". | string | — |
actionsButtons or other actions under the card. | React.ReactNode | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Screen readers | An article named by its title; the arrow between agents reads 'hands over to'; each list has a labelled section |
| Status | Written as words in a pill, never colour alone |
| Actions | Whatever buttons you pass keep their own keyboard behaviour |
Use with AI
from and to are { name, role? }; title; context [{ label, detail?, size? }]; questions string[]; constraints string[]; status pending | accepted | returned; at; actions slot. A server-friendly card with no state. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Handoff Card (@ballmac/handoff-card) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Showing what one agent passed to another, in a transcript, an inbox or a run view
- A human review of a delegation before it is accepted
Not for
- The timeline of who did what when; use agent-lanes
- A single step of a plan; use agent-plan
Registry JSON: https://ui.ballmac.com/r/handoff-card.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.
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.
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.
Agent Roster
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.
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.