An agent run status pill whose working state pulses one soft ring and rolls status verbs vertically, blur-swapping to a check or alert when the run settles.
npx shadcn@latest add @paragon/agent-statusAlso installs: pulse-ring
"use client";
import * as React from "react";
import {
AnimatePresence,
motion,
useInView,
useReducedMotion,
} from "motion/react";
import { Check, CircleAlert } from "lucide-react";
import { PulseRing } from "@/registry/paragon/ui/pulse-ring";
import { cn } from "@/lib/utils";
/** Mirrors var(--ease-out). */
const EASE_OUT: [number, number, number, number] = [0.22, 1, 0.36, 1];
/** Mirrors var(--ease-exit). */
const EASE_EXIT: [number, number, number, number] = [0.4, 0, 1, 1];
export type AgentStatusState = "idle" | "working" | "done" | "error";
export interface AgentStatusProps
extends Omit<
React.ComponentProps<"div">,
"onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart"
> {
state?: AgentStatusState;
/** Status verbs rolled through while working. */
verbs?: string[];
/** Seconds each verb holds before rolling to the next. */
hold?: number;
idleLabel?: string;
doneLabel?: string;
errorLabel?: string;
/** Disables rolling, pulsing, and swap motion. */
static?: boolean;
}
/**
* Agent run status pill. The working state shows one soft pulse ring while
* status verbs roll vertically — a 120ms roll after each 2s hold — then the
* indicator blur-swaps to a check (or alert) when the run settles. The verb
* column is sized by its longest entry so the pill never jitters mid-roll;
* state changes animate the pill's width via layout.
*/
export function AgentStatus({
state = "idle",
verbs = ["Analyzing sources", "Drafting response", "Running checks"],
hold = 2,
idleLabel = "Standing by",
doneLabel = "Completed",
errorLabel = "Needs attention",
static: isStatic = false,
className,
...props
}: AgentStatusProps) {
const ref = React.useRef<HTMLDivElement>(null);
const inView = useInView(ref);
const reducedMotion = useReducedMotion();
const [verbIndex, setVerbIndex] = React.useState(0);
// Roll verbs only while working and on screen; reset on each new run.
React.useEffect(() => {
if (state !== "working") {
setVerbIndex(0);
return;
}
if (isStatic || verbs.length < 2 || !inView) return;
const id = setInterval(() => {
if (document.hidden) return;
setVerbIndex((i) => (i + 1) % verbs.length);
}, hold * 1000);
return () => clearInterval(id);
}, [state, verbs.length, hold, inView, isStatic]);
const animate = !isStatic && !reducedMotion;
const blur = animate ? "blur(4px)" : "blur(0px)";
const label =
state === "idle"
? idleLabel
: state === "done"
? doneLabel
: state === "error"
? errorLabel
: (verbs[verbIndex % verbs.length] ?? "Working");
return (
<motion.div
ref={ref}
data-slot="agent-status"
role="status"
layout={animate}
transition={{ type: "spring", duration: 0.35, bounce: 0 }}
className={cn(
"inline-flex h-8 items-center gap-2 rounded-full bg-card pr-3.5 pl-3 text-[13px] text-card-foreground shadow-border",
className,
)}
{...props}
>
<motion.span
layout={animate ? "position" : false}
className="flex size-4 shrink-0 items-center justify-center"
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={state === "working" ? "working" : state}
className="flex items-center justify-center"
initial={{ opacity: 0, scale: 0.25, filter: blur }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.25, filter: blur }}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
>
{state === "working" ? (
<PulseRing
size={7}
rings={1}
duration={2}
scale={2.4}
static={isStatic}
className="text-primary"
/>
) : state === "done" ? (
<Check className="size-3.5 text-emerald-600 dark:text-emerald-500" />
) : state === "error" ? (
<CircleAlert className="size-3.5 text-destructive" />
) : (
<span className="size-[7px] rounded-full bg-muted-foreground/40" />
)}
</motion.span>
</AnimatePresence>
</motion.span>
{state === "working" ? (
<motion.span
layout={animate ? "position" : false}
className="inline-grid items-baseline overflow-hidden"
>
{/* Longest verb sizes the cell so rolls never resize the pill. */}
{verbs.map((verb) => (
<span
key={verb}
aria-hidden
className="invisible whitespace-nowrap [grid-area:1/1]"
>
{verb}
</span>
))}
<AnimatePresence initial={false}>
<motion.span
key={verbIndex}
className="whitespace-nowrap [grid-area:1/1]"
initial={{ opacity: 0, y: animate ? 10 : 0 }}
animate={{
opacity: 1,
y: 0,
transition: { duration: 0.12, ease: EASE_OUT },
}}
exit={{
opacity: 0,
y: animate ? -10 : 0,
transition: { duration: 0.12, ease: EASE_EXIT },
}}
>
{label}
</motion.span>
</AnimatePresence>
</motion.span>
) : (
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={state}
layout={animate ? "position" : false}
className="whitespace-nowrap"
initial={{ opacity: 0, filter: blur }}
animate={{ opacity: 1, filter: "blur(0px)" }}
exit={{
opacity: 0,
filter: blur,
transition: { duration: 0.15, ease: EASE_EXIT },
}}
transition={{ duration: 0.25, ease: EASE_OUT }}
>
{label}
</motion.span>
</AnimatePresence>
)}
</motion.div>
);
}