Anatomy of the Agent Timeline
How a live agent run is a spring-filled connector, three cross-faded ring states, and a collapsible body that morphs open on its own clock.
An agent run isn't a progress bar — it's a rail of independent decisions, each
one reporting its own status as it lands. AgentTimeline renders that rail as
an <ol> of AgentSteps, and every piece of motion in it is keyed off one
prop: status.
The three pieces
Each AgentStep is a ring, a connector, and a body. The ring shows the
step's current status. The connector below it fills as that status
resolves, drawing the eye down to whatever's next. The body is optional —
only steps with children get a chevron and something to expand.
- Ring
- border-color + fill keyed by status
- Connector
- scaleY 0 → 0.5 → 1, spring 320/32/0.9
- Body
- height + opacity, collapsible per step
<li data-status={status} className="relative flex gap-3 pb-2">
<div className="relative flex flex-col items-center">
<span className={STATUS_RING[status]}>{/* icon */}</span>
{!last ? (
<span className="relative my-1 w-px flex-1 overflow-hidden rounded bg-border">
<motion.span className={`absolute inset-0 origin-top ${CONNECTOR_FILL[status]}`} />
</span>
) : null}
</div>
<div className="min-w-0 flex-1 pb-2">{/* title, meta, chevron, body */}</div>
</li>last is the only structural prop — it just hides the connector on the final
step, so the rail doesn't dangle a line into nothing.
The connector: one number, three destinations
The connector is a motion.span inside an overflow-hidden track, scaled from
an origin-top. scaleY maps directly off status: 0 while pending, 0.5
while running — a half-fill that reads as "in flight" — and 1 once the step
lands on success or error. Every transition rides the same spring:
stiffness: 320, damping: 32, mass: 0.9.
- Pending
- hollow ring, dot
- Running
- spinner + ping, half fill
- Success
- solid ring, check, full fill
animate={{
scaleY:
status === "success" || status === "error"
? 1
: status === "running"
? 0.5
: 0,
}}
transition={{ type: "spring", stiffness: 320, damping: 32, mass: 0.9 }}Why scaleY, not a growing height
A connector that grows by animating height has to re-run layout on every
tick — the browser recalculates where every element below it sits, every
frame. scaleY on a fixed-height track is a compositor transform: the browser
scales an already-painted layer with no layout or paint work at all. The
track's own height never changes; only the fill inside it stretches from a
pinned top edge.
<span className="relative my-1 w-px flex-1 overflow-hidden rounded bg-border">
<motion.span className="absolute inset-0 origin-top ..." /> {/* scaleY, not height */}
</span>The ring's icon swap — dot → spinner → check — is a plain conditional render,
not a cross-fade transition; only the connector and the collapsible body get
the spring treatment. The running ring also grows a motion-reduce-gated
animate-ping halo, layered absolutely over the same box so it never
disturbs the ring's own layout.
The collapsible body
Steps with children get a chevron and an AnimatePresence-wrapped panel.
Opening one is the same spring as the connector — height: "auto" and
opacity animating together, so the panel never overshoots into visible
overflow the way a plain duration tween would.
- Body
- scaleY + opacity, spring 320/32/0.9
- Chevron
- rotate 0 → 90deg, independent curve
<AnimatePresence initial={false}>
{hasBody && open ? (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ type: "spring", stiffness: 320, damping: 32, mass: 0.9 }}
className="overflow-hidden"
>
{children}
</motion.div>
) : null}
</AnimatePresence>height: "auto" is the one place this component reaches for a layout
animation instead of a transform — Motion measures the panel's natural height
and interpolates a real pixel value under the hood, which is why the wrapper
needs overflow-hidden to hide the in-between states. It's the right
trade-off here: the panel's content is arbitrary (reasoning text, tool
output), so there's no fixed size to fake with a transform, and it only
happens on a deliberate click, not every frame of a running step.
The chevron is unrelated motion living on the same button — a plain
rotate-90 toggle driven by open, with its own transition-transform, so
it never has to wait on the spring to know which way it's pointing.
<ChevronIcon
className={`transition-transform ${open ? "rotate-90" : ""}`}
/>Cheap when idle, honest when running
The title pulses only while status === "running" —
motion-safe:animate-pulse scoped to that one class, so a step that's
already resolved sits perfectly still. Nothing here polls or ticks on an
interval; every step is a pure function of the status its caller passes in,
so the whole timeline is exactly as busy as the run actually is.
className={`... ${status === "running" ? "text-muted-foreground motion-safe:animate-pulse" : ""}`}Accessibility
The step header is a real <button>, disabled when there's no body to open —
so keyboard users can Tab to it and toggle with Enter/Space for free,
and screen readers get aria-expanded reflecting the live state. Every
animate-spin, animate-ping, and motion-safe:animate-pulse in the rail
carries a motion-reduce counterpart, and the connector/body springs both
collapse to { duration: 0 } under useReducedMotion() — a run still reports
its status instantly, just without the flourish.
const reduce = useReducedMotion();
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 320, damping: 32, mass: 0.9 }}<button
type="button"
disabled={!hasBody}
aria-expanded={hasBody ? open : undefined}
onClick={() => hasBody && setOpen((o) => !o)}
>The result
A full run, advancing on its own — watch the connectors spring-fill as each step lands and the running step's body morph open.
- Indexed 128 files across 6 packages.
One prop drives all of it: status in, a filled connector and a settled ring
out. That's the whole timeline — a rail that never has to be told how to
move, only what happened.