Skip to content

Agent Progress Steps

Steps move from loading to done with drawn checks.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/agent-steps.json

Manual

  1. Install the dependencies

    npm install clsx motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts. See Installation if you have not set them up yet.

  3. Add the theme tokens

    This component uses --destructive-text and --success, which shadcn does not define by default. Add them to your global CSS (--destructive-text falls back to your --destructive unless you set it):

    app/globals.css
    :root {
      --success: #15803d;
    }
    
    .dark {
      --success: #4ade80;
    }
    
    @theme inline {
      --color-destructive-text: var(--destructive-text, var(--destructive));
      --color-success: var(--success);
    }
  4. Copy the source

    components/ui/agent-steps/agent-steps.tsx
    "use client";
    
    import type { ComponentProps, ReactNode } from "react";
    import { motion, useReducedMotion } from "motion/react";
    import { cn } from "@/lib/utils";
    import { ShimmerText } from "@/components/ui/shimmer-text/shimmer-text";
    import { SpinnerCheck, type SpinnerStatus } from "@/components/ui/spinner-check/spinner-check";
    
    export type AgentStepStatus = "pending" | "running" | "done" | "error";
    
    export interface AgentStep {
      /** Stable identity. */
      id: string;
      /** What the step does, e.g. "Reading 4 files". */
      title: string;
      /** Detail under the title, e.g. a file name or result. */
      description?: ReactNode;
      /** Where the step is. */
      status: AgentStepStatus;
    }
    
    export interface AgentStepsProps
      extends Omit<ComponentProps<"ol">, "children" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> {
      /** Steps in order. */
      steps: AgentStep[];
    }
    
    const ICON: Record<AgentStepStatus, SpinnerStatus> = { pending: "idle", running: "loading", done: "success", error: "error" };
    const SPOKEN: Record<AgentStepStatus, string> = { pending: "pending", running: "in progress", done: "completed", error: "failed" };
    
    export function AgentSteps({ steps, className, "aria-label": ariaLabel = "Progress", ...props }: AgentStepsProps) {
      const reduceMotion = useReducedMotion();
      return (
        <ol aria-label={ariaLabel} className={cn("relative", className)} {...props}>
          {steps.map((step, i) => {
            const last = i === steps.length - 1;
            return (
              <li key={step.id} aria-current={step.status === "running" ? "step" : undefined} className="relative flex gap-3 pb-5 last:pb-0">
                {/* The connector to the next step fills in once this step is done. */}
                {!last ? (
                  <span aria-hidden="true" className="absolute top-6 bottom-1 left-[9px] w-0.5 overflow-hidden rounded-full bg-border">
                    <motion.span
                      className="block size-full origin-top bg-success"
                      initial={false}
                      animate={{ scaleY: step.status === "done" ? 1 : 0 }}
                      transition={reduceMotion ? { duration: 0 } : { duration: 0.45, ease: [0.22, 1, 0.36, 1], delay: 0.15 }}
                    />
                  </span>
                ) : null}
                <SpinnerCheck aria-hidden="true" role="presentation" status={ICON[step.status]} size={20} className="mt-px text-muted-foreground" />
                <div className="min-w-0 flex-1">
                  <p
                    className={cn(
                      "text-sm font-medium",
                      step.status === "pending" && "text-muted-foreground",
                      step.status === "done" && "text-foreground",
                      step.status === "error" && "text-destructive-text",
                    )}
                  >
                    {step.status === "running" ? <ShimmerText>{step.title}</ShimmerText> : step.title}
                    <span className="sr-only">, {SPOKEN[step.status]}</span>
                  </p>
                  {step.description ? (
                    <div className="mt-0.5 text-xs leading-5 text-muted-foreground">{step.description}</div>
                  ) : null}
                </div>
              </li>
            );
          })}
        </ol>
      );
    }

Usage

import { AgentSteps } from "@/components/ui/agent-steps/agent-steps";

<AgentSteps
  steps={[
    { id: "read", title: "Reading the issue", status: "done" },
    { id: "fix", title: "Writing a fix", status: "running", description: "pricing/total.ts" },
    { id: "test", title: "Running the tests", status: "pending" },
  ]}
/>

API reference

PropTypeDefaultDescription
steps*AgentStep[]–Steps in order.

Also accepts every native <ol> attribute, including ref and className.

  • Each step's icon moves from a hollow ring to a spinner to a drawn check (or a cross), and the connector to the next step fills once it's done.
  • The running step's title shimmers. Built from Spinner to Check and Shimmer Text, installed along with it.

Accessibility

  • An ordered list; the running step has aria-current="step" and every step's status is spelled out for screen readers (completed, in progress, pending, failed).
  • With reduced motion enabled, connectors fill instantly and checks appear without drawing.