Human Handoff
A bot-to-human escalation card that walks one ticket through request, queue, connecting, connected and an offline leave-your-address fallback.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/human-handoff.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "HumanHandoff" component (lucide-react for
icons, shadcn Button / Input / Label). It is ONE card that lives in a chat
transcript and changes shape as a bot-to-human escalation moves along, instead
of five unrelated system messages.
Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement> minus
children; root is role="group" with an aria-label and data-stage={stage}.
- stage: "request" | "queued" | "connecting" | "connected" | "offline" — fully
controlled. The card NEVER advances itself; it renders the stage, emits
intent, and waits for the host to send the next stage back.
- team?: string (default "support") — woven into every stage's wording.
- reason?: string — why the assistant is stepping aside (request stage).
- shareNote?: string — one muted line about what travels with the user.
- position?: number — place in line; clamped to >= 1, never moved locally.
- etaSeconds?: number — the server's estimate; ticks down locally.
- countdown?: boolean (default true) — turn off if you push a value per second.
- announceEverySeconds?: number (default 30) — floor between spoken ETA updates.
- keepTypingNote?: string — reassurance shown while queued and connecting.
- person?: { name; role?; avatarUrl?; greeting? } — pass it during "connecting"
to show who is on the way, not only after they arrive.
- waitedSeconds?: number — overrides the wait the card measured itself.
- offlineNote?: string, defaultEmail?: string, showProgress?: boolean (true).
- copy?: Partial<Record<Stage, {title?; body?}>>, labels?: Partial<Labels> —
merged over derived defaults, so a host can translate without forking.
- onRequest?: () => void | Promise<void>
- onCancel?: (from: Stage) => void
- onLeaveEmail?: (email: string) => void | Promise<void>
- Export isHumanHandoffCancellable(stage) — true for request/queued/connecting —
so the host can gate its own "back to the bot" affordance with exactly the
predicate the card uses.
Behavior — stage machine
- Each stage renders one header (icon or avatar, title, body) plus at most one
extra row: request/connected get the share note, queued/connecting get the
keep-typing note, offline gets the email row.
- A four-dot track (request → queued → connecting → connected) renders as an
<ol> whose dots are aria-hidden and whose real content is an sr-only
"Step 2 of 4: waiting in the queue — current"; the current <li> carries
aria-current="step". "offline" is deliberately OFF that track and hides it —
it is a different exit, not a failed step.
- Every stage change resets the local estimate, clears any failure line and
disarms a half-armed cancel: an armed "leave the queue" must never survive
into a stage where there is no queue left to leave. Do this as a render-phase
sync against the previous stage, not in an effect, so no extra frame renders
the stale state.
Behavior — the clock is the only thing the card owns
- Mirror etaSeconds into state and re-mirror it whenever the prop changes: a
fresh server estimate always wins over the local countdown.
- While stage === "queued" and the remainder is > 0, a 1s interval decrements
it; the interval is created only when it can do work and is cleared on
unmount, on reaching zero, and when the stage moves on.
- The POSITION never moves on its own. A place in line invented client-side is
a lie the user can catch when the next server push contradicts it.
- Print the estimate twice, in two registers: prose that rounds ("in about 4
minutes", "any moment now") and a monospace mm:ss readout in the corner that
is aria-hidden — per-second precision is a visual cue, not something a screen
reader should hear sixty times a minute.
- Measure the real wait: stamp the moment the card first sees queued/connecting
and, on reaching connected, report "Connected after 4 min 12 s". waitedSeconds
overrides it for SSR, reconnects or a resumed thread; if the card mounted
straight into "connected" and knows nothing, it says nothing.
Behavior — announcements
- One sr-only role="status" (polite) plus one role="alert" region, both driven
by { id, text, urgent } state so identical wording re-announces.
- Announce stage TRANSITIONS only; the very first render is silent, because the
message list that inserted the card already announced it.
- One-shot: reaching position 1 announces "You're next in line." exactly once,
and re-arms only after leaving the queue.
- Heartbeat: while queued, at most one spoken ETA update per
announceEverySeconds, tracked against a floor that re-baselines whenever the
server pushes a LONGER wait (a growing estimate must not fire an update the
user did not ask for).
Behavior — cancelling, and the two async actions
- Cancel stays reachable for request/queued/connecting and disappears at
connected. "Not now" on the request stage fires immediately; leaving a queue
or a connection ARMS first — it swaps in "Leave and lose your place?" with a
destructive confirm and a stay-in-line button, auto-disarms after 6s, and
Escape backs out.
- Focus follows the swap: focus moves onto the confirm button when it arms, and
comes back to the leave button on disarm ONLY if focus was lost to <body>
(i.e. it was on the button that just unmounted) — an auto-expiry must never
yank the caret out of the composer.
- onRequest and onLeaveEmail share one lock: a second click while a run is in
flight is dropped, a promise gets the pending label + spinner, a rejection
restores the buttons, prints a muted failure line and says "nothing was
changed" through the alert region. The lock is a REF read and written
synchronously inside the handler, not the pending state: several clicks
dispatched in one task all observe the same stale state, so a state-only guard
would open two tickets. A synchronous handler enters no pending state at all.
Guard resolutions with a run id and an alive ref so a promise landing after
unmount is a no-op.
- The email row exists only when onLeaveEmail is passed — an affordance without
a handler is a lie. Validate against /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/ on
submit, mark the field aria-invalid + aria-describedby (no live region: the
message is read when the field is focused), clear the error on the next
keystroke, and while a submit is in flight make the input readOnly rather than
disabled so an Enter-submit does not throw focus to <body>. On success the row
collapses to "We'll reply to <address>" with a change-address escape hatch.
Rendering & styling
- Semantic tokens only: bg-card / text-card-foreground for the shell, border,
bg-muted + text-muted-foreground for the idle avatar and secondary lines,
bg-primary (and /50, /40) for the track and the join ring, text-destructive
for failures, focus-visible ring from the Button primitive. No hex, no rgb(),
no oklch().
- Avatar: remote portrait with an initials fallback, keyed by the URL that
failed (not a boolean) so a second person handed the same slot gets a fresh
attempt; a ref callback probes node.complete && naturalWidth === 0 because a
cached image can fail before hydration attaches onError.
- Motion is decoration and never load-bearing: the current track dot pings, the
connecting icon spins, and a one-shot ring expands behind the avatar the
moment a person lands (React 19 hoisted <style href precedence> keyframes,
deduped across instances). All of it is motion-reduce:hidden /
motion-reduce:animate-none — the countdown, the stage machine and every label
keep working with animation off.
- The card is width-fluid (w-full); the header is a 3-column flex row (avatar,
title+body, mm:ss) with min-w-0 on the text column so a long team name cannot
push the clock out of the card.
Customization levers
- Stage set: drop "request" if your product escalates without asking (start at
queued), or drop "offline" if the desk is always staffed — both are single
branches plus one entry in the copy map.
- Wording: `copy` overrides title/body per stage and `labels` overrides every
button; both merge over the derived defaults, which is the localization seam.
The prose helpers (ordinal, ETA phrasing, duration phrasing) are plain
functions — swap them for Intl.RelativeTimeFormat / Intl.PluralRules.
- Queue honesty: countdown={false} makes the estimate purely server-pushed;
raise announceEverySeconds for quieter screen-reader output; drop the mm:ss
readout entirely if your wait is measured in hours.
- Rows: showProgress={false} turns the card into a transcript record; omit
keepTypingNote or shareNote to shrink it to two lines; add a "why me" details
disclosure under the reason if your support team needs the routing rationale.
- Confirmation policy: change which stages arm before cancelling (only queued,
or none) and the arm timeout (6s) — or replace the inline confirm with an
AlertDialog if the queue is expensive to lose.
- Identity: swap the avatar for a team crest, or hide the person entirely until
connected if your agents work anonymously.
- Density: the root's gap-3 / p-4 / text-sm and the secondary rows' text-xs are
the only spacing knobs; override them through className.
- If you drop the card inside another <form> (a composer), replace the email
<form> with a div plus an Enter handler — nested forms are invalid HTML.Concepts
- Controlled stage machine — the queue lives on a backend, so the card renders the stage it is given and only emits intent (
onRequest/onCancel/onLeaveEmail); every transition is a round trip, which is what keeps the card and the ticket from telling two different stories. - Local clock, server position — the estimate is mirrored into state and ticked once a second so a four-minute wait does not look frozen, and any fresh
etaSecondsimmediately replaces it; the place in line is never moved locally, because an invented position is a lie the next push exposes. - Coalesced live region — the ETA changes every second but assistive tech hears only transitions, a one-shot "you're next", and at most one heartbeat per 30 seconds; the mm:ss readout that does move every second is
aria-hidden, because it is a glance cue, not a sentence. - Arm before you drop the place — leaving a queue is irreversible, so the cancel button swaps into a confirm pair, carries focus onto it, backs out on Escape, and forgets itself after six seconds so a user who walked away does not return to a card whose next click gives up their spot.
- Offline is an exit, not an error — a closed desk replaces the queue with a leave-your-address row, and that row is rendered only when a handler exists to receive it; a captured address collapses into a confirmation with a change-address escape hatch.
- Keep-your-place reassurance — the queued and connecting stages state that typing continues to work and nothing is lost, because the most common way a handoff fails is the user assuming the thread is frozen and abandoning it.
Interpreter Output
The result of one code-interpreter run — source collapsed by default, stdout / stderr / rich results behind tabs derived from what actually arrived, the exception pinned above them, tail-capped output that sticks while it streams, and a one-shot Re-run.
Scheduled Tasks
A list of scheduled agent automations — a human cadence next to a live countdown to the next run, the last run's outcome inline, a pause switch, a one-shot manual trigger, the failure printed on the row that broke, and four data states.