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 · mediumanimationmotion
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))
}