free128 lines · framer-motion
Agent Status
Who's working, who's stuck, who needs you. An avatar stack of agents with status rings (idle, thinking, working, needs you, error), counts by state, and a detail line on hover.
- Use it for
- App headers, team views, sidebars.
- Themes
- Light + dark
- License
- Commercial use (draft)
Live
- +2
2 working1 needs you1 error
Hover an agent to see what it is doing.
tsx
"use client";
import * as React from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
import { cn } from "@/lib/utils";
export type AgentState = "idle" | "thinking" | "working" | "waiting" | "error";
export interface AgentInfo {
id: string;
name: string;
state: AgentState;
task?: string;
}
export interface AgentStatusProps {
agents?: AgentInfo[];
/** Avatars shown before collapsing into +N. */
max?: number;
/** Cycle states for demos. */
autoplay?: boolean;
size?: "sm" | "md" | "lg";
className?: string;
}
const STATE: Record<AgentState, { label: string; dot: string; ring: string; text: string }> = {
idle: { label: "Idle", dot: "bg-muted-foreground/50", ring: "ring-border", text: "text-muted-foreground" },
thinking: { label: "Thinking", dot: "bg-info", ring: "ring-info", text: "text-info" },
working: { label: "Working", dot: "bg-ok", ring: "ring-ok", text: "text-ok" },
waiting: { label: "Needs you", dot: "bg-signal", ring: "ring-signal", text: "text-signal" },
error: { label: "Error", dot: "bg-destructive", ring: "ring-destructive", text: "text-destructive" },
};
const DEFAULT: AgentInfo[] = [
{ id: "a", name: "triage", state: "working", task: "Labelling 14 issues" },
{ id: "b", name: "coder", state: "waiting", task: "PR #482 needs approval" },
{ id: "c", name: "research", state: "thinking", task: "Reading Stripe docs" },
{ id: "d", name: "support", state: "working", task: "Replying to ticket 9921" },
{ id: "e", name: "deploy", state: "idle" },
{ id: "f", name: "finance", state: "idle" },
{ id: "g", name: "security", state: "error", task: "Scanner timed out" },
];
const ORDER: AgentState[] = ["idle", "thinking", "working", "waiting", "working", "idle"];
const SIZES = { sm: "size-8 text-[10px]", md: "size-10 text-[11px]", lg: "size-12 text-xs" };
export function AgentStatus({ agents: initial = DEFAULT, max = 5, autoplay = true, size = "md", className }: AgentStatusProps) {
const reduce = useReducedMotion();
const [agents, setAgents] = React.useState(initial);
const [open, setOpen] = React.useState<string | null>(null);
React.useEffect(() => setAgents(initial), [initial]);
React.useEffect(() => {
if (!autoplay) return;
let i = 0;
const t = setInterval(() => {
i++;
setAgents((as) => as.map((a, k) => (k === i % as.length && a.state !== "error" ? { ...a, state: ORDER[(ORDER.indexOf(a.state) + 1) % ORDER.length] } : a)));
}, 1600);
return () => clearInterval(t);
}, [autoplay]);
const shown = agents.slice(0, max);
const rest = agents.length - shown.length;
const counts = agents.reduce<Record<string, number>>((m, a) => ((m[a.state] = (m[a.state] ?? 0) + 1), m), {});
const active = agents.find((a) => a.id === open);
return (
<div className={cn("inline-flex w-full max-w-md flex-col gap-3 rounded-2xl border border-border bg-card p-4 text-card-foreground", className)}>
<div className="flex items-center gap-4">
<ul className="flex -space-x-2" aria-label="Agents">
{shown.map((a) => {
const s = STATE[a.state];
return (
<li key={a.id} className="relative">
<button
type="button"
onClick={() => setOpen((o) => (o === a.id ? null : a.id))}
onMouseEnter={() => setOpen(a.id)}
aria-label={`${a.name}: ${s.label}${a.task ? `, ${a.task}` : ""}`}
aria-expanded={open === a.id}
className={cn("relative grid place-items-center rounded-full bg-primary font-mono font-bold uppercase text-primary-foreground ring-[3px] ring-offset-2 ring-offset-card transition-transform hover:z-10 hover:-translate-y-0.5", SIZES[size], s.ring)}
>
{a.name.slice(0, 2)}
<span className={cn("absolute -bottom-0.5 -right-0.5 size-3 rounded-full border-2 border-card", s.dot)} />
{!reduce && (a.state === "thinking" || a.state === "waiting") && (
<motion.span className={cn("absolute inset-0 rounded-full ring-2", s.ring)} animate={{ scale: [1, 1.35], opacity: [0.7, 0] }} transition={{ repeat: Infinity, duration: 1.5 }} aria-hidden />
)}
</button>
</li>
);
})}
{rest > 0 && (
<li className={cn("grid place-items-center rounded-full bg-muted font-mono font-bold text-muted-foreground ring-[3px] ring-card", SIZES[size])} aria-label={`${rest} more agents`}>
+{rest}
</li>
)}
</ul>
<div className="ml-auto flex flex-wrap justify-end gap-1.5" aria-live="polite">
{(["working", "waiting", "error"] as AgentState[]).filter((k) => counts[k]).map((k) => (
<span key={k} className={cn("inline-flex items-center gap-1.5 rounded-full bg-muted px-2 py-0.5 font-mono text-[10.5px] font-semibold", STATE[k].text)}>
<span className={cn("size-1.5 rounded-full", STATE[k].dot)} />
{counts[k]} {STATE[k].label.toLowerCase()}
</span>
))}
</div>
</div>
<div className="min-h-[2.75rem]">
<AnimatePresence mode="wait" initial={false}>
{active ? (
<motion.div key={active.id + active.state} initial={reduce ? false : { opacity: 0, y: 4 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="flex items-center gap-3 rounded-xl bg-muted px-3 py-2">
<span className="font-mono text-[12px] font-semibold">{active.name}</span>
<span className={cn("font-mono text-[10.5px] font-bold uppercase tracking-wider", STATE[active.state].text)}>{STATE[active.state].label}</span>
<span className="truncate text-[12.5px] text-muted-foreground">{active.task ?? "No task"}</span>
</motion.div>
) : (
<motion.p key="hint" initial={false} animate={{ opacity: 1 }} className="px-1 py-2 text-[12.5px] text-muted-foreground">
Hover an agent to see what it is doing.
</motion.p>
)}
</AnimatePresence>
</div>
</div>
);
}
export default AgentStatus;
How it behaves
- →Avatars overlap; each has a coloured ring and a status dot. Thinking and needs-you rings pulse.
- →Beyond max avatars, a +N chip collapses the rest.
- →Summary chips count working / needs you / error.
- →Hover or click an avatar to show its current task underneath.
Accessibility
- ✓Each avatar is a button whose label includes name, state and task.
- ✓Counts are aria-live=polite.
- ✓Honours prefers-reduced-motion.