Agent Step Timeline
Each finished stage of an agent run ticks over and grows its connector toward the one after it.
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, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
/**
* Vibary · Agent Step Timeline
*
* A vertical run log for an agent working through a plan: the current
* marker turns, the finished one ticks over, and the connector grows
* toward whatever comes after it.
*
* Self-contained: depends only on `motion` (react ships with your app).
* Surfaces are mixed from the inherited text color, so it reads
* correctly on a light page and on a dark one.
* Works with zero props; tune via `variant`, `steps`, `stepMs`, `title`.
* Requires the automatic JSX runtime (default since React 17).
*/
export type AgentStep = {
/** What the agent is doing. */
action: string;
/** What it found, revealed once the stage completes. */
outcome: string;
};
export type AgentStepTimelineProps = {
/** Visual character of the motion. */
variant?: "subtle" | "default" | "playful";
/** Heading above the run. */
title?: string;
/** The plan, in order. */
steps?: AgentStep[];
/** ms each stage runs before it completes. */
stepMs?: number;
/** Accent for markers and connectors. */
color?: string;
};
type VariantConfig = {
/** Seconds the connector takes to grow to the following marker. */
connectorSeconds: number;
/** Seconds the tick takes to be drawn. */
tickSeconds: number;
/** Seconds per turn of the running marker. */
spinSeconds: number;
/** px the outcome line travels as it arrives. */
outcomeRise: number;
spring: { type: "spring"; stiffness: number; damping: number };
};
// Damping ratios (ζ = damping / 2√stiffness) stay at or above 0.8. Four
// markers landing in sequence multiply any overshoot by four, and this
// surface is read as a status report, not watched as an animation.
const VARIANTS: Record<"subtle" | "default" | "playful", VariantConfig> = {
// ζ ≈ 1.07 — no overshoot. For a dense log of many stages.
subtle: {
connectorSeconds: 0.24,
tickSeconds: 0.2,
spinSeconds: 1.1,
outcomeRise: 3,
spring: { type: "spring", stiffness: 460, damping: 46 },
},
// ζ ≈ 0.93 — lands clean. The all-purpose setting.
default: {
connectorSeconds: 0.32,
tickSeconds: 0.26,
spinSeconds: 0.9,
outcomeRise: 5,
spring: { type: "spring", stiffness: 420, damping: 38 },
},
// ζ ≈ 0.82 — one soft settle and a livelier marker, for a run the
// product wants people to watch.
playful: {
connectorSeconds: 0.4,
tickSeconds: 0.32,
spinSeconds: 0.75,
outcomeRise: 8,
spring: { type: "spring", stiffness: 380, damping: 32 },
},
};
const SAMPLE_STEPS: AgentStep[] = [
{ action: "Read the ticket", outcome: "3 attachments parsed" },
{ action: "Look up the order", outcome: "Order 48210 matched" },
{ action: "Check the refund policy", outcome: "Inside the 30-day window" },
{ action: "Draft the reply", outcome: "Queued for review" },
];
/** Theme-adaptive neutral: `currentColor` is the text color this
* component inherits — near-black on a light page, near-white on a dark
* one — so mixing it with `transparent` yields a surface, border or fill
* that is correctly toned in either theme. The accent stays literal:
* it marks state, it is not a surface. */
const tone = (percent: number) =>
`color-mix(in srgb, currentColor ${percent}%, transparent)`;
export default function AgentStepTimeline({
variant = "default",
title = "Resolving ticket 48210",
steps = SAMPLE_STEPS,
stepMs = 900,
color = "#7C7CF0",
}: AgentStepTimelineProps) {
const reduceMotion = useReducedMotion();
const cfg = VARIANTS[variant];
const [completed, setCompleted] = useState(0);
// One timer per stage, scheduled from the stage currently running: the
// run advances by rescheduling itself, so it cannot drift out of step
// and unmounting mid-run leaves nothing pending.
useEffect(() => {
if (completed >= steps.length) return;
const timer = setTimeout(() => setCompleted((value) => value + 1), stepMs);
return () => clearTimeout(timer);
}, [completed, steps.length, stepMs]);
const finished = completed >= steps.length;
return (
<div
style={{
width: 300,
padding: "14px 16px 16px",
borderRadius: 16,
background: tone(5),
border: `1px solid ${tone(11)}`,
}}
>
<div
style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: 10,
paddingBottom: 12,
marginBottom: 12,
borderBottom: `1px solid ${tone(10)}`,
}}
>
<span style={{ fontSize: 12.5, fontWeight: 650, opacity: 0.78 }}>
{title}
</span>
<span
aria-live="polite"
style={{
fontSize: 11,
opacity: 0.45,
fontVariantNumeric: "tabular-nums",
whiteSpace: "nowrap",
}}
>
{Math.min(completed, steps.length)} of {steps.length}
</span>
</div>
<ol style={{ margin: 0, padding: 0, listStyle: "none" }}>
{steps.map((step, index) => {
const isComplete = index < completed;
const isRunning = index === completed && !finished;
const isLast = index === steps.length - 1;
return (
<li
key={step.action}
style={{
display: "flex",
gap: 11,
paddingBottom: isLast ? 0 : 15,
}}
>
<div
style={{
position: "relative",
width: 19,
flex: "0 0 auto",
}}
>
{!isLast && (
<span
aria-hidden
style={{
position: "absolute",
left: 8.75,
top: 21,
bottom: -3,
width: 1.5,
borderRadius: 999,
background: tone(12),
overflow: "hidden",
}}
>
{/* The connector grows toward the following marker
with scaleY, so the run reads as one continuous
thread rather than four separate rows. */}
<motion.span
initial={false}
animate={
reduceMotion
? { opacity: isComplete ? 1 : 0, scaleY: 1 }
: { scaleY: isComplete ? 1 : 0, opacity: 1 }
}
transition={{
duration: reduceMotion ? 0 : cfg.connectorSeconds,
ease: [0.22, 1, 0.36, 1],
}}
style={{
display: "block",
width: "100%",
height: "100%",
background: color,
transformOrigin: "top center",
}}
/>
</span>
)}
<span
aria-hidden
style={{
position: "relative",
display: "grid",
placeItems: "center",
width: 19,
height: 19,
borderRadius: "50%",
borderWidth: 1.5,
borderStyle: "solid",
// The marker's fill is a CSS transition, not an
// animated value: these neutrals are color-mix()
// surfaces, which the browser interpolates and a JS
// color parser does not.
background: isComplete ? color : "transparent",
borderColor: isComplete
? color
: isRunning
? tone(24)
: tone(18),
transition:
"background-color 220ms ease-out, border-color 220ms ease-out",
}}
>
{isComplete && (
<svg width="11" height="11" viewBox="0 0 12 12" fill="none">
<motion.path
d="M2.9 6.2 4.9 8.2 9.1 3.9"
stroke="#fff"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
initial={reduceMotion ? false : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{
duration: reduceMotion ? 0 : cfg.tickSeconds,
ease: [0.22, 1, 0.36, 1],
}}
/>
</svg>
)}
{isRunning && (
// Reduced motion: the arc is drawn but held still —
// the running stage is identified by its shape and by
// the count in the header, not by rotation.
<motion.svg
width="19"
height="19"
viewBox="0 0 20 20"
fill="none"
animate={reduceMotion ? undefined : { rotate: 360 }}
transition={
reduceMotion
? undefined
: {
duration: cfg.spinSeconds,
repeat: Infinity,
ease: "linear",
}
}
style={{ position: "absolute", inset: -1.5 }}
>
<path
d="M10 1.4a8.6 8.6 0 0 1 8.6 8.6"
stroke={color}
strokeWidth="1.8"
strokeLinecap="round"
/>
</motion.svg>
)}
</span>
</div>
<div style={{ display: "grid", gap: 2, paddingTop: 1 }}>
{/* Titles never move or resize — only their weight in the
page changes, carried entirely by opacity. */}
<motion.span
animate={{
opacity: isRunning ? 0.95 : isComplete ? 0.8 : 0.38,
}}
transition={{ duration: 0.25, ease: "easeOut" }}
style={{ fontSize: 12.5, fontWeight: 600, lineHeight: 1.35 }}
>
{step.action}
</motion.span>
{/* The outcome line is always in the layout and only
fades in, so nothing below it ever shifts. */}
<motion.span
initial={false}
animate={{
opacity: isComplete ? 0.45 : 0,
y: isComplete || reduceMotion ? 0 : cfg.outcomeRise,
}}
transition={{
y: cfg.spring,
opacity: { duration: 0.24, ease: "easeOut" },
}}
style={{ fontSize: 11.5, lineHeight: 1.35 }}
>
{step.outcome}
</motion.span>
</div>
</li>
);
})}
</ol>
</div>
);
}About this pattern
An agent that works for thirty seconds without saying anything is indistinguishable from one that has crashed. A vertical run log fixes that by making completion the thing that moves: the current marker turns, the finished marker fills and has its tick drawn, and the connector grows down toward the following stage so the whole run reads as one thread rather than four rows. Every outcome line is already in the layout and only fades in, so nothing below a completing stage ever shifts — the list can be read while it is still running.
Where it shows up
Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.
- AI assistant
A running plan whose stages complete one at a time while the work continues.
Related patterns
- Tool Call BadgeA badge announces the tool an agent is calling, turns while it runs, then settles into the result count.
- AI Thinking PulseA calm breathing dot with a soft expanding ring — the app is thinking, not stuck.
- Retry Backoff CountdownA ring empties over the wait while the seconds count down to the next automatic attempt.