Agent Loading States
Three loading states for AI interfaces: shimmering status text, live agent progress, and cycling reasoning phrases.
Thinking...
Indexing files
Reading the codebase
import { AgentProgress, ReasoningPhases, ShimmerText } from "@/components/motion/agent-loading-states";
const PHASES = ["Reading the codebase", "Searching for related files", "Drafting a response"];
export function AgentLoadingStatesPreview() {
return (
<div className="flex w-full max-w-xs flex-col gap-5 rounded-2xl bg-background p-4 shadow-[0_0_0_1px_var(--border)]">
<ShimmerText>Thinking...</ShimmerText>
<AgentProgress label="Indexing files" value={64} />
<ReasoningPhases phases={PHASES} />
</div>
);
}
"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>
);
}
Installation
$ bunx --bun shadcn add @easeui/agent-loading-states
- 1
Set up the theme tokens
Do this once per project. Follow the theme setup or skip it if you already ran shadcn init.
- 2
Install the dependencies
terminal npm install clsx motion tailwind-merge - 3
Add the source files
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)) }
API reference
| Component | Prop | Type | Default | Description |
|---|---|---|---|---|
| ShimmerText | className? | string | - | - |
| AgentProgress | label | string | - | - |
| AgentProgress | value? | number | - | 0 to 100. Omitted, the bar runs indeterminate. |
| AgentProgress | className? | string | - | - |
| ReasoningPhases | phases | {} | - | - |
| ReasoningPhases | interval? | number | 2200 | Milliseconds each phrase stays before crossfading to the next. Default 2200. |
| ReasoningPhases | className? | string | - | - |
Updated