Anatomy of Agent Flow
How a node graph becomes one continuous light: length-driven speed, a border-to-beam handoff, and a sequencer that walks the graph on its own.
↓Scroll to step through it
The graph
AgentFlow looks like a diagram tool, but this article is about the light.
Every node card and every edge derives its motion from the same handful of
numbers, so a graph of any shape reads as one unbroken current instead of a
pile of independent animations.
A node is a card at an absolute (x, y) centre. An edge is an SVG quadratic
curve from its source's right-edge centre to its target's left-edge
centre. The anchors are computed from each card's live measured size, not
a fixed offset, so relabelling a node (a longer sublabel, a taller card) never
misaligns the line it's plugged into.
Card sizes come from a ResizeObserver over every rendered node, not from
props, so fitView and every edge anchor stay correct even after the DOM
reflows text.
One pace for every length
A bigger card's border has more perimeter to draw. A longer edge has more
distance to cover. If each animated separately by duration, the light would
visibly slow down on big pieces and speed up on small ones. AgentFlow fixes
the pace instead of the duration: everything shares one flowSpeed
(px/second, default 280), and each element's duration falls out of its own
length.
Edges do the same with their own geometry. The chord length is averaged with the two control-arm lengths, which estimates a curved edge better than a straight line does:
const chord = Math.hypot(endX - startX, endY - startY);
const curveLen =
Math.hypot(controlX - startX, controlY - startY) +
Math.hypot(endX - controlX, endY - controlY);
const len = (chord + curveLen) / 2;
const edgeDuration = matchSpeed ? Math.max(0.2, len / flowSpeed) : flowDuration;The border trace also uses linear easing, not the EASE_OUT the rest of
the component reaches for. An ease-out front decelerates right as it finishes,
which would visibly stutter at the moment the beam should pick up speed and
continue. Constant velocity keeps the seam invisible.
Border → beam, one continuous light
Each card's outline is two paths. borderTracePaths(w, h) returns
symmetric top and bottom outlines that both start at the left-edge centre
and meet at the right-edge centre, so the trace grows outward in both
directions from the same point an incoming beam would have landed on, and
finishes at the exact point the outgoing beam starts from.
The icon chip doesn't wait for the border to finish. It lights at the halfway point of the trace, from a plain timer rather than an animation-complete event:
const t = setTimeout(() => setIconLit(true), (traceDuration * 1000) / 2);And the packet's arrival fires slightly early, at 0.98 of its flight instead
of 1.0, so the next border starts the instant the beam visually lands instead
of a frame after:
const t = setTimeout(() => onArrive?.(id), flowDuration * 1000 * 0.98);Both timers exist for the same reason: a CSS/Motion "animation complete" event is one tick late and occasionally dropped entirely on a fast edge. Timers derived from the same duration the animation uses fire exactly when the visual handoff needs them, including on the graph's last hop, which has no next element waiting to double-check it.
The sequencer. autoPlay walks the graph with three sets (tracing,
lit, flowing) managed by a small reducer, not a timeline library. A
node's status is derived from which set contains its id; an edge only shows
its beam while its id is in flowing.
type SeqState = { tracing: Set<string>; lit: Set<string>; flowing: Set<string> };
function seqReducer(state: SeqState, action: SeqAction): SeqState {
switch (action.type) {
case "reset":
return { tracing: new Set(action.roots), lit: new Set(), flowing: new Set() };
case "traceDone": {
// move id: tracing → lit, and queue its outgoing edges into flowing
}
case "arrive": {
// remove edgeId from flowing; if the target isn't lit/tracing yet, add it to tracing
}
}
}Root nodes (no incoming edge) seed tracing on reset. From there the
graph runs itself: a card's onTraceComplete dispatches traceDone, which
starts every outgoing edge; an edge's onArrive dispatches arrive, which
starts tracing the node it landed on. Nothing schedules the whole sequence
up front. Each hop only knows how to start the next one, which is why the
same reducer handles a straight line, a fan-out, or a diamond that
re-converges without any graph-shape-specific code.
const flowEdges: AgentFlowEdge[] = autoPlay
? edges.map((e) => ({ ...e, animated: seq.flowing.has(e.id), loop: false }))
: edges;
const statusOf = (n: AgentFlowNode): AgentNodeStatus => {
if (!autoPlay) return n.status ?? "idle";
if (seq.lit.has(n.id)) return "done";
if (seq.tracing.has(n.id)) return "running";
return "idle";
};persist edges layer on top of this for free: a persisted edge draws its own
primary-coloured trail in sync with the packet and keeps it lit once
animated drops back to false, so a graph that's finished a full pass shows
its whole travelled route glowing rather than resetting to plain lines.
The result
Same reducer, same length-driven pace, same 0.98-early arrival, now running on
a real graph instead of a two-node illustration. Watch the border meet the beam
at every hop; flowSpeed exists to keep that seam clean.
- Node card
- absolute (x, y) centre, size measured live
- Edge path
- quadratic curve, right-centre → left-centre
- Packet
- gradient beam that rides the curve
const startX = from.x + fs.w / 2;const startY = from.y;const endX = to.x - ts.w / 2;const endY = to.y;const controlX = (startX + endX) / 2;const controlY = (startY + endY) / 2 - (edge.curvature ?? 0);const d = `M ${startX},${startY} Q ${controlX},${controlY} ${endX},${endY}`;Anatomy of Agent Flow
How a node graph becomes one continuous light: length-driven speed, a border-to-beam handoff, and a sequencer that walks the graph on its own.
- Node card
- absolute (x, y) centre, size measured live
- Edge path
- quadratic curve, right-centre → left-centre
- Packet
- gradient beam that rides the curve
const startX = from.x + fs.w / 2;const startY = from.y;const endX = to.x - ts.w / 2;const endY = to.y;const controlX = (startX + endX) / 2;const controlY = (startY + endY) / 2 - (edge.curvature ?? 0);const d = `M ${startX},${startY} Q ${controlX},${controlY} ${endX},${endY}`;The graph
AgentFlow looks like a diagram tool, but this article is about the light.
Every node card and every edge derives its motion from the same handful of
numbers, so a graph of any shape reads as one unbroken current instead of a
pile of independent animations.
A node is a card at an absolute (x, y) centre. An edge is an SVG quadratic
curve from its source's right-edge centre to its target's left-edge
centre. The anchors are computed from each card's live measured size, not
a fixed offset, so relabelling a node (a longer sublabel, a taller card) never
misaligns the line it's plugged into.
Card sizes come from a ResizeObserver over every rendered node, not from
props, so fitView and every edge anchor stay correct even after the DOM
reflows text.
duration = borderOutlineLength(w, h) / flowSpeed
function borderOutlineLength(w: number, h: number): number {const r = Math.min(CARD_RADIUS, w / 2, h / 2);return w + h - (4 - Math.PI) * r;}const traceDuration = borderOutlineLength(size.w, size.h) / flowSpeed;One pace for every length
A bigger card's border has more perimeter to draw. A longer edge has more
distance to cover. If each animated separately by duration, the light would
visibly slow down on big pieces and speed up on small ones. AgentFlow fixes
the pace instead of the duration: everything shares one flowSpeed
(px/second, default 280), and each element's duration falls out of its own
length.
Edges do the same with their own geometry. The chord length is averaged with the two control-arm lengths, which estimates a curved edge better than a straight line does:
const chord = Math.hypot(endX - startX, endY - startY);
const curveLen =
Math.hypot(controlX - startX, controlY - startY) +
Math.hypot(endX - controlX, endY - controlY);
const len = (chord + curveLen) / 2;
const edgeDuration = matchSpeed ? Math.max(0.2, len / flowSpeed) : flowDuration;The border trace also uses linear easing, not the EASE_OUT the rest of
the component reaches for. An ease-out front decelerates right as it finishes,
which would visibly stutter at the moment the beam should pick up speed and
continue. Constant velocity keeps the seam invisible.
- Trace / light
- border draws on, icon pops at the midpoint
- Beam
- packet sweeps the edge, fires the next trace on arrival
function borderTracePaths(w: number, h: number): [string, string] {const r = Math.min(CARD_RADIUS, w / 2, h / 2);const top = `M 0,${h / 2} L 0,${r} A ${r},${r} 0 0 1 ${r},0 L ${w - r},0 A ${r},${r} 0 0 1 ${w},${r} L ${w},${h / 2}`;const bottom = `M 0,${h / 2} L 0,${h - r} A ${r},${r} 0 0 0 ${r},${h} L ${w - r},${h} A ${r},${r} 0 0 0 ${w},${h - r} L ${w},${h / 2}`;return [top, bottom];}Border → beam, one continuous light
Each card's outline is two paths. borderTracePaths(w, h) returns
symmetric top and bottom outlines that both start at the left-edge centre
and meet at the right-edge centre, so the trace grows outward in both
directions from the same point an incoming beam would have landed on, and
finishes at the exact point the outgoing beam starts from.
The icon chip doesn't wait for the border to finish. It lights at the halfway point of the trace, from a plain timer rather than an animation-complete event:
const t = setTimeout(() => setIconLit(true), (traceDuration * 1000) / 2);And the packet's arrival fires slightly early, at 0.98 of its flight instead
of 1.0, so the next border starts the instant the beam visually lands instead
of a frame after:
const t = setTimeout(() => onArrive?.(id), flowDuration * 1000 * 0.98);Both timers exist for the same reason: a CSS/Motion "animation complete" event is one tick late and occasionally dropped entirely on a fast edge. Timers derived from the same duration the animation uses fire exactly when the visual handoff needs them, including on the graph's last hop, which has no next element waiting to double-check it.
The sequencer. autoPlay walks the graph with three sets (tracing,
lit, flowing) managed by a small reducer, not a timeline library. A
node's status is derived from which set contains its id; an edge only shows
its beam while its id is in flowing.
type SeqState = { tracing: Set<string>; lit: Set<string>; flowing: Set<string> };
function seqReducer(state: SeqState, action: SeqAction): SeqState {
switch (action.type) {
case "reset":
return { tracing: new Set(action.roots), lit: new Set(), flowing: new Set() };
case "traceDone": {
// move id: tracing → lit, and queue its outgoing edges into flowing
}
case "arrive": {
// remove edgeId from flowing; if the target isn't lit/tracing yet, add it to tracing
}
}
}Root nodes (no incoming edge) seed tracing on reset. From there the
graph runs itself: a card's onTraceComplete dispatches traceDone, which
starts every outgoing edge; an edge's onArrive dispatches arrive, which
starts tracing the node it landed on. Nothing schedules the whole sequence
up front. Each hop only knows how to start the next one, which is why the
same reducer handles a straight line, a fan-out, or a diamond that
re-converges without any graph-shape-specific code.
const flowEdges: AgentFlowEdge[] = autoPlay
? edges.map((e) => ({ ...e, animated: seq.flowing.has(e.id), loop: false }))
: edges;
const statusOf = (n: AgentFlowNode): AgentNodeStatus => {
if (!autoPlay) return n.status ?? "idle";
if (seq.lit.has(n.id)) return "done";
if (seq.tracing.has(n.id)) return "running";
return "idle";
};persist edges layer on top of this for free: a persisted edge draws its own
primary-coloured trail in sync with the packet and keeps it lit once
animated drops back to false, so a graph that's finished a full pass shows
its whole travelled route glowing rather than resetting to plain lines.
The result
Same reducer, same length-driven pace, same 0.98-early arrival, now running on
a real graph instead of a two-node illustration. Watch the border meet the beam
at every hop; flowSpeed exists to keep that seam clean.
Reduced motion
useReducedMotion() is read once at the top of the component and threaded
down to every trace, sweep, and fade. Under it, every transition collapses
to { duration: 0 } and every timer that would have waited for a trace or
flight to finish fires immediately instead:
const traceT = reduce ? { duration: 0 } : { duration: traceDuration, ease: EASE_LINEAR };
React.useEffect(() => {
if (status !== "running") return;
if (reduce) {
onTraceComplete(node.id);
return;
}
const t = setTimeout(() => onTraceComplete(node.id), traceDuration * 1000);
return () => clearTimeout(t);
}, [status, reduce, traceDuration, node.id, onTraceComplete]);A reduced-motion run still produces the same final graph, with every node lit and every persisted edge drawn. It just gets there without the trip.
Motion Score
box-shadownode status glow shares its transition with border/background paint, so isolating it wins nothingtranslatePosition / lift via translatescaleScale spring / pressopacityFade / cross-fadefilterFilter (blur / brightness)pathLengthSVG pathLength reveal