Installation
npx shadcn@latest add @pixeldosa/human-handoffRequires `cn` at `@/lib/utils`. Fill `context` with the facts a colleague would ask for in the first thirty seconds, including what the agent already tried.
Usage
"use client";
import { HumanHandoff } from "@/components/ui/human-handoff";
<HumanHandoff
stage="waitingForPerson"
reason="The customer asked for a refund outside the policy window. I can't decide this one."
context={[
{ id: "customer", label: "Customer", value: "Priya Menon · 3 years, no refunds before" },
{ id: "tried", label: "I already tried", value: "Offered a replacement and a 30% credit. Both declined." },
]}
onTakeOver={take}
/>
Why it exists
Handoffs usually lose everything: the customer repeats themselves, whoever takes over re-reads a transcript to find three facts, and nobody records what the agent already tried — so the same rejected remedy gets offered twice.
Features
- The context is the component. The facts a colleague would ask for in the first thirty seconds, including what the agent already tried.
- The reason is in the agent's own words, so the person knows why it stopped.
- It works both ways. Handing a finished job back to the agent is a real stage, not an afterthought.
- Waiting holds completely still, in the waiting colour, the same treatment approvals get.
Variants
Waiting for a person
Refund decision
Waiting for a person to take over. Waiting 6 minutes.
The customer asked for a refund outside the policy window. I can't decide this one.
- Order
- #48120 · £240 · delivered 31 days ago
- I already tried
- Offered a replacement and a 30% credit. Both declined.
A person has it
Billing question
A person has taken over. Dana in support.
Dana has the conversation. I'll keep the notes up to date.
Props
| Prop | Type | Notes |
|---|---|---|
stage | "agentHandling" | "waitingForPerson" | "personHandling" | "returnedToAgent" | Required. |
reason | string | Why the agent stepped back, in its words. |
context | { id, label, value }[] | The facts the person receives. |
person | string | Who has it now. |
waitingFor | string | How long, formatted by you. |
onTakeOver / takeOverLabel | () => void / string | |
onReturnToAgent / returnLabel | () => void / string | |
title | string | Defaults to "Handover". |
headingLevel | 2 | 3 | 4 | 5 | 6 | Defaults to 3. |
Accessibility
- A labelled
<section>; the context is a description list, so labels and values are paired for screen readers. - The stage is stated in words, not only by colour.
- Nothing animates in any stage.
When not to use
- One consequential action awaiting a decision: use
AI Approval Gate. - A question the agent could answer itself: use
Agent Ask. - Routing or assignment rules: that's your queueing system, not this.
why it's built this way
Engineering Notes
Stage is a four-value enum rather than a boolean because the return leg is a real state with its own copy, and products that model handoff as 'escalated: true' have nowhere to put the moment a person finishes and the agent resumes. Waiting is styled in the waiting colour with full stillness, the same treatment approvals get, so a queue of handoffs reads at a glance without a separate legend. The context list is `dl` markup rather than free text so a caller can render five facts consistently and a screen reader hears label-and-value pairs, and it sits inside the component rather than being left to a slot because the whole failure mode is teams shipping a handoff button with no payload.
how it moves, and when it doesn't
Motion Notes
Nothing animates, in any stage. A waiting handoff is a person's turn and the strongest signal available is complete stillness; a pulsing 'needs attention' badge here would compete with the queue around it and train people to ignore it.