Run timeline
The steps of one agent run as a vertical timeline: a connector that fills as steps finish, a live timer on the active step, and a waiting step that holds an approval.
Install#
npx shadcn@latest add @pixilui/run-timelineOnce per app, add the registry to components.json:
{
"registries": {
"@pixilui": "https://pixilui.com/r/{name}.json"
}
}Or install from the URL, no namespace needed: npx shadcn@latest add https://pixilui.com/r/run-timeline.json
- 1
Install the npm packages.
npm install motion clsx tailwind-merge - 2
Add the items it depends on, the same way.
- 3
Copy each file to the path shown.
"use client"; import { forwardRef, useEffect, useState, useSyncExternalStore, type ComponentPropsWithoutRef, type KeyboardEvent, type ReactNode } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { cn } from "./lib/utils"; import { instantTransition, motionTokens, reducedTransition } from "./motion-tokens"; const noSubscription = () => () => {}; /** * useReducedMotion, held at false until hydration finishes. The server cannot know the * preference, so reading it on the first client render would change the markup and break * hydration; the reduced branch takes over on the next render instead. */ function useReducedMotionAfterHydration() { const hydrated = useSyncExternalStore(noSubscription, () => true, () => false); const reduced = useReducedMotion(); return hydrated && (reduced ?? false); } export type RunTimelineStatus = "pending" | "active" | "done" | "error" | "waiting"; export type RunTimelineStep = { /** Stable key for the step; onStepSelect receives it. */ id: string; /** What the step does, in a few words: "Pull stuck refunds from Stripe". */ title: ReactNode; /** Evidence once it ran ("9 refunds pending for more than 72 hours"), or why it failed. Hidden in compact mode. */ detail?: ReactNode; /** Pending, active (live timer), done (drawn check), error, or waiting on a person. */ status: RunTimelineStatus; /** When the step started (epoch ms). Drives the live timer and the duration. */ startedAt?: number; /** When the step finished (epoch ms). With startedAt, shows how long it took. */ endedAt?: number; /** Content under the step: an approval card on a waiting step, a tool call, a diff. Waiting steps show it in compact mode too. */ children?: ReactNode; }; export type RunTimelineLabels = Record<RunTimelineStatus, string>; const defaultLabels: RunTimelineLabels = { pending: "Up next", active: "In progress", done: "Done", error: "Failed", waiting: "Needs approval", }; export type RunTimelineProps = Omit<ComponentPropsWithoutRef<"ol">, "children"> & { /** The run's steps, in order. Update a step's status and the timeline animates the change. */ steps: RunTimelineStep[]; /** Titles only, tighter rhythm. Waiting steps still show their children. */ compact?: boolean; /** Makes each step a button. Called with the step's id. */ onStepSelect?: (id: string) => void; /** The step shown elsewhere (a detail pane), marked with data-selected. */ selectedId?: string; /** Status words, read by screen readers and shown for waiting and failed steps. */ labels?: Partial<RunTimelineLabels>; }; const TICK_MS = 250; /** "820ms", "1.4s", "1m 04s". */ function formatDuration(ms: number) { if (ms < 1000) return `${Math.round(ms)}ms`; if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`; const s = Math.round(ms / 1000); return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`; } function formatLive(ms: number) { const s = Math.floor(ms / 1000); return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`; } /** A live timer whose digits are keyed by place and roll up. Only this re-renders each tick. */ function LiveTimer({ startedAt, reduced }: { startedAt: number; reduced: boolean }) { const [now, setNow] = useState<number | null>(null); useEffect(() => { const tick = () => setNow(Date.now()); tick(); const id = setInterval(tick, TICK_MS); return () => clearInterval(id); }, [startedAt]); if (now === null) return null; const value = formatLive(Math.max(0, now - startedAt)); const chars = value.split(""); return ( <span role="timer" aria-label={value} className="numeric inline-flex"> {chars.map((char, index) => { const place = chars.length - index; if (reduced || !/\d/.test(char)) { return ( <span key={place} aria-hidden="true"> {char} </span> ); } return ( <span key={place} aria-hidden="true" className="relative inline-flex h-[1lh] overflow-hidden"> <AnimatePresence initial={false} mode="popLayout"> <motion.span key={char} initial={{ y: "100%" }} animate={{ y: 0 }} exit={{ y: "-100%" }} transition={motionTokens.spring.snappy}> {char} </motion.span> </AnimatePresence> </span> ); })} </span> ); } /** Glyph rings sit between the 1px hairline and a 2px border. */ const ring = { borderWidth: 1.5 }; function Mark({ status, reduced }: { status: RunTimelineStatus; reduced: boolean }) { const draw = reduced ? { initial: { pathLength: 1 }, transition: reducedTransition } : { initial: { pathLength: 0 }, transition: { duration: motionTokens.duration.slow, ease: motionTokens.ease.enter, delay: motionTokens.stagger.item * 2 } }; switch (status) { case "pending": return <span className="size-2.5 rounded-pill border-solid border-border-strong" style={ring} />; case "active": return ( <span className="grid size-5 place-items-center rounded-pill border-solid border-accent-solid" style={ring}> <span className="size-2 rounded-pill bg-accent-solid" /> </span> ); case "done": return ( <span className="grid size-5 place-items-center rounded-pill bg-accent-solid text-accent-contrast"> <svg viewBox="0 0 12 12" fill="none" className="size-3"> <motion.path d="M2.75 6.25l2.25 2.25 4.25-4.75" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" initial={draw.initial} animate={{ pathLength: 1 }} transition={draw.transition} /> </svg> </span> ); case "error": return ( <span className="grid size-5 place-items-center rounded-pill bg-danger-subtle text-danger"> <svg viewBox="0 0 12 12" fill="none" className="size-3"> <motion.path d="M3.75 3.75l4.5 4.5M8.25 3.75l-4.5 4.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" initial={draw.initial} animate={{ pathLength: 1 }} transition={draw.transition} /> </svg> </span> ); case "waiting": return ( <span className="grid size-5 place-items-center rounded-pill bg-warning-subtle text-warning"> <svg viewBox="0 0 12 12" fill="none" className="size-3"> <path d="M4.5 3.5v5M7.5 3.5v5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" /> </svg> </span> ); } } /** * Detail and children open their own height, so the steps below are pushed along in the flow * (no overlap while the content fades in). Growing uses morph, closing uses smooth. The clip is * lifted once it has opened, so an approval card's shadow and focus ring show in full. */ function Reveal({ reduced, className, children, ...rest }: { reduced: boolean; className?: string; children: ReactNode; "data-slot"?: string }) { const [clip, setClip] = useState(true); const motionProps = reduced ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0, height: 0, transition: instantTransition }, transition: reducedTransition, } : { initial: { opacity: 0, height: 0, filter: `blur(${motionTokens.blur.subtle}px)` }, animate: { opacity: 1, height: "auto", filter: "blur(0px)", transition: { height: motionTokens.spring.morph, default: { duration: motionTokens.duration.fast, ease: motionTokens.ease.enter, delay: motionTokens.stagger.item * 2 }, }, }, exit: { opacity: 0, height: 0, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { height: motionTokens.spring.smooth, default: { duration: motionTokens.duration.instant, ease: motionTokens.ease.exit } }, }, }; return ( <motion.div {...rest} {...motionProps} className={className} style={{ overflow: clip && !reduced ? "hidden" : "visible" }} onAnimationStart={() => setClip(true)} onAnimationComplete={() => setClip(false)} > {children} </motion.div> ); } export const RunTimeline = forwardRef<HTMLOListElement, RunTimelineProps>(function RunTimeline( { steps, compact = false, onStepSelect, selectedId, labels: labelsProp, className, onKeyDown, ...rest }, ref, ) { const labels = { ...defaultLabels, ...labelsProp }; const reduced = useReducedMotionAfterHydration(); // Up and Down walk the step buttons, Home and End jump to the ends. const handleKeyDown = (event: KeyboardEvent<HTMLOListElement>) => { onKeyDown?.(event); if (event.defaultPrevented || !onStepSelect || !["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return; const buttons = Array.from(event.currentTarget.querySelectorAll<HTMLElement>('[data-slot="run-timeline-trigger"]')); const current = buttons.indexOf(event.target as HTMLElement); if (current === -1) return; event.preventDefault(); const next = event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : Math.min(buttons.length - 1, Math.max(0, current + (event.key === "ArrowDown" ? 1 : -1))); buttons[next]?.focus(); }; const swapIn = reduced ? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0, transition: instantTransition }, transition: instantTransition } : { initial: { opacity: 0, scale: motionTokens.scale.enterFrom, filter: `blur(${motionTokens.blur.subtle}px)` }, animate: { opacity: 1, scale: 1, filter: "blur(0px)" }, exit: { opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: motionTokens.ease.exit } }, transition: { duration: motionTokens.duration.fast, ease: motionTokens.ease.swap }, }; return ( <ol ref={ref} data-compact={compact || undefined} className={cn("flex w-full min-w-0 flex-col font-body text-14 text-foreground", className)} onKeyDown={handleKeyDown} {...rest} > {steps.map((step, index) => { const last = index === steps.length - 1; const duration = step.startedAt !== undefined && step.endedAt !== undefined ? step.endedAt - step.startedAt : null; const showDetail = !compact && step.detail; const showChildren = step.children && (!compact || step.status === "waiting"); const flagged = step.status === "waiting" || step.status === "error"; const titleRow = ( <> <span className={cn("min-w-0 flex-1 text-pretty font-medium transition-colors duration-fast ease-swap", step.status === "pending" ? "text-muted" : "text-foreground")}> {step.title} </span> <span className="flex shrink-0 items-center gap-2 pt-px text-12 text-muted"> {flagged ? <span className={step.status === "error" ? "text-danger" : "text-warning"}>{labels[step.status]}</span> : null} {step.status === "active" && step.startedAt !== undefined ? <LiveTimer startedAt={step.startedAt} reduced={reduced} /> : null} {step.status === "done" && duration !== null ? <span className="numeric">{formatDuration(duration)}</span> : null} </span> </> ); return ( <li key={step.id} data-status={step.status} data-selected={selectedId === step.id || undefined} aria-current={step.status === "active" ? "step" : undefined} className="relative grid min-w-0 grid-cols-[1.25rem_minmax(0,1fr)] gap-x-3" > {/* Rail: the glyph, then the connector to the next step, which fills when this one is done. */} <span aria-hidden="true" className="relative flex justify-center"> <span className="relative mt-0.5 grid size-5 place-items-center"> <AnimatePresence initial={false}> <motion.span key={step.status} data-slot="run-timeline-glyph" className="absolute inset-0 grid place-items-center" {...swapIn}> <Mark status={step.status} reduced={reduced} /> </motion.span> </AnimatePresence> </span> {last ? null : ( <span className="absolute top-7 bottom-1 left-1/2 w-0.5 -translate-x-1/2 overflow-hidden rounded-pill bg-border"> <motion.span data-slot="run-timeline-connector" className="absolute inset-0 origin-top rounded-pill bg-accent-solid" initial={false} animate={{ scaleY: step.status === "done" ? 1 : 0 }} transition={reduced ? instantTransition : { duration: motionTokens.duration.slow, ease: motionTokens.ease.linear }} /> </span> )} </span> <div className={cn("min-w-0", last ? "pb-0" : compact ? "pb-3" : "pb-6")}> {onStepSelect ? ( <button type="button" data-slot="run-timeline-trigger" aria-pressed={selectedId !== undefined ? selectedId === step.id : undefined} onClick={() => onStepSelect(step.id)} className={cn( "-mx-2 -my-1 flex w-[calc(100%+1rem)] min-w-0 items-start gap-3 rounded-control px-2 py-1 text-left outline-none transition-colors duration-instant ease-swap hover-fine:bg-surface-muted focus-visible:focus-ring", selectedId === step.id && "bg-surface-muted", )} > {titleRow} </button> ) : ( <div className="flex min-w-0 items-start gap-3">{titleRow}</div> )} <span className="sr-only">{labels[step.status]}</span> <AnimatePresence initial={false}> {showDetail ? ( <Reveal key="detail" reduced={reduced}> <div className={cn("pt-0.5 text-pretty", step.status === "error" ? "text-danger" : "text-secondary")}>{step.detail}</div> </Reveal> ) : null} </AnimatePresence> <AnimatePresence initial={false}> {showChildren ? ( <Reveal key="children" data-slot="run-timeline-children" className="min-w-0" reduced={reduced}> <div className="pt-3">{step.children}</div> </Reveal> ) : null} </AnimatePresence> </div> </li> ); })} </ol> ); }); - 4
Check that your global stylesheet imports the foundation, after Tailwind.
@import "tailwindcss"; @import "../components/pixilui/foundation.css";
Adds components/pixilui/run-timeline.tsx. Needs motion, clsx, tailwind-merge.
Preview#
The connector fills down to the next step as each check draws itself, until the run stops on an approval card and the card folds into a receipt.
Code#
"use client";
import { forwardRef, useEffect, useState, useSyncExternalStore, type ComponentPropsWithoutRef, type KeyboardEvent, type ReactNode } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { cn } from "./lib/utils";
import { instantTransition, motionTokens, reducedTransition } from "./motion-tokens";
const noSubscription = () => () => {};
/**
* useReducedMotion, held at false until hydration finishes. The server cannot know the
* preference, so reading it on the first client render would change the markup and break
* hydration; the reduced branch takes over on the next render instead.
*/
function useReducedMotionAfterHydration() {
const hydrated = useSyncExternalStore(noSubscription, () => true, () => false);
const reduced = useReducedMotion();
return hydrated && (reduced ?? false);
}
export type RunTimelineStatus = "pending" | "active" | "done" | "error" | "waiting";
export type RunTimelineStep = {
/** Stable key for the step; onStepSelect receives it. */
id: string;
/** What the step does, in a few words: "Pull stuck refunds from Stripe". */
title: ReactNode;
/** Evidence once it ran ("9 refunds pending for more than 72 hours"), or why it failed. Hidden in compact mode. */
detail?: ReactNode;
/** Pending, active (live timer), done (drawn check), error, or waiting on a person. */
status: RunTimelineStatus;
/** When the step started (epoch ms). Drives the live timer and the duration. */
startedAt?: number;
/** When the step finished (epoch ms). With startedAt, shows how long it took. */
endedAt?: number;
/** Content under the step: an approval card on a waiting step, a tool call, a diff. Waiting steps show it in compact mode too. */
children?: ReactNode;
};
export type RunTimelineLabels = Record<RunTimelineStatus, string>;
const defaultLabels: RunTimelineLabels = {
pending: "Up next",
active: "In progress",
done: "Done",
error: "Failed",
waiting: "Needs approval",
};
export type RunTimelineProps = Omit<ComponentPropsWithoutRef<"ol">, "children"> & {
/** The run's steps, in order. Update a step's status and the timeline animates the change. */
steps: RunTimelineStep[];
/** Titles only, tighter rhythm. Waiting steps still show their children. */
compact?: boolean;
/** Makes each step a button. Called with the step's id. */
onStepSelect?: (id: string) => void;
/** The step shown elsewhere (a detail pane), marked with data-selected. */
selectedId?: string;
/** Status words, read by screen readers and shown for waiting and failed steps. */
labels?: Partial<RunTimelineLabels>;
};
const TICK_MS = 250;
/** "820ms", "1.4s", "1m 04s". */
function formatDuration(ms: number) {
if (ms < 1000) return `${Math.round(ms)}ms`;
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
const s = Math.round(ms / 1000);
return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`;
}
function formatLive(ms: number) {
const s = Math.floor(ms / 1000);
return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, "0")}s`;
}
/** A live timer whose digits are keyed by place and roll up. Only this re-renders each tick. */
function LiveTimer({ startedAt, reduced }: { startedAt: number; reduced: boolean }) {
const [now, setNow] = useState<number | null>(null);
useEffect(() => {
const tick = () => setNow(Date.now());
tick();
const id = setInterval(tick, TICK_MS);
return () => clearInterval(id);
}, [startedAt]);
if (now === null) return null;
const value = formatLive(Math.max(0, now - startedAt));
const chars = value.split("");
return (
<span role="timer" aria-label={value} className="numeric inline-flex">
{chars.map((char, index) => {
const place = chars.length - index;
if (reduced || !/\d/.test(char)) {
return (
<span key={place} aria-hidden="true">
{char}
</span>
);
}
return (
<span key={place} aria-hidden="true" className="relative inline-flex h-[1lh] overflow-hidden">
<AnimatePresence initial={false} mode="popLayout">
<motion.span key={char} initial={{ y: "100%" }} animate={{ y: 0 }} exit={{ y: "-100%" }} transition={motionTokens.spring.snappy}>
{char}
</motion.span>
</AnimatePresence>
</span>
);
})}
</span>
);
}
/** Glyph rings sit between the 1px hairline and a 2px border. */
const ring = { borderWidth: 1.5 };
function Mark({ status, reduced }: { status: RunTimelineStatus; reduced: boolean }) {
const draw = reduced
? { initial: { pathLength: 1 }, transition: reducedTransition }
: { initial: { pathLength: 0 }, transition: { duration: motionTokens.duration.slow, ease: motionTokens.ease.enter, delay: motionTokens.stagger.item * 2 } };
switch (status) {
case "pending":
return <span className="size-2.5 rounded-pill border-solid border-border-strong" style={ring} />;
case "active":
return (
<span className="grid size-5 place-items-center rounded-pill border-solid border-accent-solid" style={ring}>
<span className="size-2 rounded-pill bg-accent-solid" />
</span>
);
case "done":
return (
<span className="grid size-5 place-items-center rounded-pill bg-accent-solid text-accent-contrast">
<svg viewBox="0 0 12 12" fill="none" className="size-3">
<motion.path d="M2.75 6.25l2.25 2.25 4.25-4.75" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" initial={draw.initial} animate={{ pathLength: 1 }} transition={draw.transition} />
</svg>
</span>
);
case "error":
return (
<span className="grid size-5 place-items-center rounded-pill bg-danger-subtle text-danger">
<svg viewBox="0 0 12 12" fill="none" className="size-3">
<motion.path d="M3.75 3.75l4.5 4.5M8.25 3.75l-4.5 4.5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" initial={draw.initial} animate={{ pathLength: 1 }} transition={draw.transition} />
</svg>
</span>
);
case "waiting":
return (
<span className="grid size-5 place-items-center rounded-pill bg-warning-subtle text-warning">
<svg viewBox="0 0 12 12" fill="none" className="size-3">
<path d="M4.5 3.5v5M7.5 3.5v5" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
</svg>
</span>
);
}
}
/**
* Detail and children open their own height, so the steps below are pushed along in the flow
* (no overlap while the content fades in). Growing uses morph, closing uses smooth. The clip is
* lifted once it has opened, so an approval card's shadow and focus ring show in full.
*/
function Reveal({ reduced, className, children, ...rest }: { reduced: boolean; className?: string; children: ReactNode; "data-slot"?: string }) {
const [clip, setClip] = useState(true);
const motionProps = reduced
? {
initial: { opacity: 0 },
animate: { opacity: 1 },
exit: { opacity: 0, height: 0, transition: instantTransition },
transition: reducedTransition,
}
: {
initial: { opacity: 0, height: 0, filter: `blur(${motionTokens.blur.subtle}px)` },
animate: {
opacity: 1,
height: "auto",
filter: "blur(0px)",
transition: {
height: motionTokens.spring.morph,
default: { duration: motionTokens.duration.fast, ease: motionTokens.ease.enter, delay: motionTokens.stagger.item * 2 },
},
},
exit: {
opacity: 0,
height: 0,
filter: `blur(${motionTokens.blur.subtle}px)`,
transition: { height: motionTokens.spring.smooth, default: { duration: motionTokens.duration.instant, ease: motionTokens.ease.exit } },
},
};
return (
<motion.div
{...rest}
{...motionProps}
className={className}
style={{ overflow: clip && !reduced ? "hidden" : "visible" }}
onAnimationStart={() => setClip(true)}
onAnimationComplete={() => setClip(false)}
>
{children}
</motion.div>
);
}
export const RunTimeline = forwardRef<HTMLOListElement, RunTimelineProps>(function RunTimeline(
{ steps, compact = false, onStepSelect, selectedId, labels: labelsProp, className, onKeyDown, ...rest },
ref,
) {
const labels = { ...defaultLabels, ...labelsProp };
const reduced = useReducedMotionAfterHydration();
// Up and Down walk the step buttons, Home and End jump to the ends.
const handleKeyDown = (event: KeyboardEvent<HTMLOListElement>) => {
onKeyDown?.(event);
if (event.defaultPrevented || !onStepSelect || !["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return;
const buttons = Array.from(event.currentTarget.querySelectorAll<HTMLElement>('[data-slot="run-timeline-trigger"]'));
const current = buttons.indexOf(event.target as HTMLElement);
if (current === -1) return;
event.preventDefault();
const next =
event.key === "Home" ? 0 : event.key === "End" ? buttons.length - 1 : Math.min(buttons.length - 1, Math.max(0, current + (event.key === "ArrowDown" ? 1 : -1)));
buttons[next]?.focus();
};
const swapIn = reduced
? { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0, transition: instantTransition }, transition: instantTransition }
: {
initial: { opacity: 0, scale: motionTokens.scale.enterFrom, filter: `blur(${motionTokens.blur.subtle}px)` },
animate: { opacity: 1, scale: 1, filter: "blur(0px)" },
exit: { opacity: 0, filter: `blur(${motionTokens.blur.subtle}px)`, transition: { duration: motionTokens.duration.instant, ease: motionTokens.ease.exit } },
transition: { duration: motionTokens.duration.fast, ease: motionTokens.ease.swap },
};
return (
<ol
ref={ref}
data-compact={compact || undefined}
className={cn("flex w-full min-w-0 flex-col font-body text-14 text-foreground", className)}
onKeyDown={handleKeyDown}
{...rest}
>
{steps.map((step, index) => {
const last = index === steps.length - 1;
const duration = step.startedAt !== undefined && step.endedAt !== undefined ? step.endedAt - step.startedAt : null;
const showDetail = !compact && step.detail;
const showChildren = step.children && (!compact || step.status === "waiting");
const flagged = step.status === "waiting" || step.status === "error";
const titleRow = (
<>
<span className={cn("min-w-0 flex-1 text-pretty font-medium transition-colors duration-fast ease-swap", step.status === "pending" ? "text-muted" : "text-foreground")}>
{step.title}
</span>
<span className="flex shrink-0 items-center gap-2 pt-px text-12 text-muted">
{flagged ? <span className={step.status === "error" ? "text-danger" : "text-warning"}>{labels[step.status]}</span> : null}
{step.status === "active" && step.startedAt !== undefined ? <LiveTimer startedAt={step.startedAt} reduced={reduced} /> : null}
{step.status === "done" && duration !== null ? <span className="numeric">{formatDuration(duration)}</span> : null}
</span>
</>
);
return (
<li
key={step.id}
data-status={step.status}
data-selected={selectedId === step.id || undefined}
aria-current={step.status === "active" ? "step" : undefined}
className="relative grid min-w-0 grid-cols-[1.25rem_minmax(0,1fr)] gap-x-3"
>
{/* Rail: the glyph, then the connector to the next step, which fills when this one is done. */}
<span aria-hidden="true" className="relative flex justify-center">
<span className="relative mt-0.5 grid size-5 place-items-center">
<AnimatePresence initial={false}>
<motion.span key={step.status} data-slot="run-timeline-glyph" className="absolute inset-0 grid place-items-center" {...swapIn}>
<Mark status={step.status} reduced={reduced} />
</motion.span>
</AnimatePresence>
</span>
{last ? null : (
<span className="absolute top-7 bottom-1 left-1/2 w-0.5 -translate-x-1/2 overflow-hidden rounded-pill bg-border">
<motion.span
data-slot="run-timeline-connector"
className="absolute inset-0 origin-top rounded-pill bg-accent-solid"
initial={false}
animate={{ scaleY: step.status === "done" ? 1 : 0 }}
transition={reduced ? instantTransition : { duration: motionTokens.duration.slow, ease: motionTokens.ease.linear }}
/>
</span>
)}
</span>
<div className={cn("min-w-0", last ? "pb-0" : compact ? "pb-3" : "pb-6")}>
{onStepSelect ? (
<button
type="button"
data-slot="run-timeline-trigger"
aria-pressed={selectedId !== undefined ? selectedId === step.id : undefined}
onClick={() => onStepSelect(step.id)}
className={cn(
"-mx-2 -my-1 flex w-[calc(100%+1rem)] min-w-0 items-start gap-3 rounded-control px-2 py-1 text-left outline-none transition-colors duration-instant ease-swap hover-fine:bg-surface-muted focus-visible:focus-ring",
selectedId === step.id && "bg-surface-muted",
)}
>
{titleRow}
</button>
) : (
<div className="flex min-w-0 items-start gap-3">{titleRow}</div>
)}
<span className="sr-only">{labels[step.status]}</span>
<AnimatePresence initial={false}>
{showDetail ? (
<Reveal key="detail" reduced={reduced}>
<div className={cn("pt-0.5 text-pretty", step.status === "error" ? "text-danger" : "text-secondary")}>{step.detail}</div>
</Reveal>
) : null}
</AnimatePresence>
<AnimatePresence initial={false}>
{showChildren ? (
<Reveal key="children" data-slot="run-timeline-children" className="min-w-0" reduced={reduced}>
<div className="pt-3">{step.children}</div>
</Reveal>
) : null}
</AnimatePresence>
</div>
</li>
);
})}
</ol>
);
});Usage#
Use it when
- Showing an agent run in progress, step by step, with the evidence of what each step did.
- A run that pauses for a person: the waiting step hosts an approval card in place.
- A run history view where each step can open details elsewhere (onStepSelect).
Use something else when
- A flat list of tool calls inside one message: use tool-call-row with ToolCallRowGroup.
- A quiet status line while a model thinks: use thinking-state.
- A form wizard or onboarding progress: use a stepper.
import { useEffect, useState, useSyncExternalStore } from "react";
import { useReducedMotion } from "motion/react";
import { Mail, RotateCcw } from "lucide-react";
import { Button } from "@/components/pixilui/button";
import { ApprovalCard, type ApprovalCardItem, type ApprovalCardStatus } from "@/components/pixilui/approval-card";
import { RunTimeline, type RunTimelineStatus, type RunTimelineStep } from "@/components/pixilui/run-timeline";
const noSubscription = () => () => {};
/**
* useReducedMotion, held at false until hydration finishes. The server cannot know the
* preference, so reading it on the first client render would change the markup and break
* hydration; the reduced branch takes over on the next render instead.
*/
function useReducedMotionAfterHydration() {
const hydrated = useSyncExternalStore(noSubscription, () => true, () => false);
const reduced = useReducedMotion();
return hydrated && (reduced ?? false);
}
// The first option of each knob is the demo's default; the docs preview starts there.
export const knobs = {
state: ["play", "waiting", "error", "done"],
mode: ["expanded", "compact"],
approval: ["auto", "manual"],
} as const;
type StepCopy = { id: string; title: string; detail: string };
const STEPS: StepCopy[] = [
{ id: "pull", title: "Pull stuck refunds from Stripe", detail: "9 refunds pending for more than 72 hours" },
{ id: "match", title: "Match refunds to customer accounts", detail: "7 accounts, with 2 duplicate refunds merged" },
{ id: "draft", title: "Draft apology emails with a $15 credit", detail: "7 drafts in the support voice, 94 to 118 words each" },
{ id: "approve", title: "Get approval to send", detail: "Approved by Dana Whitfield" },
{ id: "post", title: "Post the summary to #support-ops", detail: "Posted with links to ENG-4127 and the 7 tickets" },
];
const ITEMS: ApprovalCardItem[] = [
{ label: "Recipients", value: "7 customers" },
{ label: "Credits", value: "$105.00", numeric: true },
{ label: "Sends from", value: "support@northwind.co" },
];
type StepState = { status: RunTimelineStatus; startedAt?: number; endedAt?: number };
type Frame = { steps: StepState[]; approval: ApprovalCardStatus };
// Fixed clock for the still states, so durations read the same on every render.
const T0 = 1_761_400_000_000;
const DURATIONS = [1140, 870, 2260, 4120, 930];
const ended = (index: number): StepState => {
const start = T0 + DURATIONS.slice(0, index).reduce((a, b) => a + b, 0);
return { status: "done", startedAt: start, endedAt: start + DURATIONS[index]! };
};
const STILL: Record<"waiting" | "error" | "done", Frame> = {
waiting: { steps: [ended(0), ended(1), ended(2), { status: "waiting" }, { status: "pending" }], approval: "pending" },
error: { steps: [ended(0), ended(1), { status: "error" }, { status: "pending" }, { status: "pending" }], approval: "pending" },
done: { steps: STEPS.map((_, index) => ended(index)), approval: "approved" },
};
const START: Frame = { steps: STEPS.map(() => ({ status: "pending" })), approval: "pending" };
const STEP_MS = 900;
export default function RunTimelineDemo({ state = "play", mode = "expanded", approval = "auto" }: KnobValues<typeof knobs>) {
const reduced = useReducedMotionAfterHydration();
const [run, setRun] = useState(0);
const [frame, setFrame] = useState<Frame>(START);
const [finished, setFinished] = useState(false);
const playing = state === "play" && !reduced;
const combo = `${state}-${approval}-${reduced}`;
const [seen, setSeen] = useState<string | null>(null);
if (seen !== combo) {
setSeen(combo);
setFinished(false);
setFrame(playing ? START : STILL[state === "play" ? "done" : state]);
}
const setStep = (index: number, next: StepState) => setFrame((f) => ({ ...f, steps: f.steps.map((s, i) => (i === index ? next : s)) }));
// Steps 1 to 3 run at reading speed, then the run waits on a person.
useEffect(() => {
if (!playing) return;
const begin = (index: number) => () => setFrame((f) => ({ ...f, steps: f.steps.map((s, i) => (i === index ? { status: "active", startedAt: Date.now() } : s)) }));
const finish = (index: number) => () =>
setFrame((f) => ({ ...f, steps: f.steps.map((s, i) => (i === index ? { ...s, status: "done", endedAt: Date.now() } : s)) }));
const plan: [number, () => void][] = [
[300, begin(0)],
[300 + STEP_MS, finish(0)],
[300 + STEP_MS, begin(1)],
[300 + STEP_MS * 2, finish(1)],
[300 + STEP_MS * 2, begin(2)],
[300 + STEP_MS * 3, finish(2)],
[300 + STEP_MS * 3, () => setFrame((f) => ({ ...f, steps: f.steps.map((s, i) => (i === 3 ? { status: "waiting", startedAt: Date.now() } : s)) }))],
];
if (approval === "auto") plan.push([300 + STEP_MS * 3 + 2000, () => setFrame((f) => ({ ...f, approval: "approved" }))]);
const timers = plan.map(([at, fn]) => setTimeout(fn, at));
return () => timers.forEach(clearTimeout);
}, [playing, approval, run]);
// Once approved: close the waiting step, then run the last one.
const approved = frame.approval === "approved";
const waitingOnApproval = frame.steps[3]!.status === "waiting";
const lastStatus = frame.steps[4]!.status;
useEffect(() => {
if (!approved || !waitingOnApproval) return;
const id = setTimeout(
() => setFrame((f) => ({ ...f, steps: f.steps.map((s, i) => (i === 3 ? { ...s, status: "done", endedAt: Date.now() } : i === 4 ? { status: "active", startedAt: Date.now() } : s)) })),
500,
);
return () => clearTimeout(id);
}, [approved, waitingOnApproval]);
useEffect(() => {
if (lastStatus === "active") {
const id = setTimeout(() => setFrame((f) => ({ ...f, steps: f.steps.map((s, i) => (i === 4 ? { ...s, status: "done", endedAt: Date.now() } : s)) })), STEP_MS + 100);
return () => clearTimeout(id);
}
if (lastStatus === "done") {
const id = setTimeout(() => setFinished(true), STEP_MS);
return () => clearTimeout(id);
}
}, [lastStatus]);
const steps: RunTimelineStep[] = STEPS.map((copy, index) => {
const s = frame.steps[index]!;
const detail =
s.status === "done" ? copy.detail : s.status === "error" ? "Template refund-apology-v2 was archived on Oct 21. Pick another and retry." : undefined;
const card =
copy.id === "approve" && (s.status === "waiting" || s.status === "done") ? (
<ApprovalCard
title="Send 7 apology emails and apply $105.00 in credits"
icon={<Mail strokeWidth={1.5} />}
source="Support agent, run 7f2e1c"
items={ITEMS}
risk="medium"
status={frame.approval}
receipt="7 emails queued, $105.00 in credits applied"
labels={{ approve: "Approve and send" }}
onApprove={async () => {
await new Promise((resolve) => setTimeout(resolve, 700));
setFrame((f) => ({ ...f, approval: "approved" }));
}}
onReject={async () => {
await new Promise((resolve) => setTimeout(resolve, 500));
setStep(3, { status: "error" });
}}
/>
) : undefined;
return { id: copy.id, title: copy.title, detail: copy.id === "approve" ? undefined : detail, status: s.status, startedAt: s.startedAt, endedAt: s.endedAt, children: card };
});
return (
<div className="flex w-full min-w-0 max-w-lg flex-col gap-4">
<div className="min-h-[30rem]">
<RunTimeline aria-label="Refund run 7f2e1c" steps={steps} compact={mode === "compact"} />
</div>
<div className="flex h-control-sm justify-center">
{state === "play" && !reduced && finished ? (
<Button
variant="ghost"
size="sm"
icon={<RotateCcw strokeWidth={1.5} />}
onClick={() => {
setFrame(START);
setFinished(false);
setRun((n) => n + 1);
}}
>
Replay
</Button>
) : null}
</div>
</div>
);
}API#
| Prop | Type | Default | Description |
|---|---|---|---|
stepsrequired | RunTimelineStep[] | The run's steps, in order. Update a step's status and the timeline animates the change. | |
compact | boolean | false | Titles only, tighter rhythm. Waiting steps still show their children. |
onStepSelect | (id: string) => void | Makes each step a button. Called with the step's id. | |
selectedId | string | The step shown elsewhere (a detail pane), marked with data-selected. | |
labels | Partial<RunTimelineLabels> | Status words, read by screen readers and shown for waiting and failed steps. |
Also takes every native <ol> attribute, and a ref to it.
Keyboard#
- Tab
- Moves through step buttons when onStepSelect is set, and into a waiting step's approval.
- Enter
- Selects the focused step.
- ArrowDownArrowUp
- Moves between step buttons when onStepSelect is set.
- HomeEnd
- Jumps to the first or last step button.
Accessibility#
- An ordered list, so screen readers say how many steps there are and where each sits.
- The active step has aria-current="step"; every step carries its status in text (Done, In progress, Up next, Failed, Needs approval).
- Waiting and failed steps also show their status as visible words, not only as a colour or a glyph.
- The live timer has role timer, so it is not announced every second.
- With onStepSelect, steps are real buttons; selectedId sets aria-pressed.
Motion#
- Status changes crossfade the glyph through a 2px blur in 250ms on the swap easing; the done check draws itself.
- The connector under a finished step fills top to bottom in 400ms on linear easing, so progress reads segment by segment.
- Detail and children open their own height on the morph spring and fade up, pushing the steps below instead of overlapping them.
- The active step's timer digits roll up on the snappy spring.
- Reduced motion: glyphs and connectors swap instantly, content fades in.
Responsive#
- Fills its container; titles and details wrap, timings stay on the title line.
- Compact mode drops details and tightens the rhythm for side panels; waiting steps keep their approval.
Performance#
- Only the active step's timer re-renders each tick; the list re-renders when steps change.
- Connectors animate transform only.
Notes for AI#
- Keep one source of truth: map your run events to steps with id, title, status, startedAt and endedAt, and let the component animate the changes.
- Write detail as evidence in past tense once a step is done ("9 refunds pending for more than 72 hours"); on error, say what failed and what to do.
- Put an approval-card in the children of the waiting step; it stays visible in compact mode. Keep it there after approval so its receipt shows what was approved.
- Use onStepSelect with selectedId to open step details in a side pane.
- Statuses are pending, active, done, error and waiting; keep at most one active.