New: Activity Terrain +12 more

AI State Loading

Agent progress as a list of steps with a live log, elapsed time and a summary when done.

1 exampleDepends onlucide-reactmotionFrom KokonutUI (MIT)

Installation

Run this in your project. It adds the files, installs the npm packages and anything this builds on, and sets up the theme:

npx shadcn@latest add https://kryptonite-ui.vercel.app/r/ai-loading.json
Install manually
  1. Install the dependencies:

    npm install lucide-react motion
  2. Copy this into components/kokonut/ai-loading.tsx:

    ai-loading.tsx
    "use client";
    
    /**
     * @author: @kokonutui
     * @description: AI Loading State
     * @version: 2.0.0
     * @date: 2026-10-14
     * @license: MIT
     * @website: https://kokonutui.com
     * @github: https://github.com/kokonut-labs/kokonutui
     */
    
    import { ChevronDown } from "lucide-react";
    import { AnimatePresence, MotionConfig, motion } from "motion/react";
    import { useEffect, useId, useRef, useState } from "react";
    import { cn } from "@/lib/utils";
    
    export type AgentStepStatus = "pending" | "running" | "done" | "error";
    
    export interface AgentStep {
      id: string;
      label: string;
      status: AgentStepStatus;
      /** Log lines for the step. The running step shows the latest ones. */
      detail?: string[];
    }
    
    interface AILoadingStateProps {
      /**
       * The agent's steps, in order. Update them as work progresses. When
       * omitted, a demo run plays on a loop.
       */
      steps?: AgentStep[];
      /** Collapse the list once every step is done. Defaults to true. */
      collapseWhenDone?: boolean;
      className?: string;
    }
    
    const EASE_OUT = [0.23, 1, 0.32, 1] as const;
    const LINE_HEIGHT = 24;
    const VISIBLE_LINES = 3;
    
    const DEMO_STEPS: Omit<AgentStep, "status">[] = [
      {
        id: "search",
        label: "Searching the web",
        detail: [
          "query: loading states in agent UIs",
          "visiting 5 websites",
          "reading emilkowal.ski/ui/great-animations",
          "reading developer.apple.com/design",
        ],
      },
      {
        id: "read",
        label: "Reading 4 sources",
        detail: [
          "extracting key passages",
          "dropping 1 duplicate source",
          "ranking by relevance",
        ],
      },
      {
        id: "compare",
        label: "Comparing approaches",
        detail: [
          "spinners vs. step lists",
          "progress you can verify",
          "checking motion preferences",
        ],
      },
      {
        id: "write",
        label: "Writing the summary",
        detail: ["drafting 3 sections"],
      },
    ];
    
    const DEMO_TICK_MS = 750;
    const DEMO_REST_MS = 3200;
    
    interface DemoPosition {
      step: number;
      line: number;
    }
    
    const advanceDemo = ({ step, line }: DemoPosition): DemoPosition => {
      if (step >= DEMO_STEPS.length) {
        return { step: 0, line: 0 };
      }
      const lineCount = DEMO_STEPS[step].detail?.length ?? 0;
      return line < lineCount
        ? { step, line: line + 1 }
        : { step: step + 1, line: 0 };
    };
    
    /** Advances through DEMO_STEPS one log line per tick, pausing offscreen. */
    function useDemoRun(
      enabled: boolean,
      rootRef: React.RefObject<HTMLElement | null>
    ) {
      const [position, setPosition] = useState<DemoPosition>({
        step: 0,
        line: 0,
      });
      const [isVisible, setIsVisible] = useState(true);
    
      useEffect(() => {
        const element = rootRef.current;
        if (!(enabled && element)) {
          return;
        }
        const observer = new IntersectionObserver(([entry]) =>
          setIsVisible(entry.isIntersecting)
        );
        observer.observe(element);
        return () => observer.disconnect();
      }, [enabled, rootRef]);
    
      const positionRef = useRef(position);
    
      useEffect(() => {
        if (!(enabled && isVisible)) {
          return;
        }
        let id: ReturnType<typeof setTimeout>;
        const schedule = () => {
          const finished = positionRef.current.step >= DEMO_STEPS.length;
          id = setTimeout(
            () => {
              positionRef.current = advanceDemo(positionRef.current);
              setPosition(positionRef.current);
              schedule();
            },
            finished ? DEMO_REST_MS : DEMO_TICK_MS
          );
        };
        schedule();
        return () => clearTimeout(id);
      }, [enabled, isVisible]);
    
      return DEMO_STEPS.map((step, i): AgentStep => {
        if (i < position.step) {
          return { ...step, status: "done" };
        }
        if (i === position.step) {
          return {
            ...step,
            status: "running",
            detail: step.detail?.slice(0, position.line),
          };
        }
        return { ...step, status: "pending" };
      });
    }
    
    function StepIcon({ status }: { status: AgentStepStatus }) {
      return (
        <span className="relative flex size-4 shrink-0 items-center justify-center">
          <AnimatePresence initial={false} mode="popLayout">
            <motion.span
              animate={{ opacity: 1, filter: "blur(0px)" }}
              className="absolute inset-0 flex items-center justify-center"
              exit={{ opacity: 0, filter: "blur(2px)" }}
              initial={{ opacity: 0, filter: "blur(2px)" }}
              key={status}
              transition={{ duration: 0.18, ease: EASE_OUT }}
            >
              {status === "pending" && (
                <span className="size-1.5 rounded-full bg-zinc-300 dark:bg-zinc-600" />
              )}
              {status === "running" && (
                <svg
                  aria-hidden="true"
                  className="size-3.5 motion-safe:animate-spin"
                  fill="none"
                  style={{ animationDuration: "0.8s" }}
                  viewBox="0 0 16 16"
                >
                  <circle
                    className="stroke-zinc-200 dark:stroke-zinc-700"
                    cx="8"
                    cy="8"
                    r="6.25"
                    strokeWidth="1.5"
                  />
                  <path
                    className="stroke-zinc-700 dark:stroke-zinc-200"
                    d="M8 1.75a6.25 6.25 0 0 1 6.25 6.25"
                    strokeLinecap="round"
                    strokeWidth="1.5"
                  />
                </svg>
              )}
              {status === "done" && (
                <svg
                  aria-hidden="true"
                  className="size-3.5 text-zinc-700 dark:text-zinc-200"
                  fill="none"
                  viewBox="0 0 16 16"
                >
                  {/* The check draws itself once, from the short stroke out. */}
                  <motion.path
                    animate={{ pathLength: 1 }}
                    d="M3.5 8.5 6.5 11.5 12.5 4.5"
                    initial={{ pathLength: 0 }}
                    stroke="currentColor"
                    strokeLinecap="round"
                    strokeLinejoin="round"
                    strokeWidth="1.75"
                    transition={{ duration: 0.3, ease: EASE_OUT, delay: 0.05 }}
                  />
                </svg>
              )}
              {status === "error" && (
                <svg
                  aria-hidden="true"
                  className="size-3.5 text-red-500"
                  fill="none"
                  viewBox="0 0 16 16"
                >
                  <path
                    d="M4.5 4.5l7 7m0-7-7 7"
                    stroke="currentColor"
                    strokeLinecap="round"
                    strokeWidth="1.75"
                  />
                </svg>
              )}
            </motion.span>
          </AnimatePresence>
        </span>
      );
    }
    
    /** The running step's latest log lines, scrolling up as new ones arrive. */
    function StepLog({ lines }: { lines: string[] }) {
      const offset = Math.max(0, lines.length - VISIBLE_LINES);
      return (
        <div
          aria-hidden="true"
          className="relative overflow-hidden font-mono text-xs"
          style={{
            height: LINE_HEIGHT * Math.min(lines.length, VISIBLE_LINES),
            maskImage:
              lines.length > VISIBLE_LINES - 1
                ? "linear-gradient(to bottom, rgb(0 0 0 / 0.25), black 70%)"
                : undefined,
            WebkitMaskImage:
              lines.length > VISIBLE_LINES - 1
                ? "linear-gradient(to bottom, rgb(0 0 0 / 0.25), black 70%)"
                : undefined,
            transition: "height 240ms cubic-bezier(0.23, 1, 0.32, 1)",
          }}
        >
          <div
            className="transition-transform duration-300 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none"
            style={{ transform: `translateY(${-offset * LINE_HEIGHT}px)` }}
          >
            {lines.map((line, index) => (
              <motion.div
                animate={{ opacity: 1 }}
                className="flex items-center gap-2 text-zinc-500 dark:text-zinc-400"
                initial={{ opacity: 0 }}
                key={`${index}-${line}`}
                style={{ height: LINE_HEIGHT }}
                transition={{ duration: 0.2, ease: EASE_OUT }}
              >
                <span className="w-4 select-none text-right text-zinc-300 tabular-nums dark:text-zinc-600">
                  {index + 1}
                </span>
                <span className="truncate">{line}</span>
              </motion.div>
            ))}
          </div>
        </div>
      );
    }
    
    const formatDuration = (seconds: number) =>
      seconds < 60
        ? `${seconds}s`
        : `${Math.floor(seconds / 60)}m ${(seconds % 60).toString().padStart(2, "0")}s`;
    
    /**
     * Seconds since the run started. Freezes while nothing runs, and restarts
     * when a step runs again after the whole list was done.
     */
    function useElapsed(active: boolean, done: boolean) {
      const [seconds, setSeconds] = useState(0);
      const startRef = useRef<number | null>(null);
      const finishedRef = useRef(false);
    
      useEffect(() => {
        if (done) {
          finishedRef.current = true;
        }
      }, [done]);
    
      useEffect(() => {
        if (!active) {
          return;
        }
        if (finishedRef.current) {
          finishedRef.current = false;
          startRef.current = null;
        }
        const start = startRef.current ?? Date.now();
        startRef.current = start;
        const tick = () => setSeconds(Math.floor((Date.now() - start) / 1000));
        tick();
        const id = setInterval(tick, 500);
        return () => clearInterval(id);
      }, [active]);
    
      return seconds;
    }
    
    // Same stylesheet as AI Text Loading, so the two share one copy on a page.
    const WAVE_CSS = `
    @keyframes kokonut-ai-wave { 50% { opacity: 0.3; } }
    .kokonut-ai-wave { animation: kokonut-ai-wave 1.6s ease-in-out infinite both; }
    @media (prefers-reduced-motion: reduce) { .kokonut-ai-wave { animation: none; } }
    `;
    
    /** A soft brightness wave through the running step's letters. */
    function WaveText({ text }: { text: string }) {
      return (
        <>
          <style href="kokonut-ai-wave" precedence="default">
            {WAVE_CSS}
          </style>
          {Array.from(text).map((char, i) => (
            <span
              className="kokonut-ai-wave whitespace-pre"
              // biome-ignore lint/suspicious/noArrayIndexKey: letters of one fixed label, never reordered
              key={i}
              style={{ animationDelay: `${i * 0.05}s` }}
            >
              {char}
            </span>
          ))}
        </>
      );
    }
    
    const formatStepTime = (ms: number) =>
      ms < 10_000 ? `${(ms / 1000).toFixed(1)}s` : `${Math.round(ms / 1000)}s`;
    
    /**
     * Times each step from when it starts running to when it ends, so callers
     * only pass statuses. A step back to pending clears its time.
     */
    function useStepTimes(steps: AgentStep[]) {
      const [times, setTimes] = useState<Record<string, number>>({});
      const startsRef = useRef<Record<string, number>>({});
    
      useEffect(() => {
        const now = Date.now();
        const starts = startsRef.current;
        const finished: Record<string, number> = {};
        let changed = false;
        for (const step of steps) {
          if (step.status === "pending") {
            delete starts[step.id];
            changed ||= step.id in times;
            continue;
          }
          // Running again after it had ended: a new run, so time it afresh.
          if (step.status === "running" && step.id in times) {
            starts[step.id] = now;
            changed = true;
          }
          starts[step.id] ??= now;
          if (step.status === "done" || step.status === "error") {
            finished[step.id] = times[step.id] ?? now - starts[step.id];
            changed ||= !(step.id in times);
          }
        }
        if (changed) {
          setTimes(finished);
        }
      }, [steps, times]);
    
      return times;
    }
    
    const LABEL_COLORS: Record<AgentStepStatus, string> = {
      pending: "text-zinc-400 dark:text-zinc-500",
      running: "text-zinc-900 dark:text-zinc-100",
      done: "text-zinc-500 dark:text-zinc-400",
      error: "text-red-600 dark:text-red-400",
    };
    
    function StepRow({
      step,
      time,
      isLast,
    }: {
      step: AgentStep;
      time: number | undefined;
      isLast: boolean;
    }) {
      return (
        <li className="relative flex gap-3 pt-3.5">
          {/* The rail between icons fills in as each step completes. */}
          {!isLast && (
            <span
              aria-hidden="true"
              className="absolute top-[34px] bottom-[-10px] left-[7.5px] w-px overflow-hidden bg-zinc-200 dark:bg-zinc-800"
            >
              <span
                className={cn(
                  "block h-full w-full origin-top bg-zinc-400 transition-transform duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] dark:bg-zinc-500",
                  step.status === "done" ? "scale-y-100" : "scale-y-0"
                )}
              />
            </span>
          )}
          <StepIcon status={step.status} />
          <div className="-mt-0.5 min-w-0 flex-1">
            <div className="flex items-baseline gap-2">
              <p
                className={cn(
                  "min-w-0 truncate text-sm transition-colors duration-200",
                  LABEL_COLORS[step.status]
                )}
              >
                {step.status === "running" ? (
                  <>
                    <span className="sr-only">{step.label}</span>
                    <span aria-hidden="true">
                      <WaveText text={step.label} />
                    </span>
                  </>
                ) : (
                  step.label
                )}
              </p>
              {time !== undefined && (
                <motion.span
                  animate={{ opacity: 1 }}
                  className="ml-auto shrink-0 text-xs text-zinc-400 tabular-nums dark:text-zinc-500"
                  initial={{ opacity: 0 }}
                  transition={{ duration: 0.2, ease: EASE_OUT }}
                >
                  {formatStepTime(time)}
                </motion.span>
              )}
            </div>
            {step.status === "running" && step.detail?.length ? (
              <div className="mt-1">
                <StepLog lines={step.detail} />
              </div>
            ) : null}
          </div>
        </li>
      );
    }
    
    export default function AILoadingState({
      steps: stepsProp,
      collapseWhenDone = true,
      className,
    }: AILoadingStateProps) {
      const rootRef = useRef<HTMLDivElement>(null);
      const demoSteps = useDemoRun(stepsProp === undefined, rootRef);
      const steps = stepsProp ?? demoSteps;
      const listId = useId();
    
      const running = steps.find((step) => step.status === "running");
      const failed = steps.find((step) => step.status === "error");
      const isDone =
        steps.length > 0 && steps.every((step) => step.status === "done");
      const isActive = Boolean(running);
      const seconds = useElapsed(isActive, isDone);
      const times = useStepTimes(steps);
    
      // Open while working; folds away once everything is done, until the user
      // opens it again.
      // Synced during render rather than in an effect, so a list that mounts
      // already done starts folded instead of playing a collapse.
      const shouldCollapse = isDone && collapseWhenDone;
      const [isOpen, setIsOpen] = useState(!shouldCollapse);
      const [wasCollapsed, setWasCollapsed] = useState(shouldCollapse);
      if (wasCollapsed !== shouldCollapse) {
        setWasCollapsed(shouldCollapse);
        setIsOpen(!shouldCollapse);
      }
    
      let title = "Getting ready";
      if (failed) {
        title = `Stopped at: ${failed.label}`;
      } else if (isDone) {
        // A run that was already done when it mounted (an older message) was
        // never timed here, so it doesn't claim "Worked for 0s".
        title = seconds > 0 ? `Worked for ${formatDuration(seconds)}` : "Done";
      } else if (running) {
        title = running.label;
      }
    
      return (
        <div
          className={cn("flex w-full items-center justify-center", className)}
          ref={rootRef}
        >
          <MotionConfig reducedMotion="user">
            <div className="w-full max-w-sm">
              <button
                aria-controls={listId}
                aria-expanded={isOpen}
                className="group/header flex w-full items-center gap-2 rounded-md py-1 text-left font-medium text-sm text-zinc-600 outline-hidden focus-visible:ring-2 focus-visible:ring-zinc-400/40 dark:text-zinc-300"
                onClick={() => setIsOpen((open) => !open)}
                type="button"
              >
                {/* <output> has the implicit role="status", so the current step
                    is announced as it changes. */}
                <output className="min-w-0 truncate">{title}</output>
                {isActive && (
                  <span className="text-zinc-400 tabular-nums dark:text-zinc-500">
                    {formatDuration(seconds)}
                  </span>
                )}
                {isDone && (
                  <span className="text-zinc-400 dark:text-zinc-500">
                    · {steps.length} steps
                  </span>
                )}
                <ChevronDown
                  aria-hidden="true"
                  className={cn(
                    "ml-auto size-4 text-zinc-400 transition-transform duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover/header:text-zinc-600 dark:group-hover/header:text-zinc-300",
                    isOpen && "rotate-180"
                  )}
                />
              </button>
    
              <AnimatePresence initial={false}>
                {isOpen && (
                  <motion.ol
                    animate={{ height: "auto", opacity: 1 }}
                    className="overflow-hidden"
                    exit={{ height: 0, opacity: 0 }}
                    id={listId}
                    initial={{ height: 0, opacity: 0 }}
                    transition={{ duration: 0.25, ease: EASE_OUT }}
                  >
                    {steps.map((step, i) => (
                      <StepRow
                        isLast={i === steps.length - 1}
                        key={step.id}
                        step={step}
                        time={times[step.id]}
                      />
                    ))}
                  </motion.ol>
                )}
              </AnimatePresence>
            </div>
          </MotionConfig>
        </div>
      );
    }

Feedback

Questions, bugs or ideas for AI State Loading? Post them here, or reply to someone else. Sign in with GitHub to comment.