The moment an AI agent hands a conversation to a person: who is waiting and for how long, why, how the customer seems to feel in words, what the agent understood, the last messages, an editable suggested reply, and Take over and Snooze.
Maria Lopez
Chat · Help centre
Seems Frustrated Waiting 4 min
Why a person is needed: The refund is over the amount an agent may approve alone, and the customer asked to speak to a person.
What the agent understood
- Order A-9914, paid by card on 12 October
- Item arrived damaged; photos were sent
- Refund of $340 requested, policy allows it
- Maria Lopez · 2:09 pmThis is the second time it has arrived broken. I want my money back, not another replacement.
- Agent · 2:10 pmI am sorry about that. I can see the photos. Let me check what I can do for you.
- Maria Lopez · 2:11 pmPlease just get someone who can actually approve it.
"use client"
import { EscalationCard } from "@/components/ballmac/escalation-card"
export default function EscalationCardDemo() {
return (
<div className="w-full max-w-xl">
<EscalationCard
customer="Maria Lopez"
channel="Chat · Help centre"
sentiment="frustrated"
waiting="4 min"
reason="The refund is over the amount an agent may approve alone, and the customer asked to speak to a person."
summary={["Order A-9914, paid by card on 12 October", "Item arrived damaged; photos were sent", "Refund of $340 requested, policy allows it"]}
messages={[
{ from: "customer", text: "This is the second time it has arrived broken. I want my money back, not another replacement.", time: "2:09 pm" },
{ from: "agent", text: "I am sorry about that. I can see the photos. Let me check what I can do for you.", time: "2:10 pm" },
{ from: "customer", text: "Please just get someone who can actually approve it.", time: "2:11 pm" },
]}
suggestedReply={"Hi Maria, I am sorry this has happened twice. I have checked the photos and I am approving a full refund of $340 to your card now. It usually takes 3 to 5 working days to show."}
onTakeOver={() => new Promise((r) => setTimeout(r, 500))}
onSnooze={() => new Promise((r) => setTimeout(r, 400))}
onSendReply={() => new Promise((r) => setTimeout(r, 500))}
/>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/escalation-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/escalation-card.tsx// Ballmac UI: Escalation Card. https://ui.ballmac.com/components/escalation-card "use client" import * as React from "react" import { BellOff, Clock, HandHelping, Loader2, Send, Sparkles } from "lucide-react" import { cn } from "@/lib/utils" import { useMessages } from "@/lib/ballmac/i18n" type Sentiment = "calm" | "frustrated" | "angry" | "unknown" type EscalationMessage = { from: "customer" | "agent" text: string /** When it was sent, already formatted, such as "2:11 pm". */ time?: string } type EscalationCardProps = Omit<React.ComponentProps<"article">, "title"> & { customer: string /** Where the conversation is happening, such as "Chat" or "Email". */ channel: string /** Why the agent is handing it to a person, in a sentence. */ reason: string sentiment?: Sentiment /** How long they have been waiting, already formatted, such as "4 min". */ waiting?: string /** What the agent understood so far, as short points. */ summary?: string[] /** The last few messages. */ messages?: EscalationMessage[] /** A reply the agent suggests, which the person can edit before sending. */ suggestedReply?: string /** Called when the person takes the conversation over. */ onTakeOver?: () => void | Promise<void> /** Called to snooze it for a while. */ onSnooze?: () => void | Promise<void> /** Called with the (possibly edited) reply when the person sends it. */ onSendReply?: (reply: string) => void | Promise<void> } const SENTIMENT: Record<Sentiment, string> = { calm: "Calm", frustrated: "Frustrated", angry: "Angry", unknown: "Unknown" } /** * The moment an AI agent hands a conversation to a person: who is waiting and for how long, why it was handed over, how the customer seems to feel (in words), * what the agent understood, the last messages, and a suggested reply the person can edit and send. Take over and Snooze report back, and a failure is said plainly. */ function EscalationCard({ customer, channel, reason, sentiment = "unknown", waiting, summary = [], messages = [], suggestedReply = "", onTakeOver, onSnooze, onSendReply, className, ...props }: EscalationCardProps) { const msg = useMessages() const uid = React.useId() const [reply, setReply] = React.useState(suggestedReply) const [busy, setBusy] = React.useState<"take" | "snooze" | "send" | null>(null) const [result, setResult] = React.useState<{ ok: boolean; text: string } | null>(null) const [done, setDone] = React.useState<string | null>(null) async function run(kind: "take" | "snooze" | "send", work: () => void | Promise<void>, ok: string) { setBusy(kind) setResult(null) try { await work() setDone(kind) setResult({ ok: true, text: ok }) } catch (e) { setResult({ ok: false, text: e instanceof Error ? e.message : msg("escalation-card.failed", "That did not go through. Nothing was changed.") }) } finally { setBusy(null) } } const sentimentText = msg(`escalation-card.sentiment.${sentiment}`, SENTIMENT[sentiment]) const finished = done !== null && done !== "send" return ( <article data-slot="escalation-card" data-sentiment={sentiment} aria-labelledby={`${uid}-t`} className={cn("grid w-full gap-4 rounded-xl border bg-card p-4 text-card-foreground sm:p-5", sentiment === "angry" && "border-destructive/50", className)} {...props}> <header className="flex flex-wrap items-start justify-between gap-x-6 gap-y-2"> <div className="min-w-0"> <h3 id={`${uid}-t`} className="text-base font-semibold tracking-tight">{customer}</h3> <p className="text-xs text-muted-foreground">{channel}</p> </div> <p className="flex flex-wrap items-center gap-2 text-xs"> <span className={cn("rounded-full border px-2.5 py-0.5 font-medium", sentiment === "angry" && "border-destructive/60 text-destructive")}> <span className="sr-only">{msg("escalation-card.feels", "Seems")} </span>{sentimentText} </span> {waiting && ( <span className="flex items-center gap-1.5 tabular-nums text-muted-foreground"> <Clock aria-hidden="true" className="size-3.5" /> {msg("escalation-card.waiting", "Waiting {time}", { time: waiting })} </span> )} </p> </header> <p className="flex gap-2 rounded-lg bg-muted/50 px-3 py-2.5 text-[13px] leading-snug"> <HandHelping aria-hidden="true" className="mt-0.5 size-4 shrink-0" /> <span><span className="font-medium">{msg("escalation-card.why", "Why a person is needed:")}</span> {reason}</span> </p> {summary.length > 0 && ( <section aria-label={msg("escalation-card.summary", "What the agent understood")}> <p className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground"><Sparkles aria-hidden="true" className="size-3.5" /> {msg("escalation-card.summary", "What the agent understood")}</p> <ul className="mt-2 grid gap-1.5 text-[13px] leading-snug"> {summary.map((s) => ( <li key={s} className="flex gap-2"><span aria-hidden="true" className="mt-[0.45rem] size-1 shrink-0 rounded-full bg-foreground" />{s}</li> ))} </ul> </section> )} {messages.length > 0 && ( <section aria-label={msg("escalation-card.messages", "Last messages")}> <ul className="grid gap-2"> {messages.map((m, i) => ( <li key={i} className={cn("max-w-[85%] rounded-xl px-3 py-2 text-[13px] leading-snug", m.from === "customer" ? "justify-self-start bg-muted" : "justify-self-end border bg-background")}> <span className="mb-0.5 block text-[11px] font-medium text-muted-foreground"> {m.from === "customer" ? customer : msg("escalation-card.agent", "Agent")}{m.time ? ` · ${m.time}` : ""} </span> {m.text} </li> ))} </ul> </section> )} {suggestedReply !== "" && !finished && ( <div className="grid gap-1.5"> <label htmlFor={`${uid}-reply`} className="text-xs font-medium text-muted-foreground">{msg("escalation-card.reply", "Suggested reply, edit before sending")}</label> <textarea id={`${uid}-reply`} value={reply} onChange={(e) => setReply(e.target.value)} rows={4} className="min-h-24 w-full resize-y rounded-lg border bg-background px-3 py-2 text-sm outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" /> </div> )} {!finished && ( <div className="flex flex-wrap items-center justify-end gap-2 border-t pt-4"> <button type="button" disabled={busy !== null} onClick={() => void run("snooze", () => onSnooze?.(), msg("escalation-card.snoozed", "Snoozed. It will come back if the customer writes again."))} className="inline-flex h-9 items-center gap-1.5 rounded-full border bg-background px-3.5 text-sm font-medium outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none"> <BellOff aria-hidden="true" className="size-4" /> {msg("escalation-card.snooze", "Snooze")} </button> {suggestedReply !== "" && ( <button type="button" disabled={busy !== null || !reply.trim()} onClick={() => void run("send", () => onSendReply?.(reply.trim()), msg("escalation-card.sent", "Reply sent to {customer}.", { customer }))} className="inline-flex h-9 items-center gap-1.5 rounded-full border bg-background px-3.5 text-sm font-medium outline-none transition-colors hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none"> {busy === "send" ? <Loader2 aria-hidden="true" className="size-4 animate-spin" /> : <Send aria-hidden="true" className="size-4 rtl:-scale-x-100" />} {msg("escalation-card.send", "Send reply")} </button> )} <button type="button" disabled={busy !== null} onClick={() => void run("take", () => onTakeOver?.(), msg("escalation-card.taken", "You have taken over from the agent."))} className="inline-flex h-9 items-center gap-1.5 rounded-full bg-primary px-4 text-sm font-medium text-primary-foreground outline-none transition-colors hover:bg-primary/90 focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none"> {busy === "take" && <Loader2 aria-hidden="true" className="size-4 animate-spin" />} {msg("escalation-card.take", "Take over")} </button> </div> )} <p aria-live="polite" role={result && !result.ok ? "alert" : undefined} className={cn("text-sm", result && !result.ok ? "font-medium text-destructive" : "text-muted-foreground")}>{result?.text}</p> </article> ) } export { EscalationCard, type EscalationCardProps, type EscalationMessage, type Sentiment }Update the import paths to match your project setup.
Usage
import { EscalationCard } from "@/components/ballmac/escalation-card"The full example is in the Code tab above.
Examples
No suggested reply
Jonas Weber
Seems Calm Waiting 22 min
Why a person is needed: The question is about a contract, which the agent is not allowed to answer.
What the agent understood
- Asks whether the annual plan can be switched to a team plan mid-term
"use client"
import { EscalationCard } from "@/components/ballmac/escalation-card"
export default function EscalationCardSimple() {
return (
<div className="w-full max-w-xl">
<EscalationCard
customer="Jonas Weber"
channel="Email"
sentiment="calm"
waiting="22 min"
reason="The question is about a contract, which the agent is not allowed to answer."
summary={["Asks whether the annual plan can be switched to a team plan mid-term"]}
onTakeOver={() => new Promise((r) => setTimeout(r, 400))}
onSnooze={() => Promise.reject(new Error("The inbox service is unavailable, so it is still open."))}
/>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
customer* | string | — |
channel*Where the conversation is happening, such as "Chat" or "Email". | string | — |
reason*Why the agent is handing it to a person, in a sentence. | string | — |
sentiment | Sentiment | "unknown" |
waitingHow long they have been waiting, already formatted, such as "4 min". | string | — |
summaryWhat the agent understood so far, as short points. | string[] | [] |
messagesThe last few messages. | EscalationMessage[] | [] |
suggestedReplyA reply the agent suggests, which the person can edit before sending. | string | "" |
onTakeOverCalled when the person takes the conversation over. | () => void | Promise<void> | — |
onSnoozeCalled to snooze it for a while. | () => void | Promise<void> | — |
onSendReplyCalled with the (possibly edited) reply when the person sends it. | (reply: string) => void | Promise<void> | — |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Screen readers | An article named by the customer; how they seem to feel is read as a word ('Seems frustrated'); results are a polite live region and failures an alert |
| Tab | The suggested reply, then Snooze, Send reply and Take over |
| Sentiment | Always a word in a pill; red only reinforces 'Angry' |
Use with AI
customer, channel, reason (required); sentiment calm | frustrated | angry | unknown, waiting, summary string[], messages [{ from: 'customer' | 'agent', text, time? }], suggestedReply (editable). onTakeOver, onSnooze and onSendReply(reply) may return promises; each reports success in words and shows a failure as an alert. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI Escalation Card (@ballmac/escalation-card) to this project with the shadcn MCP, then use it where it fits.
Use it for
- Showing a person a conversation an agent could not finish, with context and a head start
- An inbox row that needs a human decision
Not for
- Approving a tool call; use approval-card
- The packet passed between two agents; use handoff-card
Registry JSON: https://ui.ballmac.com/r/escalation-card.json
Credits
Free to use in personal and commercial projects.
- npm
- lucide-react
- Registry
- @ballmac/i18nshadcn/utils
Pairs well with
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.
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.
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.
AI Chat
The layout for a chat UI: a message log that sticks to the bottom while replies stream unless the reader scrolls up, a jump-to-latest button, an empty state with suggestions and a footer.
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.