Skip to content
loopkit
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.