shadimage
Kokonut UI
componentai-loadingsource: ok · refreshed

AI State Loading

Animated AI loading state cycling task statuses through a scrolling code-style log with an SVG progress spinner. Built with React and Tailwind CSS.

npx shadcn add "https://kokonutui.com/r/ai-loading.json"
free

Where it goes

stats

Visual style

terminal-mono

Interaction

animated-counter

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
animationmotion

Files1 · source cached 10/8/2026, 4:08:17 AM

/components/kokonutui/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-gray-300 dark:bg-gray-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-gray-200 dark:stroke-gray-700"
                cx="8"
                cy="8"
                r="6.25"
                strokeWidth="1.5"
              />
              <path
                className="stroke-gray-700 dark:stroke-gray-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-gray-700 dark:text-gray-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-gray-500 dark:text-gray-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-gray-300 tabular-nums dark:text-gray-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-gray-400 dark:text-gray-500",
  running: "text-gray-900 dark:text-gray-100",
  done: "text-gray-500 dark:text-gray-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-gray-200 dark:bg-gray-800"
        >
          <span
            className={cn(
              "block h-full w-full origin-top bg-gray-400 transition-transform duration-500 ease-[cubic-bezier(0.23,1,0.32,1)] dark:bg-gray-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-gray-400 text-xs tabular-nums dark:text-gray-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.
  const [isOpen, setIsOpen] = useState(true);
  useEffect(() => {
    setIsOpen(!(isDone && collapseWhenDone));
  }, [isDone, collapseWhenDone]);

  let title = "Getting ready";
  if (failed) {
    title = `Stopped at: ${failed.label}`;
  } else if (isDone) {
    title = `Worked for ${formatDuration(seconds)}`;
  } 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-gray-600 text-sm outline-hidden focus-visible:ring-2 focus-visible:ring-gray-400/40 dark:text-gray-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-gray-400 tabular-nums dark:text-gray-500">
                {formatDuration(seconds)}
              </span>
            )}
            {isDone && (
              <span className="text-gray-400 dark:text-gray-500">
                · {steps.length} steps
              </span>
            )}
            <ChevronDown
              aria-hidden="true"
              className={cn(
                "ml-auto size-4 text-gray-400 transition-transform duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] group-hover/header:text-gray-600 dark:group-hover/header:text-gray-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>
  );
}

More from Kokonut UI

Browse all components in this library →