AI

Human Handoff

A bot-to-human escalation card that walks one ticket through request, queue, connecting, connected and an offline leave-your-address fallback.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  Check,
  Clock3,
  Headset,
  LoaderCircle,
  MailCheck,
  MessageSquare,
  MoonStar,
  ShieldCheck,
  UserRoundCheck,
} from "lucide-react"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/human-handoff.json

Prompt

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 etaSeconds immediately 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.

On This Page