shadimage
easeui
componentagent-loading-statessource: ok · hit

Agent Loading States

Three loading states for AI interfaces: shimmering status text, live agent progress, and cycling reasoning phrases.

npx shadcn add "https://easeui.dev/r/agent-loading-states.json"
free

Where it goes

stats

Visual style

3d

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

Files3 · source cached 10/14/2026, 2:05:46 PM

components/motion/agent-loading-states.tsx
"use client";
// easeui.dev/components/agents/agent-loading-states

import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { type ReactNode, useEffect, useState } from "react";
import { Progress } from "@/components/motion/progress";
import { cn } from "@/lib/utils";

const EASE = [0.23, 1, 0.32, 1] as const;

export interface ShimmerTextProps {
  children: ReactNode;
  className?: string;
}

/** Status text with a bright band sweeping across it, for a quiet "still working" signal. */
export function ShimmerText({ children, className }: ShimmerTextProps) {
  const reduce = useReducedMotion();

  if (reduce) {
    return (
      <span className={cn("text-sm font-medium text-muted-foreground", className)}>
        {children}
      </span>
    );
  }

  return (
    <span
      className={cn(
        "inline-block animate-shimmer bg-[length:200%_100%] bg-clip-text text-sm font-medium text-transparent [-webkit-background-clip:text]",
        className,
      )}
      style={{
        backgroundImage:
          "linear-gradient(90deg, var(--muted-foreground) 30%, var(--foreground) 50%, var(--muted-foreground) 70%)",
      }}
    >
      {children}
    </span>
  );
}

export interface AgentProgressProps {
  label: string;
  /** 0 to 100. Omitted, the bar runs indeterminate. */
  value?: number;
  className?: string;
}

/** A labeled progress bar with a pulsing dot marking it as a live, running step. */
export function AgentProgress({ label, value, className }: AgentProgressProps) {
  return (
    <div className={cn("flex flex-col gap-1.5", className)}>
      <span className="flex items-center gap-2 text-sm text-foreground">
        <span className="relative flex h-2 w-2 shrink-0">
          <span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-accent opacity-75 motion-reduce:hidden" />
          <span className="relative inline-flex h-2 w-2 rounded-full bg-accent" />
        </span>
        {label}
      </span>
      <Progress value={value} />
    </div>
  );
}

export interface ReasoningPhasesProps {
  phases: string[];
  /** Milliseconds each phrase stays before crossfading to the next. Default 2200. */
  interval?: number;
  className?: string;
}

/** Cycles through short phrases, crossfading between them, to narrate an agent's steps. */
export function ReasoningPhases({ phases, interval = 2200, className }: ReasoningPhasesProps) {
  const [index, setIndex] = useState(0);
  const reduce = useReducedMotion();

  useEffect(() => {
    if (phases.length < 2) return;
    const timer = setInterval(() => setIndex((i) => (i + 1) % phases.length), interval);
    return () => clearInterval(timer);
  }, [phases.length, interval]);

  return (
    <div className={cn("text-sm text-muted-foreground", className)}>
      <AnimatePresence mode="wait">
        <motion.span
          key={index}
          initial={reduce ? false : { opacity: 0, y: 4 }}
          animate={{ opacity: 1, y: 0 }}
          exit={{ opacity: 0, y: -4, transition: { duration: reduce ? 0 : 0.1 } }}
          transition={{ duration: reduce ? 0 : 0.2, ease: EASE }}
          className="block"
        >
          {phases[index]}
        </motion.span>
      </AnimatePresence>
    </div>
  );
}
components/motion/progress.tsx
import { cn } from "@/lib/utils";

export interface ProgressProps {
  /** 0 to 100. Omitted, the bar runs indeterminate. */
  value?: number;
  /** Shows the percentage above the bar. Ignored when indeterminate. Default false. */
  showValue?: boolean;
  className?: string;
}

/** A progress bar that fills with scale, not width, so it stays cheap to animate. */
export function Progress({ value, showValue = false, className }: ProgressProps) {
  const clamped = value === undefined ? undefined : Math.min(100, Math.max(0, value));

  return (
    <div className={cn("flex flex-col gap-1.5", className)}>
      {showValue && clamped !== undefined ? (
        <span className="text-right text-xs tabular-nums text-muted-foreground">
          {Math.round(clamped)}%
        </span>
      ) : null}
      <div
        role="progressbar"
        aria-valuenow={clamped}
        aria-valuemin={0}
        aria-valuemax={100}
        className="h-2 w-full overflow-hidden rounded-full bg-muted shadow-[0_0_0_1px_var(--border)]"
      >
        <div
          className={cn(
            "h-full w-full origin-left rounded-full bg-foreground",
            clamped === undefined
              ? "scale-x-[0.4] animate-progress-indeterminate motion-reduce:animate-none"
              : "transition-transform duration-300 ease-out motion-reduce:transition-none",
          )}
          style={clamped === undefined ? undefined : { transform: `scaleX(${clamped / 100})` }}
        />
      </div>
    </div>
  );
}
lib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

More from easeui

Browse all components in this library →