Agent Progress
Expandable multi-step agent progress with synchronized status animation.
Steps
Expandable multi-step progress.
5 steps left
Read project files
Update and install light mode tokens
Implement dark mode tokens
Add reusable registered theme toggle
Run registry, lint and production build
function AgentProgressDemo() {
return <AgentProgress steps={AGENT_PROGRESS_STEPS} />;
}function AgentProgressDemo() {
return <AgentProgress steps={AGENT_PROGRESS_STEPS} />;
}Installation
npx shadcn@latest add https://boardcn.dev/r/agent-progress.jsonnpx shadcn@latest add https://boardcn.dev/r/agent-progress.jsonnpm packages
- motion
BoardCN dependencies
The CLI installs these for you — you do not need to add them yourself.
Source
The file the CLI copies into your project.
"use client";
import { useEffect, useId, useState } from "react";
import {
AnimatePresence,
LayoutGroup,
MotionConfig,
motion,
} from "motion/react";
import { usePrefersReducedMotion } from "@/hooks/use-count-up";
import { cx } from "@/utils/cx";
const layoutTransition = {
type: "spring" as const,
stiffness: 260,
damping: 30,
mass: 0.8,
};
const easeOutExpo = [0.22, 1, 0.36, 1] as const;
export interface AgentProgressProps {
/** Ordered tasks to display. */
steps: string[];
/** Time in milliseconds spent on each task. */
stepDuration?: number;
/** Delay in milliseconds before `onFinished` runs after the final task. */
completionDelay?: number;
/** Called after every task and the completion delay have elapsed. */
onFinished?: () => void;
className?: string;
}
interface ProgressRingProps {
running: boolean;
duration: number;
size?: number;
reducedMotion?: boolean;
}
function LoadingText({ children, className }: { children: string; className?: string }) {
return (
<span
aria-label={children}
className={cx("agent-progress-loading-text inline-block", className)}
>
{children}
</span>
);
}
function ProgressRing({
running,
duration,
size = 16,
reducedMotion = false,
}: ProgressRingProps) {
return (
<svg
aria-hidden
viewBox="0 0 16 16"
width={size}
height={size}
className="shrink-0 -rotate-90"
>
<circle
cx="8"
cy="8"
r="6.75"
fill="none"
stroke="var(--color-border-button-default)"
strokeWidth="2.5"
/>
<motion.circle
data-progress-ring
cx="8"
cy="8"
r="6.75"
fill="none"
stroke="var(--color-agent-progress-ring)"
strokeWidth="2.5"
strokeLinecap="round"
initial={reducedMotion ? false : { pathLength: 0 }}
animate={{ pathLength: Number(running) }}
transition={{
duration: reducedMotion ? 0 : running ? duration : 0,
ease: "linear",
}}
/>
</svg>
);
}
function StepProgressRing({
running,
duration,
reducedMotion = false,
}: Omit<ProgressRingProps, "size">) {
return (
<svg aria-hidden viewBox="0 0 14 14" className="size-3.5 shrink-0 -rotate-90">
<circle
cx="7"
cy="7"
r="5.75"
fill="none"
stroke="var(--color-border-button-default)"
strokeWidth="1.5"
/>
<motion.circle
data-step-progress
cx="7"
cy="7"
r="5.75"
fill="none"
stroke="var(--color-agent-progress-ring)"
strokeWidth="1.5"
strokeLinecap="round"
initial={reducedMotion ? false : { pathLength: 0 }}
animate={{ pathLength: Number(running) }}
transition={{
duration: reducedMotion ? 0 : running ? duration : 0,
ease: "linear",
}}
/>
</svg>
);
}
function MinimizeIcon() {
return (
<span aria-hidden className="relative block size-5 shrink-0">
<span className="absolute top-px left-px size-[18px] rounded-sm bg-background-quaternary-default" />
<span className="absolute top-[13px] left-1 h-0.5 w-3 rounded-[3px] bg-foreground-icon-secondary" />
</span>
);
}
function CompleteIcon() {
return (
<svg aria-hidden viewBox="0 0 14 14" className="size-[15px]">
<circle cx="7" cy="7" r="7" fill="var(--color-background-quaternary-default)" />
<path
d="M4 7.5 5.646 9.146a.5.5 0 0 0 .708 0L10 5.5"
fill="none"
stroke="var(--color-foreground-icon-secondary)"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
);
}
function PendingIcon() {
return (
<svg aria-hidden viewBox="0 0 15 15" className="size-[15px]">
<circle
cx="7.5"
cy="7.5"
r="7"
fill="none"
stroke="var(--color-background-quaternary-default)"
strokeDasharray="2 2"
/>
</svg>
);
}
function CurrentStepArrow() {
return (
<svg aria-hidden viewBox="0 0 14 14" className="size-3.5 shrink-0">
<path
d="M7.47 2.47a.75.75 0 0 1 1.06 0l4.177 4.176a.5.5 0 0 1 0 .708L8.53 11.53a.75.75 0 0 1-1.06-1.06l2.72-2.72H2a.75.75 0 0 1 0-1.5h8.19L7.47 3.53a.75.75 0 0 1 0-1.06Z"
fill="var(--color-foreground-icon-secondary)"
/>
</svg>
);
}
function ExpandIcon() {
return (
<svg aria-hidden viewBox="0 0 20 20" className="size-5">
<rect
x="1"
y="1"
width="18"
height="18"
rx="4"
fill="var(--color-background-quaternary-default)"
/>
<path
d="M7.553 6.109a.75.75 0 0 1 .75-.75h5.907a.5.5 0 0 1 .5.5v5.906a.75.75 0 0 1-1.5 0V7.919l-5.79 5.791a.75.75 0 1 1-1.061-1.06l5.79-5.791H8.303a.75.75 0 0 1-.75-.75Z"
fill="var(--color-foreground-icon-secondary)"
/>
</svg>
);
}
function AnimatedLabel({
label,
className,
reducedMotion,
}: {
label: string;
className?: string;
reducedMotion: boolean;
}) {
return (
<motion.span
layout="position"
transition={reducedMotion ? { duration: 0 } : layoutTransition}
className={cx("relative inline-flex min-w-0 overflow-hidden", className)}
>
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={label}
initial={reducedMotion ? false : { opacity: 0, y: 4, filter: "blur(3px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, y: -4, filter: "blur(3px)" }}
transition={{ duration: reducedMotion ? 0 : 0.26, ease: easeOutExpo }}
className="block whitespace-nowrap"
>
{label}
</motion.span>
</AnimatePresence>
</motion.span>
);
}
function StepLabel({
label,
complete,
active,
reducedMotion,
}: {
label: string;
complete: boolean;
active: boolean;
reducedMotion: boolean;
}) {
return (
<span
className={cx(
"relative block min-w-0 max-w-full truncate text-body-medium leading-5 transition-colors duration-300 motion-reduce:transition-none",
complete || !active ? "text-text-secondary" : "text-text-primary",
)}
>
{active ? <LoadingText>{label}</LoadingText> : label}
<AnimatePresence>
{complete ? (
<motion.span
aria-hidden
className="absolute top-1/2 right-0 left-0 h-px origin-left bg-current"
initial={reducedMotion ? false : { scaleX: 0, opacity: 0 }}
animate={{ scaleX: 1, opacity: 0.8 }}
exit={{ scaleX: 0, opacity: 0 }}
transition={{ duration: reducedMotion ? 0 : 0.38, ease: easeOutExpo }}
/>
) : null}
</AnimatePresence>
</span>
);
}
interface ProgressStepProps {
label: string;
index: number;
completedCount: number;
processingStarted: boolean;
reopening: boolean;
stepCount: number;
stepDuration: number;
reducedMotion: boolean;
}
function ProgressStep({
label,
index,
completedCount,
processingStarted,
reopening,
stepCount,
stepDuration,
reducedMotion,
}: ProgressStepProps) {
const complete = index < completedCount;
const active = index === completedCount && completedCount < stepCount;
return (
<motion.div
layout="position"
initial={reducedMotion ? false : { opacity: 0, y: -4, filter: "blur(6px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
transition={{
delay: reducedMotion ? 0 : reopening ? 0.035 * index : 0.65 + 0.16 * index,
duration: reducedMotion ? 0 : reopening ? 0.24 : 0.45,
ease: easeOutExpo,
layout: reducedMotion ? { duration: 0 } : layoutTransition,
}}
className="h-8 w-full"
>
<motion.div
animate={{ paddingLeft: active ? 9 : 4, paddingRight: active ? 13 : 4 }}
transition={{ duration: reducedMotion ? 0 : 0.36, ease: easeOutExpo }}
className="relative flex h-full w-full items-center gap-2 rounded-full"
>
{active ? (
<motion.span
layoutId="active-step-border"
aria-hidden
className="pointer-events-none absolute inset-0 rounded-full border border-border-button-default"
transition={reducedMotion ? { duration: 0 } : layoutTransition}
/>
) : null}
<span className="relative z-10 flex size-3.5 shrink-0 items-center justify-center">
<AnimatePresence initial={false} mode="popLayout">
{complete ? (
<motion.span
key="complete"
initial={reducedMotion ? false : { opacity: 0, scale: 0.72, rotate: -18 }}
animate={{ opacity: 1, scale: 1, rotate: 0 }}
transition={{ duration: reducedMotion ? 0 : 0.34, ease: easeOutExpo }}
className="absolute inset-[-0.5px]"
>
<CompleteIcon />
</motion.span>
) : active ? (
<motion.span
key={`active-${index}`}
initial={reducedMotion ? false : { opacity: 0, scale: 0.82 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.82 }}
transition={{ duration: reducedMotion ? 0 : 0.25, ease: "easeOut" }}
className="absolute inset-0"
>
<StepProgressRing
running={processingStarted}
duration={stepDuration}
reducedMotion={reducedMotion}
/>
</motion.span>
) : (
<motion.span
key="pending"
initial={reducedMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reducedMotion ? 0 : 0.2 }}
className="absolute inset-[-0.5px]"
>
<PendingIcon />
</motion.span>
)}
</AnimatePresence>
</span>
<span className="relative z-10 flex min-w-0 flex-1 items-center truncate leading-5">
<StepLabel
label={label}
complete={complete}
active={active}
reducedMotion={reducedMotion}
/>
</span>
</motion.div>
</motion.div>
);
}
/**
* Collapsible progress block for multi-step agent work.
*
* The expanded list advances each task in order. Collapsing it preserves both
* task state and the synchronized overall progress ring.
*/
export function AgentProgress({
steps,
stepDuration = 3000,
completionDelay = 1000,
onFinished,
className,
}: AgentProgressProps) {
const reducedMotion = usePrefersReducedMotion();
const layoutGroupId = useId();
const durationMs = Math.max(0, stepDuration);
const durationSeconds = durationMs / 1000;
const totalDuration = steps.length * durationSeconds;
const expandedHeight = Math.max(44, 45 + 38 * steps.length);
const [completedCount, setCompletedCount] = useState(0);
const [minimized, setMinimized] = useState(false);
const [reopening, setReopening] = useState(false);
const [processingStarted, setProcessingStarted] = useState(false);
const stepsLeft = steps.length - completedCount;
const activeStep = steps[Math.min(completedCount, steps.length - 1)];
const complete = completedCount >= steps.length;
useEffect(() => {
const timer = window.setTimeout(() => setProcessingStarted(true), 1100);
return () => window.clearTimeout(timer);
}, []);
useEffect(() => {
if (!processingStarted || complete) return;
const timer = window.setTimeout(() => {
setCompletedCount((count) => Math.min(count + 1, steps.length));
}, durationMs);
return () => window.clearTimeout(timer);
}, [complete, completedCount, durationMs, processingStarted, steps.length]);
useEffect(() => {
if (!complete || !onFinished) return;
const timer = window.setTimeout(onFinished, Math.max(0, completionDelay));
return () => window.clearTimeout(timer);
}, [complete, completionDelay, onFinished]);
const statusLabel = complete
? "All steps completed"
: `${stepsLeft} ${stepsLeft === 1 ? "step" : "steps"} left`;
return (
<MotionConfig reducedMotion={reducedMotion ? "always" : "never"}>
<motion.div
initial={
reducedMotion
? false
: { opacity: 0, y: -12, height: 0, filter: "blur(8px)" }
}
animate={{
opacity: 1,
y: 0,
height: minimized ? 44 : expandedHeight,
filter: "blur(0px)",
}}
transition={{
opacity: { duration: reducedMotion ? 0 : 0.35, ease: easeOutExpo },
y: { duration: reducedMotion ? 0 : 0.5, ease: easeOutExpo },
filter: { duration: reducedMotion ? 0 : 0.4, ease: easeOutExpo },
height: {
duration: reducedMotion
? 0
: minimized
? 0.42
: reopening
? 0.24
: 0.65,
ease: easeOutExpo,
},
}}
onAnimationComplete={() => {
if (!minimized) setReopening(false);
}}
className={cx(
"relative w-[341px] max-w-full overflow-hidden rounded-2xl border border-border-button-default bg-background-primary-default shadow-xs",
className,
)}
aria-live="polite"
data-testid="agent-progress"
>
<AnimatePresence initial={false}>
{!complete ? (
<motion.span
key="persistent-progress-ring"
className="pointer-events-none absolute top-[14px] left-[14px] z-20 flex size-4 items-center justify-center"
initial={reducedMotion ? false : { opacity: 0, scale: 0.82 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.82 }}
transition={{ duration: reducedMotion ? 0 : 0.25, ease: easeOutExpo }}
>
<ProgressRing
running={processingStarted}
duration={totalDuration}
reducedMotion={reducedMotion}
/>
</motion.span>
) : null}
</AnimatePresence>
<AnimatePresence mode="sync">
{minimized ? (
<motion.button
key="minimized"
type="button"
data-testid="agent-progress-minimized"
aria-label="Expand steps"
onClick={() => {
setReopening(true);
setMinimized(false);
}}
initial={reducedMotion ? false : { opacity: 0, filter: "blur(3px)" }}
animate={{ opacity: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, filter: "blur(3px)" }}
transition={{
duration: reducedMotion ? 0 : 0.2,
delay: reducedMotion ? 0 : 0.12,
ease: easeOutExpo,
}}
className="group absolute inset-0 flex cursor-pointer items-center gap-2 px-2.5 py-1.5 text-left"
>
<span className="flex min-w-0 shrink-0 items-center pl-1">
<AnimatePresence initial={false}>
{!complete ? (
<motion.span
key="progress-ring"
className="flex h-4 w-6 shrink-0 items-center overflow-hidden"
initial={reducedMotion ? false : { width: 24, opacity: 1 }}
animate={{ width: 24, opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ duration: reducedMotion ? 0 : 0.4, ease: easeOutExpo }}
/>
) : null}
</AnimatePresence>
<AnimatedLabel
label={statusLabel}
className="text-body-medium text-text-secondary"
reducedMotion={reducedMotion}
/>
</span>
{!complete ? (
<span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden py-1.5 pr-6 [mask-image:linear-gradient(to_right,#000_0%,#000_100%)] transition-[mask-image] duration-300 group-hover:[mask-image:linear-gradient(to_right,#000_0%,#000_68%,transparent_94%)] motion-reduce:transition-none">
<CurrentStepArrow />
<span className="block min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-body-medium">
<LoadingText className="block max-w-full overflow-hidden text-ellipsis whitespace-nowrap">
{activeStep}
</LoadingText>
</span>
</span>
) : null}
<span className="absolute top-1/2 right-2.5 size-5 -translate-y-1/2 opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-visible:opacity-100 motion-reduce:transition-none">
<ExpandIcon />
</span>
</motion.button>
) : (
<motion.div
key="expanded"
data-testid="agent-progress-expanded"
initial={reducedMotion ? false : { opacity: 0, filter: "blur(3px)" }}
animate={{ opacity: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, y: -4, filter: "blur(3px)" }}
transition={{
duration: reducedMotion ? 0 : reopening ? 0.16 : 0.22,
ease: easeOutExpo,
}}
className="absolute inset-x-0 top-0 px-2.5 pt-2 pb-2.5"
style={{ height: expandedHeight }}
>
<div className="pt-1">
<div className="flex h-5 w-full items-center pl-1">
<AnimatePresence initial={false}>
{!complete ? (
<motion.span
key="progress-ring"
className="flex h-4 w-6 shrink-0 items-center overflow-hidden"
initial={reducedMotion ? false : { width: 24, opacity: 1 }}
animate={{ width: 24, opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ duration: reducedMotion ? 0 : 0.4, ease: easeOutExpo }}
/>
) : null}
</AnimatePresence>
<AnimatedLabel
label={statusLabel}
className="flex-1 text-body-medium text-text-secondary"
reducedMotion={reducedMotion}
/>
<button
type="button"
aria-label="Minimize steps"
onClick={() => setMinimized(true)}
className="size-5 cursor-pointer rounded-sm transition-opacity duration-200 hover:opacity-80 motion-reduce:transition-none"
>
<MinimizeIcon />
</button>
</div>
<LayoutGroup id={layoutGroupId}>
<div className="mt-[9px] flex flex-col gap-1.5">
{steps.map((label, index) => (
<ProgressStep
key={label}
label={label}
index={index}
completedCount={completedCount}
processingStarted={processingStarted}
reopening={reopening}
stepCount={steps.length}
stepDuration={durationSeconds}
reducedMotion={reducedMotion}
/>
))}
</div>
</LayoutGroup>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</MotionConfig>
);
}"use client";
import { useEffect, useId, useState } from "react";
import {
AnimatePresence,
LayoutGroup,
MotionConfig,
motion,
} from "motion/react";
import { usePrefersReducedMotion } from "@/hooks/use-count-up";
import { cx } from "@/utils/cx";
const layoutTransition = {
type: "spring" as const,
stiffness: 260,
damping: 30,
mass: 0.8,
};
const easeOutExpo = [0.22, 1, 0.36, 1] as const;
export interface AgentProgressProps {
/** Ordered tasks to display. */
steps: string[];
/** Time in milliseconds spent on each task. */
stepDuration?: number;
/** Delay in milliseconds before `onFinished` runs after the final task. */
completionDelay?: number;
/** Called after every task and the completion delay have elapsed. */
onFinished?: () => void;
className?: string;
}
interface ProgressRingProps {
running: boolean;
duration: number;
size?: number;
reducedMotion?: boolean;
}
function LoadingText({ children, className }: { children: string; className?: string }) {
return (
<span
aria-label={children}
className={cx("agent-progress-loading-text inline-block", className)}
>
{children}
</span>
);
}
function ProgressRing({
running,
duration,
size = 16,
reducedMotion = false,
}: ProgressRingProps) {
return (
<svg
aria-hidden
viewBox="0 0 16 16"
width={size}
height={size}
className="shrink-0 -rotate-90"
>
<circle
cx="8"
cy="8"
r="6.75"
fill="none"
stroke="var(--color-border-button-default)"
strokeWidth="2.5"
/>
<motion.circle
data-progress-ring
cx="8"
cy="8"
r="6.75"
fill="none"
stroke="var(--color-agent-progress-ring)"
strokeWidth="2.5"
strokeLinecap="round"
initial={reducedMotion ? false : { pathLength: 0 }}
animate={{ pathLength: Number(running) }}
transition={{
duration: reducedMotion ? 0 : running ? duration : 0,
ease: "linear",
}}
/>
</svg>
);
}
function StepProgressRing({
running,
duration,
reducedMotion = false,
}: Omit<ProgressRingProps, "size">) {
return (
<svg aria-hidden viewBox="0 0 14 14" className="size-3.5 shrink-0 -rotate-90">
<circle
cx="7"
cy="7"
r="5.75"
fill="none"
stroke="var(--color-border-button-default)"
strokeWidth="1.5"
/>
<motion.circle
data-step-progress
cx="7"
cy="7"
r="5.75"
fill="none"
stroke="var(--color-agent-progress-ring)"
strokeWidth="1.5"
strokeLinecap="round"
initial={reducedMotion ? false : { pathLength: 0 }}
animate={{ pathLength: Number(running) }}
transition={{
duration: reducedMotion ? 0 : running ? duration : 0,
ease: "linear",
}}
/>
</svg>
);
}
function MinimizeIcon() {
return (
<span aria-hidden className="relative block size-5 shrink-0">
<span className="absolute top-px left-px size-[18px] rounded-sm bg-background-quaternary-default" />
<span className="absolute top-[13px] left-1 h-0.5 w-3 rounded-[3px] bg-foreground-icon-secondary" />
</span>
);
}
function CompleteIcon() {
return (
<svg aria-hidden viewBox="0 0 14 14" className="size-[15px]">
<circle cx="7" cy="7" r="7" fill="var(--color-background-quaternary-default)" />
<path
d="M4 7.5 5.646 9.146a.5.5 0 0 0 .708 0L10 5.5"
fill="none"
stroke="var(--color-foreground-icon-secondary)"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
);
}
function PendingIcon() {
return (
<svg aria-hidden viewBox="0 0 15 15" className="size-[15px]">
<circle
cx="7.5"
cy="7.5"
r="7"
fill="none"
stroke="var(--color-background-quaternary-default)"
strokeDasharray="2 2"
/>
</svg>
);
}
function CurrentStepArrow() {
return (
<svg aria-hidden viewBox="0 0 14 14" className="size-3.5 shrink-0">
<path
d="M7.47 2.47a.75.75 0 0 1 1.06 0l4.177 4.176a.5.5 0 0 1 0 .708L8.53 11.53a.75.75 0 0 1-1.06-1.06l2.72-2.72H2a.75.75 0 0 1 0-1.5h8.19L7.47 3.53a.75.75 0 0 1 0-1.06Z"
fill="var(--color-foreground-icon-secondary)"
/>
</svg>
);
}
function ExpandIcon() {
return (
<svg aria-hidden viewBox="0 0 20 20" className="size-5">
<rect
x="1"
y="1"
width="18"
height="18"
rx="4"
fill="var(--color-background-quaternary-default)"
/>
<path
d="M7.553 6.109a.75.75 0 0 1 .75-.75h5.907a.5.5 0 0 1 .5.5v5.906a.75.75 0 0 1-1.5 0V7.919l-5.79 5.791a.75.75 0 1 1-1.061-1.06l5.79-5.791H8.303a.75.75 0 0 1-.75-.75Z"
fill="var(--color-foreground-icon-secondary)"
/>
</svg>
);
}
function AnimatedLabel({
label,
className,
reducedMotion,
}: {
label: string;
className?: string;
reducedMotion: boolean;
}) {
return (
<motion.span
layout="position"
transition={reducedMotion ? { duration: 0 } : layoutTransition}
className={cx("relative inline-flex min-w-0 overflow-hidden", className)}
>
<AnimatePresence initial={false} mode="popLayout">
<motion.span
key={label}
initial={reducedMotion ? false : { opacity: 0, y: 4, filter: "blur(3px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
exit={{ opacity: 0, y: -4, filter: "blur(3px)" }}
transition={{ duration: reducedMotion ? 0 : 0.26, ease: easeOutExpo }}
className="block whitespace-nowrap"
>
{label}
</motion.span>
</AnimatePresence>
</motion.span>
);
}
function StepLabel({
label,
complete,
active,
reducedMotion,
}: {
label: string;
complete: boolean;
active: boolean;
reducedMotion: boolean;
}) {
return (
<span
className={cx(
"relative block min-w-0 max-w-full truncate text-body-medium leading-5 transition-colors duration-300 motion-reduce:transition-none",
complete || !active ? "text-text-secondary" : "text-text-primary",
)}
>
{active ? <LoadingText>{label}</LoadingText> : label}
<AnimatePresence>
{complete ? (
<motion.span
aria-hidden
className="absolute top-1/2 right-0 left-0 h-px origin-left bg-current"
initial={reducedMotion ? false : { scaleX: 0, opacity: 0 }}
animate={{ scaleX: 1, opacity: 0.8 }}
exit={{ scaleX: 0, opacity: 0 }}
transition={{ duration: reducedMotion ? 0 : 0.38, ease: easeOutExpo }}
/>
) : null}
</AnimatePresence>
</span>
);
}
interface ProgressStepProps {
label: string;
index: number;
completedCount: number;
processingStarted: boolean;
reopening: boolean;
stepCount: number;
stepDuration: number;
reducedMotion: boolean;
}
function ProgressStep({
label,
index,
completedCount,
processingStarted,
reopening,
stepCount,
stepDuration,
reducedMotion,
}: ProgressStepProps) {
const complete = index < completedCount;
const active = index === completedCount && completedCount < stepCount;
return (
<motion.div
layout="position"
initial={reducedMotion ? false : { opacity: 0, y: -4, filter: "blur(6px)" }}
animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
transition={{
delay: reducedMotion ? 0 : reopening ? 0.035 * index : 0.65 + 0.16 * index,
duration: reducedMotion ? 0 : reopening ? 0.24 : 0.45,
ease: easeOutExpo,
layout: reducedMotion ? { duration: 0 } : layoutTransition,
}}
className="h-8 w-full"
>
<motion.div
animate={{ paddingLeft: active ? 9 : 4, paddingRight: active ? 13 : 4 }}
transition={{ duration: reducedMotion ? 0 : 0.36, ease: easeOutExpo }}
className="relative flex h-full w-full items-center gap-2 rounded-full"
>
{active ? (
<motion.span
layoutId="active-step-border"
aria-hidden
className="pointer-events-none absolute inset-0 rounded-full border border-border-button-default"
transition={reducedMotion ? { duration: 0 } : layoutTransition}
/>
) : null}
<span className="relative z-10 flex size-3.5 shrink-0 items-center justify-center">
<AnimatePresence initial={false} mode="popLayout">
{complete ? (
<motion.span
key="complete"
initial={reducedMotion ? false : { opacity: 0, scale: 0.72, rotate: -18 }}
animate={{ opacity: 1, scale: 1, rotate: 0 }}
transition={{ duration: reducedMotion ? 0 : 0.34, ease: easeOutExpo }}
className="absolute inset-[-0.5px]"
>
<CompleteIcon />
</motion.span>
) : active ? (
<motion.span
key={`active-${index}`}
initial={reducedMotion ? false : { opacity: 0, scale: 0.82 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.82 }}
transition={{ duration: reducedMotion ? 0 : 0.25, ease: "easeOut" }}
className="absolute inset-0"
>
<StepProgressRing
running={processingStarted}
duration={stepDuration}
reducedMotion={reducedMotion}
/>
</motion.span>
) : (
<motion.span
key="pending"
initial={reducedMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reducedMotion ? 0 : 0.2 }}
className="absolute inset-[-0.5px]"
>
<PendingIcon />
</motion.span>
)}
</AnimatePresence>
</span>
<span className="relative z-10 flex min-w-0 flex-1 items-center truncate leading-5">
<StepLabel
label={label}
complete={complete}
active={active}
reducedMotion={reducedMotion}
/>
</span>
</motion.div>
</motion.div>
);
}
/**
* Collapsible progress block for multi-step agent work.
*
* The expanded list advances each task in order. Collapsing it preserves both
* task state and the synchronized overall progress ring.
*/
export function AgentProgress({
steps,
stepDuration = 3000,
completionDelay = 1000,
onFinished,
className,
}: AgentProgressProps) {
const reducedMotion = usePrefersReducedMotion();
const layoutGroupId = useId();
const durationMs = Math.max(0, stepDuration);
const durationSeconds = durationMs / 1000;
const totalDuration = steps.length * durationSeconds;
const expandedHeight = Math.max(44, 45 + 38 * steps.length);
const [completedCount, setCompletedCount] = useState(0);
const [minimized, setMinimized] = useState(false);
const [reopening, setReopening] = useState(false);
const [processingStarted, setProcessingStarted] = useState(false);
const stepsLeft = steps.length - completedCount;
const activeStep = steps[Math.min(completedCount, steps.length - 1)];
const complete = completedCount >= steps.length;
useEffect(() => {
const timer = window.setTimeout(() => setProcessingStarted(true), 1100);
return () => window.clearTimeout(timer);
}, []);
useEffect(() => {
if (!processingStarted || complete) return;
const timer = window.setTimeout(() => {
setCompletedCount((count) => Math.min(count + 1, steps.length));
}, durationMs);
return () => window.clearTimeout(timer);
}, [complete, completedCount, durationMs, processingStarted, steps.length]);
useEffect(() => {
if (!complete || !onFinished) return;
const timer = window.setTimeout(onFinished, Math.max(0, completionDelay));
return () => window.clearTimeout(timer);
}, [complete, completionDelay, onFinished]);
const statusLabel = complete
? "All steps completed"
: `${stepsLeft} ${stepsLeft === 1 ? "step" : "steps"} left`;
return (
<MotionConfig reducedMotion={reducedMotion ? "always" : "never"}>
<motion.div
initial={
reducedMotion
? false
: { opacity: 0, y: -12, height: 0, filter: "blur(8px)" }
}
animate={{
opacity: 1,
y: 0,
height: minimized ? 44 : expandedHeight,
filter: "blur(0px)",
}}
transition={{
opacity: { duration: reducedMotion ? 0 : 0.35, ease: easeOutExpo },
y: { duration: reducedMotion ? 0 : 0.5, ease: easeOutExpo },
filter: { duration: reducedMotion ? 0 : 0.4, ease: easeOutExpo },
height: {
duration: reducedMotion
? 0
: minimized
? 0.42
: reopening
? 0.24
: 0.65,
ease: easeOutExpo,
},
}}
onAnimationComplete={() => {
if (!minimized) setReopening(false);
}}
className={cx(
"relative w-[341px] max-w-full overflow-hidden rounded-2xl border border-border-button-default bg-background-primary-default shadow-xs",
className,
)}
aria-live="polite"
data-testid="agent-progress"
>
<AnimatePresence initial={false}>
{!complete ? (
<motion.span
key="persistent-progress-ring"
className="pointer-events-none absolute top-[14px] left-[14px] z-20 flex size-4 items-center justify-center"
initial={reducedMotion ? false : { opacity: 0, scale: 0.82 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.82 }}
transition={{ duration: reducedMotion ? 0 : 0.25, ease: easeOutExpo }}
>
<ProgressRing
running={processingStarted}
duration={totalDuration}
reducedMotion={reducedMotion}
/>
</motion.span>
) : null}
</AnimatePresence>
<AnimatePresence mode="sync">
{minimized ? (
<motion.button
key="minimized"
type="button"
data-testid="agent-progress-minimized"
aria-label="Expand steps"
onClick={() => {
setReopening(true);
setMinimized(false);
}}
initial={reducedMotion ? false : { opacity: 0, filter: "blur(3px)" }}
animate={{ opacity: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, filter: "blur(3px)" }}
transition={{
duration: reducedMotion ? 0 : 0.2,
delay: reducedMotion ? 0 : 0.12,
ease: easeOutExpo,
}}
className="group absolute inset-0 flex cursor-pointer items-center gap-2 px-2.5 py-1.5 text-left"
>
<span className="flex min-w-0 shrink-0 items-center pl-1">
<AnimatePresence initial={false}>
{!complete ? (
<motion.span
key="progress-ring"
className="flex h-4 w-6 shrink-0 items-center overflow-hidden"
initial={reducedMotion ? false : { width: 24, opacity: 1 }}
animate={{ width: 24, opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ duration: reducedMotion ? 0 : 0.4, ease: easeOutExpo }}
/>
) : null}
</AnimatePresence>
<AnimatedLabel
label={statusLabel}
className="text-body-medium text-text-secondary"
reducedMotion={reducedMotion}
/>
</span>
{!complete ? (
<span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden py-1.5 pr-6 [mask-image:linear-gradient(to_right,#000_0%,#000_100%)] transition-[mask-image] duration-300 group-hover:[mask-image:linear-gradient(to_right,#000_0%,#000_68%,transparent_94%)] motion-reduce:transition-none">
<CurrentStepArrow />
<span className="block min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap text-body-medium">
<LoadingText className="block max-w-full overflow-hidden text-ellipsis whitespace-nowrap">
{activeStep}
</LoadingText>
</span>
</span>
) : null}
<span className="absolute top-1/2 right-2.5 size-5 -translate-y-1/2 opacity-0 transition-opacity duration-200 group-hover:opacity-100 group-focus-visible:opacity-100 motion-reduce:transition-none">
<ExpandIcon />
</span>
</motion.button>
) : (
<motion.div
key="expanded"
data-testid="agent-progress-expanded"
initial={reducedMotion ? false : { opacity: 0, filter: "blur(3px)" }}
animate={{ opacity: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, y: -4, filter: "blur(3px)" }}
transition={{
duration: reducedMotion ? 0 : reopening ? 0.16 : 0.22,
ease: easeOutExpo,
}}
className="absolute inset-x-0 top-0 px-2.5 pt-2 pb-2.5"
style={{ height: expandedHeight }}
>
<div className="pt-1">
<div className="flex h-5 w-full items-center pl-1">
<AnimatePresence initial={false}>
{!complete ? (
<motion.span
key="progress-ring"
className="flex h-4 w-6 shrink-0 items-center overflow-hidden"
initial={reducedMotion ? false : { width: 24, opacity: 1 }}
animate={{ width: 24, opacity: 1 }}
exit={{ width: 0, opacity: 0 }}
transition={{ duration: reducedMotion ? 0 : 0.4, ease: easeOutExpo }}
/>
) : null}
</AnimatePresence>
<AnimatedLabel
label={statusLabel}
className="flex-1 text-body-medium text-text-secondary"
reducedMotion={reducedMotion}
/>
<button
type="button"
aria-label="Minimize steps"
onClick={() => setMinimized(true)}
className="size-5 cursor-pointer rounded-sm transition-opacity duration-200 hover:opacity-80 motion-reduce:transition-none"
>
<MinimizeIcon />
</button>
</div>
<LayoutGroup id={layoutGroupId}>
<div className="mt-[9px] flex flex-col gap-1.5">
{steps.map((label, index) => (
<ProgressStep
key={label}
label={label}
index={index}
completedCount={completedCount}
processingStarted={processingStarted}
reopening={reopening}
stepCount={steps.length}
stepDuration={durationSeconds}
reducedMotion={reducedMotion}
/>
))}
</div>
</LayoutGroup>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</MotionConfig>
);
}Props
Generated from the component's TypeScript types. Standard DOM and React Aria props are omitted.
AgentProgress
Collapsible progress block for multi-step agent work. The expanded list advances each task in order. Collapsing it preserves both task state and the synchronized overall progress ring.
| Prop | Type | Default | Description |
|---|---|---|---|
| stepsrequired | string[] | — | Ordered tasks to display. |
| className | string | — | — |
| completionDelay | number | 1000 | Delay in milliseconds before `onFinished` runs after the final task. |
| onFinished | () => void | — | Called after every task and the completion delay have elapsed. |
| stepDuration | number | 3000 | Time in milliseconds spent on each task. |