beui
componentagent-progresssource: ok · refreshed
Agent Loading States Agent Progress
A compact activity glyph, action verb, and live tabular timer for longer-running agent work.
npx shadcn add "https://beui.dev/r/agent-progress.json"free
Where it goes
stats
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/8/2026, 9:19:51 AM
components/agents/loading-states/agent-progress.tsx
"use client";
// beui.dev/components/agents/loading-states
import { motion, useReducedMotion } from "motion/react";
import { useEffect, useState } from "react";
import { EASE_IN_OUT } from "@/lib/ease";
import { cn } from "@/lib/utils";
const GRID_CELLS = [
{ id: "top-left", delay: 0 },
{ id: "top-center", delay: 0.14 },
{ id: "top-right", delay: 0.28 },
{ id: "middle-left", delay: 0.42 },
{ id: "middle-center", delay: 0.56 },
{ id: "middle-right", delay: 0.7 },
{ id: "bottom-left", delay: 0.84 },
{ id: "bottom-center", delay: 0.98 },
{ id: "bottom-right", delay: 1.12 },
];
export interface AgentProgressProps {
/** Verb describing the agent's current activity. */
label?: string;
/** Controlled elapsed time in seconds. */
elapsedSeconds?: number;
/** Starting time for the internal timer, in seconds. */
initialSeconds?: number;
/** Whether the internal timer should advance. Ignored when elapsedSeconds is provided. */
running?: boolean;
className?: string;
}
function formatElapsed(totalSeconds: number) {
const safeSeconds = Math.max(0, totalSeconds);
const minutes = Math.floor(safeSeconds / 60);
const seconds = (safeSeconds % 60).toFixed(1);
return minutes > 0 ? `${minutes}m ${seconds}s` : `${seconds}s`;
}
export function AgentProgress({
label = "Churning",
elapsedSeconds,
initialSeconds = 0,
running = true,
className,
}: AgentProgressProps) {
const reduce = useReducedMotion() ?? false;
const [internalSeconds, setInternalSeconds] = useState(initialSeconds);
useEffect(() => {
if (elapsedSeconds !== undefined || !running) return;
const startedAt = performance.now() - initialSeconds * 1000;
const timer = window.setInterval(() => {
setInternalSeconds((performance.now() - startedAt) / 1000);
}, 100);
return () => window.clearInterval(timer);
}, [elapsedSeconds, initialSeconds, running]);
const elapsed = elapsedSeconds ?? internalSeconds;
return (
<span
role="status"
aria-label={`${label}, in progress`}
className={cn(
"inline-flex items-center gap-3 font-mono text-sm text-muted-foreground",
className,
)}
>
<span
aria-hidden="true"
className="grid size-5 shrink-0 grid-cols-3 gap-[2px]"
>
{GRID_CELLS.map(({ id, delay }) => (
<motion.span
key={id}
className="rounded-[1px] bg-current"
animate={
reduce
? { opacity: [0.35, 0.8, 0.35] }
: {
opacity: [0.28, 1, 0.28],
scale: [0.72, 1, 0.72],
}
}
transition={{
duration: 1.55,
ease: EASE_IN_OUT,
repeat: Infinity,
delay,
}}
/>
))}
</span>
<span className="font-sans font-medium">{label}</span>
<span
aria-hidden="true"
className="tabular-nums text-muted-foreground/70"
>
{formatElapsed(elapsed)}
</span>
</span>
);
}
lib/ease.ts
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
lib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}