All patterns

Agent Step Timeline

Each finished stage of an agent run ticks over and grows its connector toward the one after it.

aiminimalcalmautomatic · finite · advanced · ~3.6s
Variant

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.

326 lines · react + motion only
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.

Agent run logMulti-stage plan progressWorkflow execution statusBackground job stages

Where it shows up

Screens we drew to show where this motion usually sits. Illustrations, not captures of any product.

  • Summarise the supplier contract and flag anything unusual.
    The renewal runs another twelve months at the same rate, with one clause worth a second look.
    Supplier contract.docxQ3 planning notes
    Ask a follow-up
    AI assistant

    A running plan whose stages complete one at a time while the work continues.

Related patterns