free114 lines · framer-motion
Handoff Banner
The agent tags you in. Clearly. A banner for when an agent hands a conversation to a person: the reason, what it already did, an SLA timer, and Take over / Send back.
- Use it for
- Support desks, sales agents, any chat where a human may need to step in.
- Themes
- Light + dark
- License
- Commercial use (draft)
Live
Handoff · support needs a person5:00
Customer asked for a refund above the €200 policy limit.
- ✓Verified order #9921 and the double charge
- ✓Drafted a reply, not sent
- ✓Customer is waiting in chat (2 min)
tsx
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { cn } from "@/lib/utils";
export type HandoffState = "waiting" | "human" | "returned";
export interface HandoffBannerProps {
agent?: string;
reason?: string;
/** Short bullet summary of what the agent has done so far. */
context?: string[];
/** Seconds before the handoff breaches its SLA. */
slaSeconds?: number;
tone?: "signal" | "info";
onTakeOver?: () => void;
onSendBack?: () => void;
className?: string;
}
export function HandoffBanner({
agent = "support",
reason = "Customer asked for a refund above the €200 policy limit.",
context = ["Verified order #9921 and the double charge", "Drafted a reply, not sent", "Customer is waiting in chat (2 min)"],
slaSeconds = 300,
tone = "signal",
onTakeOver,
onSendBack,
className,
}: HandoffBannerProps) {
const reduce = useReducedMotion();
const [state, setState] = React.useState<HandoffState>("waiting");
const [left, setLeft] = React.useState(slaSeconds);
const [open, setOpen] = React.useState(true);
React.useEffect(() => {
setLeft(slaSeconds);
setState("waiting");
}, [slaSeconds]);
React.useEffect(() => {
if (state !== "waiting") return;
const t = setInterval(() => setLeft((s) => Math.max(0, s - 1)), 1000);
return () => clearInterval(t);
}, [state]);
const mm = `${Math.floor(left / 60)}:${String(left % 60).padStart(2, "0")}`;
const late = left < 60;
const accent = tone === "signal" ? "bg-signal text-signal-foreground" : "bg-info text-white";
return (
<section role="region" aria-label="Handoff to human" className={cn("w-full max-w-xl overflow-hidden rounded-2xl border-2 border-foreground bg-card text-card-foreground", className)}>
<AnimatePresence mode="wait" initial={false}>
{state === "waiting" && (
<motion.div key="w" initial={reduce ? false : { opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }}>
<div className={cn("relative flex items-center gap-3 overflow-hidden px-4 py-2.5", accent)}>
{!reduce && <motion.span aria-hidden className="absolute inset-y-0 -left-1/3 w-1/3 bg-white/25 blur-md" animate={{ x: ["0%", "450%"] }} transition={{ repeat: Infinity, duration: 2.4, ease: "easeInOut" }} />}
<span className="relative flex items-center gap-2 font-mono text-[11px] font-bold uppercase tracking-[0.16em]">
<span className="relative flex size-2">
{!reduce && <span className="absolute inset-0 animate-ping rounded-full bg-current opacity-60" />}
<span className="relative size-2 rounded-full bg-current" />
</span>
Handoff · {agent} needs a person
</span>
<span className={cn("relative ml-auto rounded-md px-2 py-0.5 font-mono text-[12px] font-bold tabular-nums", late ? "bg-white text-destructive" : "bg-black/15")} role="timer" aria-label={`${mm} until SLA`}>
{mm}
</span>
</div>
<div className="p-4">
<p className="text-[15px] font-semibold leading-snug">{reason}</p>
<button type="button" onClick={() => setOpen((o) => !o)} aria-expanded={open} className="mt-2 font-mono text-[11px] text-muted-foreground underline-offset-4 hover:underline">
{open ? "Hide" : "Show"} what the agent did
</button>
<AnimatePresence initial={false}>
{open && (
<motion.ul initial={reduce ? false : { height: 0, opacity: 0 }} animate={{ height: "auto", opacity: 1 }} exit={{ height: 0, opacity: 0 }} className="mt-2 space-y-1 overflow-hidden">
{context.map((c) => (
<li key={c} className="flex gap-2 text-[13px] text-muted-foreground">
<span className="text-ok">✓</span>
{c}
</li>
))}
</motion.ul>
)}
</AnimatePresence>
<div className="mt-4 flex flex-wrap gap-2">
<button type="button" onClick={() => { setState("human"); onTakeOver?.(); }} className="h-10 rounded-lg bg-primary px-4 text-sm font-semibold text-primary-foreground">
Take over
</button>
<button type="button" onClick={() => { setState("returned"); onSendBack?.(); }} className="h-10 rounded-lg border border-border px-4 text-sm font-medium hover:bg-muted">
Send back with a note
</button>
</div>
</div>
</motion.div>
)}
{state !== "waiting" && (
<motion.div key="d" initial={reduce ? false : { opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} className="flex items-center gap-3 p-4" role="status">
<span className={cn("grid size-9 place-items-center rounded-full font-mono text-[11px] font-bold", state === "human" ? "bg-signal text-signal-foreground" : "bg-primary text-primary-foreground")}>
{state === "human" ? "YOU" : agent.slice(0, 2).toUpperCase()}
</span>
<p className="text-[14px]">{state === "human" ? `You have the conversation. ${agent} is watching and can help.` : `Sent back to ${agent} with your note.`}</p>
<button type="button" onClick={() => setState("waiting")} className="ml-auto font-mono text-[11px] underline underline-offset-4">
undo
</button>
</motion.div>
)}
</AnimatePresence>
</section>
);
}
export default HandoffBanner;
How it behaves
- →Coloured strip with a pulsing dot, a sweeping shine and a mm:ss SLA timer that turns red under a minute.
- →Reason in bold, then a collapsible checklist of what the agent has done.
- →Take over and Send back switch to a compact confirmation with undo.
Accessibility
- ✓Region with a label; timer has role=timer.
- ✓Collapsible list uses aria-expanded.
- ✓Result line is role=status.
- ✓Honours prefers-reduced-motion.