Specialist transfer card showing when a router agent hands conversation control to a domain specialist, with an expandable summary of the transferred context.
Set your access key from API keys as NEXTUI_TOKEN in .env.local and add the @nextui namespace below to components.json once. Then every item installs by name. Each install counts as a copy on a free account.
$pnpm dlx shadcn@latest add @nextui/agent-handoff
1{2"registries": {3"@nextui": {4"url": "https://nextui.dev/r/{name}.json",5"headers": { "Authorization": "Bearer ${NEXTUI_TOKEN}" }6}7}8}
1"use client";23import { AgentHandoff } from "@/components/nextui/agent-handoff";45export function SpecialistTransfer() {6return (7<AgentHandoff8fromAgent="Triage Router"9toAgent="Billing Specialist"10/>11);12}
| Prop | Type | Default | Description |
|---|---|---|---|
| fromAgent | string | "Router Agent" | Source agent name |
| toAgent | string | "Database Specialist" | Receiving specialist agent name |
| reason | string | – | Short explanation of why the transfer occurred |
| contextItems | { label: string; value: string }[] | – | Key-value context payload passed to the receiving agent |
| defaultOpen | boolean | true | Start with the handoff payload expanded |
| fromRole | string | '"Triage"' | Role line under the source agent |
| toRole | string | '"Postgres · query tuning"' | Role line under the receiving agent |
| contextSize | string | '"1.8k tok"' | Size of the handoff package shown on the travelling chip, e.g. "1.8k tok" |
| phase | "routing" | "transferring" | "accepted" | – | Controlled phase. Leave unset for the accepted state (or the demo) |
| autoPlay | boolean | false | Loop a scripted route → transfer → accept animation; stops on interaction and pauses offscreen |
| className | string | – | Extra classes on the wrapper |
"use client";
import { useEffect, useId, useRef, useState } from "react";
import { Check, ChevronDown, Database, Package, Route } from "lucide-react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";
export type AgentHandoffPhase = "routing" | "transferring" | "accepted";
export type AgentHandoffProps = {
fromAgent?: string;
toAgent?: string;
fromRole?: string;
toRole?: string;
reason?: string;
contextItems?: { label: string; value: string }[];
/** Size of the handoff package, e.g. "1.8k tok". */
contextSize?: string;
/** Controlled phase. Leave unset to show the accepted state (or the demo). */
phase?: AgentHandoffPhase;
defaultOpen?: boolean;
/** Loop a scripted route → transfer → accept animation. */
autoPlay?: boolean;
className?: string;
};
const DEFAULT_CONTEXT = [
{ label: "Goal", value: "Cut /orders p95 latency below 300ms" },
{ label: "Found", value: "Seq scan on orders (14M rows) by customer_id" },
{ label: "Tried", value: "Query cache · no gain on cold paths" },
{ label: "Limits", value: "Read-only prod · migrations on staging only" },
];
const EASE = [0.22, 1, 0.36, 1] as const;
const TICK = 100;
const LOOP = 8000;
function useDemoClock(enabled: boolean, loop: number) {
const ref = useRef<HTMLDivElement>(null);
const [visible, setVisible] = useState(false);
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
const el = ref.current;
if (!el || !enabled) return;
const io = new IntersectionObserver(([entry]) => setVisible(entry.isIntersecting));
io.observe(el);
return () => io.disconnect();
}, [enabled]);
useEffect(() => {
if (!enabled || !visible) return;
const id = setInterval(() => setElapsed((e) => e + TICK), TICK);
return () => clearInterval(id);
}, [enabled, visible]);
return { ref, t: elapsed % loop };
}
/** Transfer between specialist agents: two nodes, a context package travelling between them, and the handoff summary that opens below. */
export function AgentHandoff({
fromAgent = "Router Agent",
toAgent = "Database Specialist",
fromRole = "Triage",
toRole = "Postgres · query tuning",
reason = "Slow-query diagnosis needs index and plan analysis.",
contextItems = DEFAULT_CONTEXT,
contextSize = "1.8k tok",
phase: phaseProp,
defaultOpen = true,
autoPlay = false,
className,
}: AgentHandoffProps) {
const reduce = useReducedMotion() ?? false;
const uid = useId();
const [touched, setTouched] = useState(false);
const [openState, setOpen] = useState(defaultOpen);
const demo = autoPlay && !reduce && !touched && !phaseProp;
const { ref, t } = useDemoClock(demo, LOOP);
const phase: AgentHandoffPhase = phaseProp ?? (demo ? (t < 1400 ? "routing" : t < 2900 ? "transferring" : "accepted") : "accepted");
const progress = demo ? Math.max(0, Math.min(1, (t - 1400) / 1500)) : phase === "accepted" ? 1 : phase === "transferring" ? 0.5 : 0;
const open = demo ? t >= 3200 : openState;
const panelId = `${uid}-ctx`;
return (
<div ref={ref} className={cn("w-full max-w-md text-sm", className)}>
<div className="overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-[0_1px_2px_rgb(0_0_0/0.04),0_12px_32px_-18px_rgb(0_0_0/0.25)]">
{/* Stage */}
<div className="relative bg-gradient-to-b from-muted/50 to-transparent px-4 pt-4 pb-3">
<div className="flex items-start justify-between gap-2">
<Node name={fromAgent} role={fromRole} icon={Route} active={phase === "routing"} dim={phase === "accepted"} reduce={reduce} />
<Node name={toAgent} role={toRole} icon={Database} active={phase === "accepted"} done={phase === "accepted"} dim={phase === "routing"} reduce={reduce} align="right" />
</div>
{/* Track */}
<div className="absolute top-[26px] right-[84px] left-[84px] h-6" aria-hidden>
<span className="absolute inset-x-0 top-1/2 h-px -translate-y-1/2 border-t border-dashed border-foreground/25" />
<motion.span
className="absolute top-1/2 left-0 h-0.5 -translate-y-1/2 rounded-full bg-gradient-to-r from-foreground/20 to-foreground"
initial={false}
animate={{ width: `${progress * 100}%` }}
transition={{ duration: 0.15, ease: "linear" }}
/>
<motion.span
className="absolute top-1/2"
initial={false}
animate={{ left: `${progress * 100}%`, opacity: phase === "transferring" ? 1 : 0, scale: phase === "transferring" ? 1 : 0.6 }}
transition={{ left: { duration: 0.15, ease: "linear" }, default: { duration: 0.25 } }}
style={{ x: "-50%", y: "-50%" }}
>
<span className="inline-flex items-center gap-1 rounded-full border bg-background py-0.5 pr-2 pl-1 font-mono text-[10px] whitespace-nowrap shadow-md">
<span className="grid size-4 place-items-center rounded-full bg-foreground text-background">
<Package className="size-2.5" />
</span>
{contextSize}
</span>
</motion.span>
</div>
<div className="mt-3 flex h-5 items-center justify-center text-[11px]" role="status" aria-live="polite">
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={phase}
initial={reduce ? false : { opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={reduce ? undefined : { opacity: 0, y: -6 }}
transition={{ duration: 0.25, ease: EASE }}
className={cn("inline-flex items-center gap-1.5", phase === "accepted" ? "font-medium text-foreground" : "text-muted-foreground")}
>
{phase === "routing" && <span className={cn("size-1.5 rounded-full bg-sky-500", !reduce && "animate-pulse")} />}
{phase === "routing" ? "Classifying request…" : phase === "transferring" ? `Handing off to ${toAgent}…` : `${toAgent} accepted the handoff · 0.4s`}
</motion.span>
</AnimatePresence>
</div>
</div>
{/* Context package */}
<button
type="button"
aria-expanded={open}
aria-controls={panelId}
onClick={() => {
setTouched(true);
setOpen(!open);
}}
className="flex w-full cursor-pointer items-center gap-2.5 border-t px-4 py-2.5 text-left outline-none hover:bg-muted/40 focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset"
>
<Package className="size-3.5 shrink-0 text-muted-foreground" aria-hidden />
<span className="min-w-0 flex-1">
<span className="block text-xs font-medium">Handoff context</span>
<span className="block truncate text-[11px] text-muted-foreground">{reason}</span>
</span>
<span className="shrink-0 rounded-full border bg-background px-1.5 font-mono text-[10px] text-muted-foreground">
{contextItems.length} items · {contextSize}
</span>
<ChevronDown className={cn("size-3.5 shrink-0 text-muted-foreground transition-transform duration-300", open && "rotate-180")} aria-hidden />
</button>
<AnimatePresence initial={false}>
{open && (
<motion.div
id={panelId}
initial={reduce ? false : { height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={reduce ? undefined : { height: 0, opacity: 0 }}
transition={{ duration: 0.35, ease: EASE }}
className="overflow-hidden"
>
<dl className="mx-3 mb-3 divide-y rounded-xl border bg-background">
{contextItems.map((item, i) => (
<motion.div
key={item.label}
initial={reduce ? false : { opacity: 0, x: -8 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: 0.06 + i * 0.07, duration: 0.3, ease: EASE }}
className="flex items-baseline gap-3 px-3 py-2 text-xs"
>
<dt className="w-14 shrink-0 font-mono text-[10px] tracking-wide text-muted-foreground uppercase">{item.label}</dt>
<dd className="min-w-0 flex-1 truncate">{item.value}</dd>
</motion.div>
))}
</dl>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
}
function Node({
name,
role,
icon: Icon,
active,
done,
dim,
reduce,
align = "left",
}: {
name: string;
role: string;
icon: typeof Route;
active?: boolean;
done?: boolean;
dim?: boolean;
reduce: boolean;
align?: "left" | "right";
}) {
return (
<div className={cn("flex w-[128px] flex-col gap-1.5", align === "right" ? "items-end text-right" : "items-start")}>
<span className="relative mx-[16px] grid size-11 place-items-center">
{active && !reduce && <span className="absolute inset-0 animate-ping rounded-2xl bg-foreground/10 [animation-duration:1.6s]" aria-hidden />}
<motion.span
animate={{ scale: active ? 1 : 0.92, opacity: dim ? 0.55 : 1 }}
transition={{ type: "spring", stiffness: 400, damping: 24 }}
className={cn(
"relative grid size-11 place-items-center rounded-2xl border shadow-sm transition-colors duration-500",
active ? "border-foreground bg-foreground text-background" : "bg-background text-foreground",
)}
>
<Icon className="size-5" aria-hidden />
</motion.span>
<AnimatePresence>
{done && (
<motion.span
initial={reduce ? false : { scale: 0 }}
animate={{ scale: 1 }}
exit={{ scale: 0 }}
transition={{ type: "spring", stiffness: 600, damping: 16, delay: 0.1 }}
className="absolute -right-1 -bottom-1 grid size-4.5 place-items-center rounded-full bg-emerald-500 text-white ring-2 ring-card"
>
<Check className="size-2.5" strokeWidth={3.5} aria-hidden />
</motion.span>
)}
</AnimatePresence>
</span>
<span className={cn("min-w-0 transition-opacity duration-500", dim && "opacity-60")}>
<span className="block truncate text-xs font-semibold">{name}</span>
<span className="block truncate text-[10px] text-muted-foreground">{role}</span>
</span>
</div>
);
}
export default function AgentHandoffDemo() {
return (
<div className="flex h-[360px] w-full items-start justify-center pt-2">
<AgentHandoff autoPlay />
</div>
);
}