Multi-Agent Handoff
A pip crosses the connector, the receiving card lifts as it takes the work, and the finished one recedes with a drawn check.
The animated component in this preview is rendered from the canonical file shown here. The surrounding demo shell only provides context and is not part of the copied code.
import { useEffect, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Multi-Agent Handoff
*
* Work travelling down a chain of named agents: the connector fills, a
* pip crosses it, the receiving card lifts as it takes over, and the one
* that just finished recedes with a drawn check.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Cards are mixed from the inherited text color, so the row reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `agents`, `stepMs`.
* Requires the automatic JSX runtime (default since React 17).
*/
type GlyphKind = "search" | "chart" | "pencil";
export type AgentStep = {
id: string;
/** Shown on the card. */
name: string;
/** One-line job description under the name. */
role: string;
/** Status line while this agent holds the work. */
working: string;
glyph: GlyphKind;
};
export type MultiAgentHandoffProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** The chain, in the order work passes through it. */
agents?: AgentStep[];
/** How long each agent holds the work, in ms. */
stepMs?: number;
/** Status line once the last agent finishes. */
doneNote?: string;
/** Accent for the active card and the connectors. */
accent?: string;
/** Fires when the last agent hands off. */
onComplete?: () => void;
};
type VariantConfig = {
/** px the receiving card lifts. */
liftY: number;
spring: { type: "spring"; stiffness: number; damping: number };
/** How long the connector takes to fill. */
crossSeconds: number;
/** Opacity of an agent that has finished its part. */
restOpacity: number;
};
// The lift is the only thing that says "this one has the work now", so it
// waits for the pip to arrive rather than racing it. Damping ratios
// (ζ = damping / 2√stiffness) stay at or above 0.85: a card that bounces
// on arrival reads as a notification, not as a transfer of custody.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// ζ ≈ 1.02 — the card rises and stops. For a chain shown inside a
// message, where the row is a detail and not the subject.
subtle: {
liftY: 3,
spring: { type: "spring", stiffness: 520, damping: 46 },
crossSeconds: 0.28,
restOpacity: 0.62,
},
// ζ ≈ 0.93 — one clean settle as custody changes. All-purpose.
default: {
liftY: 6,
spring: { type: "spring", stiffness: 420, damping: 38 },
crossSeconds: 0.38,
restOpacity: 0.55,
},
// ζ ≈ 0.86 — a taller lift and a slower crossing, for a run panel.
playful: {
liftY: 10,
spring: { type: "spring", stiffness: 300, damping: 30 },
crossSeconds: 0.5,
restOpacity: 0.48,
},
};
const SAMPLE_AGENTS: AgentStep[] = [
{
id: "retriever",
name: "Retriever",
role: "Finds sources",
working: "Pulling the order history",
glyph: "search",
},
{
id: "analyst",
name: "Analyst",
role: "Checks numbers",
working: "Reconciling the refund totals",
glyph: "chart",
},
{
id: "writer",
name: "Writer",
role: "Drafts the reply",
working: "Writing the customer response",
glyph: "pencil",
},
];
const CONNECTOR = 18;
/** Theme-adaptive neutral: mixing the inherited text color with
* `transparent` gives cards, borders and connector tracks that are
* correctly toned in either theme. The accent stays literal. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
function AgentGlyph({ kind }: { kind: GlyphKind }) {
if (kind === "search") {
return (
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden>
<circle cx="7" cy="7" r="4.3" stroke="currentColor" strokeWidth="1.5" />
<path
d="m10.4 10.4 3 3"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
);
}
if (kind === "chart") {
return (
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden>
<path
d="M3.4 12.4V8.6M8 12.4V3.6M12.6 12.4V6.4"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
</svg>
);
}
return (
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" aria-hidden>
<path
d="M10.6 2.6 13.4 5.4 5.8 13H3v-2.8z"
stroke="currentColor"
strokeWidth="1.5"
strokeLinejoin="round"
/>
</svg>
);
}
export default function MultiAgentHandoff({
variant = "default",
agents = SAMPLE_AGENTS,
stepMs = 1500,
doneNote = "Draft ready for review",
accent = "#7C7CF0",
onComplete,
}: MultiAgentHandoffProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
// `stage` is the index of the agent holding the work; agents.length
// means the chain has finished.
const [stage, setStage] = useState(0);
const finished = stage >= agents.length;
const onCompleteRef = useRef(onComplete);
useEffect(() => {
onCompleteRef.current = onComplete;
}, [onComplete]);
useEffect(() => {
if (finished) return;
const id = setTimeout(() => setStage((current) => current + 1), stepMs);
return () => clearTimeout(id);
}, [stage, finished, stepMs]);
useEffect(() => {
if (finished) onCompleteRef.current?.();
}, [finished]);
const statusLines = [...agents.map((agent) => agent.working), doneNote];
const fade = { duration: reduceMotion ? 0.12 : 0.24, ease: "easeOut" as const };
return (
<div
style={{
display: "flex",
flexDirection: "column",
gap: 12,
fontSize: 12,
}}
>
<div style={{ display: "flex", alignItems: "stretch" }}>
{agents.map((agent, index) => {
const done = stage > index;
const active = stage === index;
return (
<span key={agent.id} style={{ display: "flex", alignItems: "center" }}>
{index > 0 && (
<span
aria-hidden
style={{
position: "relative",
width: CONNECTOR,
height: 2,
borderRadius: 999,
background: tone(12),
flexShrink: 0,
}}
>
<motion.span
initial={false}
animate={{ scaleX: stage >= index ? 1 : 0 }}
transition={{
duration: reduceMotion ? 0 : cfg.crossSeconds,
ease: [0.32, 0.72, 0, 1],
}}
style={{
position: "absolute",
inset: 0,
borderRadius: 999,
background: accent,
transformOrigin: "left center",
}}
/>
{/* The pip is what makes this a handoff rather than two
independent state changes: something visibly leaves
one card and arrives at the next. */}
{!reduceMotion && (
<motion.span
initial={false}
animate={
stage >= index
? { x: CONNECTOR - 5, opacity: [0, 1, 1, 0] }
: { x: 0, opacity: 0 }
}
transition={{
duration: cfg.crossSeconds,
ease: "easeInOut",
}}
style={{
position: "absolute",
top: -1.5,
left: 0,
width: 5,
height: 5,
borderRadius: "50%",
background: accent,
boxShadow: `0 0 6px ${accent}99`,
}}
/>
)}
</span>
)}
<motion.span
initial={false}
animate={{
y: reduceMotion || !active ? 0 : -cfg.liftY,
opacity: active ? 1 : done ? cfg.restOpacity : 0.4,
boxShadow: active
? `0 8px 18px ${accent}2E`
: `0 0px 0px ${accent}00`,
}}
transition={{
...cfg.spring,
// The card waits for the pip: custody lands, then the
// card rises to acknowledge it.
delay: active && index > 0 && !reduceMotion ? cfg.crossSeconds * 0.6 : 0,
opacity: fade,
boxShadow: fade,
}}
style={{
position: "relative",
display: "flex",
flexDirection: "column",
gap: 5,
width: 86,
height: 88,
padding: "10px 9px",
borderRadius: 12,
background: tone(5),
border: `1px solid ${tone(11)}`,
overflow: "hidden",
}}
>
{/* The accent state is a layer whose opacity animates,
not a colour tween: the resting surface is mixed from
the inherited text colour, and no animation engine can
interpolate that into a literal accent. */}
<motion.span
aria-hidden
initial={false}
animate={{ opacity: active ? 1 : 0 }}
transition={fade}
style={{
position: "absolute",
inset: 0,
borderRadius: 12,
background: `${accent}14`,
boxShadow: `inset 0 0 0 1px ${accent}80`,
}}
/>
<span
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
color: active ? accent : "inherit",
}}
>
<AgentGlyph kind={agent.glyph} />
<motion.span
initial={false}
animate={{ opacity: done ? 1 : 0 }}
transition={fade}
style={{ display: "grid", placeItems: "center", color: "#34D399" }}
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
{/* Drawn, not faded: the stroke arriving in one pass
reads as "this one just finished". */}
<motion.path
d="M2.6 6.3 4.9 8.6 9.4 3.8"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
initial={false}
animate={{ pathLength: done ? 1 : 0 }}
transition={{
duration: reduceMotion ? 0 : 0.24,
ease: "easeOut",
}}
/>
</svg>
</motion.span>
</span>
<span style={{ display: "grid", gap: 2 }}>
<span style={{ fontSize: 11.5, fontWeight: 650, lineHeight: 1.2 }}>
{agent.name}
</span>
<span style={{ fontSize: 10, opacity: 0.6, lineHeight: 1.25 }}>
{agent.role}
</span>
</span>
{/* A linear underline for the beat this agent holds the
work — progress the reader can time, not a spinner. It
stays full and fades out when the work moves on, rather
than snapping back to empty. */}
<motion.span
aria-hidden
initial={{ scaleX: 0, opacity: 0 }}
animate={{
scaleX: active && !reduceMotion ? 1 : done ? 1 : 0,
opacity: active && !reduceMotion ? 1 : 0,
}}
transition={{
scaleX: {
duration: active && !reduceMotion ? stepMs / 1000 : 0,
ease: "linear",
},
opacity: { duration: 0.3, ease: "easeOut" },
}}
style={{
position: "absolute",
left: 9,
right: 9,
bottom: 8,
height: 2,
borderRadius: 999,
background: accent,
transformOrigin: "left center",
}}
/>
</motion.span>
</span>
);
})}
</div>
{/* One line, four states, one grid cell: the status text changes
without ever moving. */}
<span
role="status"
aria-live="polite"
style={{ display: "grid", justifyItems: "start" }}
>
{statusLines.map((line, index) => (
<motion.span
key={index}
aria-hidden={stage !== index}
initial={false}
animate={{ opacity: stage === index ? 0.7 : 0 }}
transition={fade}
style={{
gridArea: "1 / 1",
display: "inline-flex",
alignItems: "center",
gap: 6,
fontSize: 11.5,
}}
>
{index === statusLines.length - 1 ? (
<svg width="11" height="11" viewBox="0 0 12 12" fill="none" aria-hidden>
<circle cx="6" cy="6" r="5" fill="#34D399" opacity="0.22" />
<path
d="M3.6 6.2 5.3 7.9 8.4 4.3"
stroke="#34D399"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
) : (
<span
aria-hidden
style={{
width: 5,
height: 5,
borderRadius: "50%",
background: accent,
}}
/>
)}
{line}
</motion.span>
))}
</span>
</div>
);
}About this pattern
An orchestrated run is easy to mistake for one long wait, because nothing on screen says which part of the system is currently responsible. This makes custody visible: the connector fills, a pip crosses it, and the receiving card lifts only once the pip arrives — the lift acknowledges the transfer instead of racing it. The agent that just finished recedes rather than disappearing, keeping the whole chain readable, and a single status line changes wording underneath without ever moving. Nothing bounces: a card that springs on arrival reads as a notification, not as work changing hands.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- AI assistant
A chain run rendered as named stages, with the currently executing one distinguished from the finished ones.
Related patterns
- Progressive Image GenerationA generated picture resolves from blur to sharp across a few discrete refinement passes.
- AI Result RevealThe result card rises into place while its confidence value counts up to the final number.
- Embedding Cluster SettleScattered points drift into labelled groups, the halos and captions landing after them.