nextui.dev
Home
Docs
Saved
Pricing
Components37
  • Forms & Inputs7
  • Cards13
  • Buttons, Badges & Avatars8
  • Feedback & Display6
  • Effects3
Agents46
  • Input & Context9
  • Thinking & Status6
  • Tools & Execution7
  • Human-in-the-Loop & Safety7
  • Multi-Agent & Workflows4
  • Output & Artifacts8
  • Voice & Memory5
Blocks20
  • Agent chatNew
  • Pill heroNew
  • Grid hero
  • Moodboard generatorNew
  • Waitlist heroNew
  • Intro bannerNew
  • Creative home heroNew
  • Chat heroNew
  • App shelfNew
  • Usage card
  • Design resultsNew
  • Pricing tiers
  • Dashboard home
  • The shiftNew
  • Launch perksNew
  • Testimonials
  • Quote
  • Team
  • Leads tableNew
  • Sign in
Pages16
  • ASCII workspace$49
  • Research agent$49
  • Support agent$49
  • Coding agent$49
  • CRM overviewNew
  • Marketing dashboardNew
  • Developer portfolio$49
  • Particle landing$49
  • Particle story$49
  • Statement converter$49
  • AI photo studio$49
  • Group card launch$49
  • Month-end storyNew
  • Task HubNew
  • Global MediaNew
  • Hexora ecosystem$49
Themes9
  • Default
  • Ember
  • Ocean
  • Forest
  • Grape
  • Rose
  • Amber
  • Clay
  • Brutal
UpgradeDark modeLight modeFeedbackGitHub
nextui.dev

nextui components, blocks and themes you install with one command.

Product

  • Components
  • Blocks
  • Themes
  • Pricing

Resources

  • Docs
  • Roadmap
  • GitHub

Legal

  • License
  • Privacy
  • Terms
  • Refunds

nextUI

[ 200 OK ] · nextui.dev

© 2026 nextui.dev • All rights reserved.

UpgradeDark modeLight modeFeedbackGitHub
ComponentsAI & AgentsAgent handoff
Copy promptCopy prompt

Customize

Source agent name

Receiving specialist agent name

Start with the handoff payload expanded

Loop a scripted route → transfer → accept animation; stops on interaction and pauses offscreen

JSX · copied with Copy prompt

<AgentHandoff autoPlay />

Agent handoffNew

Specialist transfer card showing when a router agent hands conversation control to a domain specialist, with an expandable summary of the transferred context.

Installation

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}

Usage

1"use client";
2
3import { AgentHandoff } from "@/components/nextui/agent-handoff";
4
5export function SpecialistTransfer() {
6 return (
7 <AgentHandoff
8 fromAgent="Triage Router"
9 toAgent="Billing Specialist"
10 />
11 );
12}

API reference

Edit props live in the preview above, or see all 11 props
PropTypeDefaultDescription
fromAgentstring"Router Agent"Source agent name
toAgentstring"Database Specialist"Receiving specialist agent name
reasonstring–Short explanation of why the transfer occurred
contextItems{ label: string; value: string }[]–Key-value context payload passed to the receiving agent
defaultOpenbooleantrueStart with the handoff payload expanded
fromRolestring'"Triage"'Role line under the source agent
toRolestring'"Postgres · query tuning"'Role line under the receiving agent
contextSizestring'"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)
autoPlaybooleanfalseLoop a scripted route → transfer → accept animation; stops on interaction and pauses offscreen
classNamestring–Extra classes on the wrapper

Related

All components

Chat inputNew

Task checklistNew

Free

ReasoningNew

components/nextui/agent-handoff.tsx
"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>
  );
}
Sign in