Installation
npx shadcn@latest add @pixeldosa/agent-presenceRequires `cn` at `@/lib/utils`, @pixeldosa/agent-figure for the `figure` and `mark` forms (installed automatically), and the `pd-orb-spin` / `pd-cell-pulse` keyframes supplied by @pixeldosa/pixeldosa-theme's css block. The state vocabulary maps directly onto AG-UI events, OpenAI Responses lifecycle events, AI SDK stream parts, LangGraph interrupts and MCP elicitation — see the docs page for the mapping table.
Usage
"use client";
import { AgentPresence } from "@/components/ui/agent-presence";
export function AgentBar({ run }: { run: AgentRun }) {
return (
<AgentPresence
state={run.state}
label={run.tool ? `Searching ${run.tool}` : undefined}
form="line"
size="sm"
onCancel={run.abort}
/>
);
}
Features
- Motion means the machine is busy. Stillness means it's your turn. Machine states animate; hand-off states stop dead and sit brighter. It's learnable in about three interactions, after which nobody reads the label to know whether they're needed.
- Rotation speed is the state — 24s per revolution when queued, 4s when working. The same object communicates urgency through tempo, which is what lets states morph into each other rather than cut between separate animations.
- The vocabulary is taken, not invented — every state maps to a real protocol event. See the mapping below.
- Reduced motion loses nothing. Each state holds a visually distinct static configuration, so the grammar still works with every keyframe disabled.
Variants
The machine's turn — these move
Your turn — these hold perfectly still
The inversion: every other library pulses the thing that wants attention. Here the thing that wants attention is the only thing not moving.
Terminal states
Figure and mark
The agent, drawn. Each state takes an Agent Figure pose, and the poses for your turn hold
still. mark is the head alone, for inline use.
Orb (deprecated)
form="orb" still renders, so existing screens keep working, but it will be removed in
a later drop. A glowing sphere reads as a mind rather than as work. For hero surfaces,
empty states and agent panels, use Agent Figure, which draws the agent and shows what
it's doing.
Line
For status bars, table rows, and anywhere the agent is a footnote rather than the subject.
Mapping from your agent stack
The state vocabulary is deliberately protocol-shaped, so adopting it is a mapping rather than a rewrite. The component itself takes no AI dependency — you own the stream, you map its events to a state.
| PixelDosa UI state | AG-UI | OpenAI Responses | AI SDK | LangGraph / MCP |
|---|---|---|---|---|
queued | — | queued | — | — |
thinking | — | reasoning delta | reasoning part | — |
deciding | TOOL_CALL_START → TOOL_CALL_ARGS | function call args delta | tool-call streaming | — |
working | TOOL_CALL_END → awaiting result | tool call in progress | tool-call awaiting result | — |
streaming | TEXT_MESSAGE_CONTENT | output_text.delta | text part | — |
done | RUN_FINISHED | completed | finish | — |
failed | RUN_ERROR | failed | error | — |
asking | — | — | — | MCP elicitation |
awaitingApproval | — | — | — | LangGraph interrupt() |
A minimal AI SDK adapter is about this long:
function toAgentState(status: string, parts: UIMessagePart[]): AgentState {
if (status === "submitted") return "queued";
const last = parts.at(-1);
if (last?.type === "reasoning") return "thinking";
if (last?.type?.startsWith("tool-")) return "working";
if (status === "streaming") return "streaming";
if (status === "error") return "failed";
return "done";
}
Examples
Naming the tool matters — working alone is a spinner with extra steps:
<AgentPresence state="working" label={`Reading ${file.name}`} onCancel={run.abort} />
Indicator only, where the surrounding UI already says what's happening. The label is still announced to screen readers:
<AgentPresence state="thinking" form="line" size="sm" hideLabel />
Props
| Prop | Type | Default | Description |
|---|---|---|---|
state | AgentState | — | Required. One of the eleven states above. |
form | "figure" | "mark" | "field" | "line" | "orb" | "field" | figure and mark draw the agent with Agent Figure. orb is deprecated. |
size | "sm" | "default" | "lg" | "default" | |
label | string | per state | Override the default. For working, name the tool. |
hideLabel | boolean | false | Visually hides the label; still announced. |
onCancel | () => void | — | Shown throughout the machine's turn. |
Accessibility
- The label is the real signal, in an
aria-live="polite"region. The indicator isaria-hiddenand supplementary — never the only way state is communicated. - Hand-off states are distinguished by weight and colour token as well as stillness, so the grammar doesn't depend on perceiving motion.
- Stop is a real keyboard-reachable
<button>with its hit area extended to ~44px while staying visually compact. - Under
prefers-reduced-motionevery keyframe is disabled and each state still holds a distinct static configuration — the same information without the movement.
When not to use
The reasoning text is Reasoning Stream. Per-artifact generation progress is
Generation Placeholder. The post-result action strip is AI Action Toolbar. This
component says what the agent is doing — not what it produced, or what you can do
about it.
why it's built this way
Engineering Notes
The load-bearing decision is that the eleven states split into two classes with opposite visual treatments rather than into eleven variations of one treatment. Every surveyed implementation renders 'thinking' and 'waiting for your approval' in the same register — something moving plus a line of text — but those are opposites: one means relax, the other means nothing proceeds until you act. Encoding that as motion-versus-stillness makes the most important question on the surface ('is it my turn?') answerable pre-attentively. The vocabulary itself is taken from protocol events rather than invented: `deciding` exists because AG-UI streams TOOL_CALL_ARGS before execution, so choosing an action is distinguishable from performing it; `queued` exists because OpenAI Responses emits it and accepted-but-not-started is not the same as working; `asking` maps to MCP elicitation and `awaitingApproval` to LangGraph's interrupt(). The orb uses a Fibonacci spiral distribution rather than the flat ring of dots most AI orbs use, which is what makes it read as a volume rather than a circle, and it is rendered with CSS 3D transforms inside a preserve-3d container so perspective supplies depth scaling for free — no per-frame JavaScript and no canvas. All three forms animate via CSS keyframes with per-element negative delays rather than a requestAnimationFrame loop; an earlier component in this system was rebuilt for exactly this reason, since re-rendering hundreds of React nodes per frame to nudge a transform is what makes these indicators stutter.
how it moves, and when it doesn't
Motion Notes
Rotation speed is the state: 24s per revolution when queued, 12s thinking, 7s deciding, 4s working — so the same object communicates urgency through tempo rather than through a different animation per state, which is what lets states morph rather than cut. Terminal machine states (`done`, `failed`, `cancelled`) drop rotation entirely and settle to a smaller resting scale. Hand-off states stop dead: no keyframe is applied at all, and the dots transition to full scale and full opacity over `--pd-duration-slow`, so the arrival of the user's turn is a settling rather than a switch. Only `transform` and `opacity` animate, so every form composites on the GPU regardless of point count. Under `prefers-reduced-motion` every keyframe is disabled and each state still holds a visually distinct static configuration — differing scale and opacity per state — so the grammar survives with zero movement; the reduced-motion state is the same information without the motion, not a removal of the signal.