A colour-coded step tracker for orders, deployments and onboarding, still at rest and animated only when a step advances.
Agent Hive
A honeycomb of frosted glass with one tinted tile that travels to whatever you pick, an action, and the queue of runs it dispatches.
AgentHive is the console pattern every AI product ends up drawing: choose an engine, send it something, watch the work come back. The comb is glass. Every cell is a frosted hexagon with a lit top edge and a shaded bottom one, and behind them sits a soft wash of the selected model's own colour — so the light in the material belongs to the selection, and moving the selection moves the light. The selected cell is not a state each cell paints for itself but a single tinted tile that slides over the frosting, stretching along its own direction of travel and settling out of it, with a plumb line swinging above. The comb underneath is a radio group wearing a shape, filled from the middle outwards, so four models sit in the centre of a ten-cell hive and the leftovers stay as comb around the rim. Below it the action takes the selected accent and pushes a ring out every couple of seconds for exactly as long as something is running. The queue is yours: onGenerate hands you the model that was picked, you push a row, and the component types out any run that arrives after mount while leaving the rows that were already there alone.
Preview
The comb is frosted glass over a wash of the selected model's colour, and one tinted tile slides between the cells — stretching along its own direction of travel, with the plumb line swinging after it. Press the action and a run drops in, types itself out and reports back.
Prism Flash
Drafts and rewrites
- Rewrite the onboarding copy for the empty dashboardDone
- Trace the failing checkout webhook back to its retryFailed
size="sm"A tighter comb and every run state at once. Nothing types here — these rows were on screen at mount, and history does not retype itself.
Swift
- Regenerate the API referenceWorking
- Diff the schema against stagingQueued
Installation
Install Agent Hive with the shadcn CLI. The registry item resolves its own dependencies and writes the file to components/joinui/agent-hive.tsx.
pnpm dlx shadcn@latest add @joinui/agent-hiveUsage
import { AgentHive, type AgentHiveRun } from "@/components/joinui/agent-hive"
import { Asterisk, Atom, Gem, Sparkles } from "lucide-react"
const models = [
{ id: "aster", label: "Aster 3", icon: <Asterisk />, accent: "#c2410c" },
{ id: "prism", label: "Prism Flash", icon: <Sparkles />, accent: "#4338ca" },
{ id: "nimbus", label: "Nimbus 2", icon: <Atom />, accent: "#0f766e" },
{ id: "quartz", label: "Quartz Mini", icon: <Gem />, accent: "#15803d" },
]
export function Example() {
const [runs, setRuns] = React.useState<AgentHiveRun[]>([])
return (
<AgentHive
models={models}
runs={runs}
onGenerate={(model) =>
setRuns((current) => [
{
id: crypto.randomUUID(),
prompt: "Identify code optimizations and performance improvements",
modelId: model.id,
state: "working",
},
...current,
])
}
/>
)
}Props
AgentHive
| Prop | Type | Default | Description |
|---|---|---|---|
| models (required) | AgentHiveModel[] | — | The models to lay into the comb. They fill it from the middle outwards, so the order is the order they radiate, not a row-by-row layout. |
| value | string | — | Controlled selection, by model id. Leave unset to let the component hold it and read the selection through onValueChange. |
| defaultValue | string | — | Where an uncontrolled comb starts. Defaults to the first enabled model. Pass value="" to start with nothing selected. |
| onValueChange | (id: string) => void | — | Fires with the model id when the selection moves. |
| runs | AgentHiveRun[] | [] | The queue below the action, newest first. It is state you own, so it can come from anywhere — a websocket, a poll, a reducer. |
| onGenerate | (model: AgentHiveModel) => void | — | Fires with the selected model when the action is pressed. Push a run onto runs from here. |
| actionLabel | string | "Generate" | Copy on the action. |
| busy | boolean | — | Overrides the busy state, which is otherwise true whenever a run is working. Busy is what sets the halo breathing and aria-busy on the frame. |
| disabled | boolean | false | Disables the whole comb and the action. |
| comb | number[] | [3, 4, 3] | Widths of the comb's rows, top to bottom. Rows are centred on each other, which is where the half-cell offset comes from. More models than cells and the comb grows by alternating its own widest and narrowest row. |
| size | "sm" | "md" | "md" | Cell, action, row and type scale. |
| arm | boolean | true | The plumb line hanging over the selected cell. It tracks the cell's horizontal centre, so it can point down the corridor between two cells of the row below, and it swings on its own travelling velocity — a hop to the next cell tilts it, a jump across the comb throws it. |
| maxRuns | number | 3 | Rows drawn before the queue fades out. The overflow row dissolves into a mask rather than being cut off. |
| typing | boolean | true | Type each arriving run out a character at a time. Runs present at mount are treated as history and never type. |
| typeSpeed | number | 26 | Milliseconds per character. Floored at 4. |
| emptyLabel | string | "Nothing queued." | Shown in place of the queue while there is nothing in it. |
| label | string | "Model" | Accessible name of the comb. |
| variant | "card" | "plain" | "card" | Plain drops the surrounding rule, background and padding so the hive can sit inside your own container. |
| className | string | — | Merged onto the root through `cn` — this is where the width goes, since the size only sets a max. |
AgentHiveModel
Shape of a single entry in the `models` array.
| Prop | Type | Default | Description |
|---|---|---|---|
| id (required) | string | — | Stable identity, and the value reported by onValueChange. |
| label (required) | string | — | The model's name. Printed under the comb, and the accessible name of its cell. |
| description | string | — | Second line under the comb while the model holds the selection. |
| icon | React.ReactNode | — | Drawn inside the cell. Sized by the component, so pass a bare icon element — or a brand mark as inline SVG. |
| accent | string | — | Any CSS colour. Tints the glass tile while the model holds the selection, lights the wash behind the comb, colours its glyph while it does not, carries into the action, and marks every run the model produced. Omit it and the component falls back to its ink tokens. |
| disabled | boolean | — | Draws the cell at half strength and takes it out of both the tab order and the arrow-key walk. |
AgentHiveRun
Shape of a single entry in the `runs` array.
| Prop | Type | Default | Description |
|---|---|---|---|
| id (required) | string | — | Stable identity for the rendered row, and what the component keys typing off — reuse an id and the row is treated as the same run. |
| prompt (required) | string | — | The line of work. Typed out when the run arrives after mount. |
| state | "queued" | "working" | "done" | "failed" | "queued" | Picks the pip and the word beside it: grey for queued, amber and pulsing for working, green for done, red for failed. |
| status | string | — | Overrides the status word — “Retrying”, “Queued · 3rd”, an elapsed time — without changing the hue. |
| modelId | string | — | Ties the run to a model, which marks the row with that model's cell in miniature. Leave it out and the marker stays neutral. |
Dependencies
npm packages
- motion
Registry items
- utils
Installed automatically by the CLI when they are missing.
Accessibility
- The comb is a real radio group: `role="radiogroup"` on the frame, `role="radio"` with `aria-checked` on every occupied cell, and a roving tabindex — so a nine-model hive costs one tab stop and the arrows move inside it.
- Cells are glyph-only, so each carries its model name as `aria-label`, and the name is also printed under the comb where a sighted user can read it. The plumb line and the fill are decoration on top of that, never the only cue.
- Empty comb, the travelling fill and the plumb line are all `aria-hidden` and non-interactive; they are scenery, and they are not announced or focusable.
- The focus ring is drawn as a hexagon rather than left as the default outline, which would trace the cell's bounding box instead of the cell.
- The selected cell is the only filled one, so the selection survives a monochrome rendering, a forced-colours mode and any accent a consumer picks — fill and position carry it, not hue and not translucency.
- The material is decoration and degrades to a flat tint: a browser without `backdrop-filter` still gets a frosted wash, a lit edge and the tinted tile, because none of the three depends on the blur.
- Run states never rest on hue alone — every row prints its status in words next to the pip, and `status` overrides that word rather than removing it.
- The queue is an ordered list with `aria-live="polite"`, so a run arriving or landing is announced without stealing focus.
- A typing row renders its prompt twice: the full text, invisible but in the accessibility tree, under the prefix typed so far, which is `aria-hidden`. Assistive technology reads the whole line at once instead of a character at a time, and the row reserves its final height so nothing below it reflows while it types.
- The only continuous motion is the ring leaving the action and the pip on a working run, and both stop when the queue does; `prefers-reduced-motion` drops the typewriter, the caret blink, the ring, the swing and every spring, leaving a still component that still works.
- Both themes are covered by the tokens rather than by `dark:` variants, so the component keeps its contrast inside a forced-theme subtree.
Keyboard interactions
| Key | Behaviour |
|---|---|
| →↓ | Selects the next model in the comb, skipping disabled ones. |
| ←↑ | Selects the previous model in the comb. |
| Home | Selects the first model. |
| End | Selects the last model. |
| Tab | Moves from the comb to the action, and on out of the component. |
| EnterSpace | Fires the action. |
Customization
Reshape the comb
The comb prop is the row widths, top to bottom, and rows centre on each other — so [3, 4, 3] is a hexagon of hexagons, [1, 2, 1] is a diamond, and a single-element array is a straight row. Models always fill from the middle outwards, so the shape stays balanced whatever the count.
{/* The default: ten cells, four of them models. */}
<AgentHive models={models} comb={[3, 4, 3]} />
{/* A diamond for four. */}
<AgentHive models={models} comb={[1, 2, 1]} size="sm" />
{/* One row, no scenery. */}
<AgentHive models={models} comb={[4]} />Wire it to a real backend
onGenerate hands you the model and gets out of the way. Push a working row, resolve it, and let the states carry the rest — the halo, the pulsing pip and aria-busy all follow from the run's state without a second prop.
async function generate(model: AgentHiveModel) {
const id = crypto.randomUUID()
const prompt = draft.trim()
setRuns((current) => [{ id, prompt, modelId: model.id, state: "working" }, ...current])
try {
await dispatch({ model: model.id, prompt })
setRuns((current) =>
current.map((run) => (run.id === id ? { ...run, state: "done" } : run))
)
} catch (error) {
setRuns((current) =>
current.map((run) =>
run.id === id ? { ...run, state: "failed", status: "Retrying" } : run
)
)
}
}Bring your own marks
An icon is any node, so the cells can carry real brand marks as inline SVG rather than an icon-set approximation. The component sizes them and leaves the fill alone unless an accent is set, in which case the glyph takes the accent and the action does too.
const models = [
{
id: "orbit",
label: "Orbit",
icon: (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.75}>
<circle cx="12" cy="12" r="4" />
<ellipse cx="12" cy="12" rx="10" ry="4.5" transform="rotate(-28 12 12)" />
</svg>
),
accent: "#7c3aed",
},
]Give the glass something to refract
Frosting only has something to say when there is something behind it. On a flat panel the hive supplies its own light — the wash of the selected accent — but dropped onto a photograph, a gradient or a mesh, the cells sample it: the blur and the saturation boost are real, so the picture moves under the comb rather than beside it. Plain removes the frame so the surface can be yours.
<div className="relative overflow-hidden rounded-3xl p-6">
<img src="/aurora.jpg" alt="" className="absolute inset-0 size-full object-cover" />
<AgentHive variant="plain" models={models} runs={runs} className="relative" />
</div>Just the picker, or just the queue
The three bands are independent. Drop runs and the queue collapses to its empty line; set maxRuns to keep a long queue from taking the page over. Plain removes the frame so the hive can sit inside a card of your own.
{/* A picker in a settings panel. */}
<div className="rounded-xl border border-border p-6">
<AgentHive
variant="plain"
models={models}
comb={[2, 3, 2]}
size="sm"
actionLabel="Set default"
emptyLabel="No recent runs."
/>
</div>
{/* A busy console: six rows in flight, three of them on screen. */}
<AgentHive models={models} runs={runs} maxRuns={3} />Slow the typewriter down, or turn it off
Typing is per run and happens once: a row that was on screen at mount is history and renders whole, and a row that arrives afterwards types itself out. Reduced motion turns it off on its own, so typing={false} is for when a queue is filling faster than anything can be read.
{/* Deliberate, terminal-paced. */}
<AgentHive models={models} runs={runs} typeSpeed={45} />
{/* Straight to the text. */}
<AgentHive models={models} runs={runs} typing={false} />Tune the material
The glass is four numbers, and all four are custom properties rather than props, because they are a decision about the surface the component was installed onto rather than about any one hive. The frosting is a wash of the theme's own ink, which is what lets a single value serve both themes; the glow is the one exception, since the same 45% that reads as light on paper reads as a whisper on charcoal, so a product that lives in one theme should say so. The glyph on the tinted tile is near-white by default — if your accents are pale, redeclare it once instead of threading a second colour through every model.
/* app/globals.css */
:root {
/* The frosting, and the emptier frosting of a cell with no model in it. */
--agent-hive-glass: color-mix(in oklab, var(--foreground) 7%, transparent);
--agent-hive-glass-empty: color-mix(in oklab, var(--foreground) 4%, transparent);
/* How brightly the selected accent lights the comb from behind. */
--agent-hive-glow: 0.45;
/* The glyph on the tinted tile. */
--agent-hive-glyph: oklch(0.985 0.002 90);
}
.dark {
--agent-hive-glow: 0.55;
}Related components
A coil of pills that keeps one item in focus and lets the rest twist away, turned either by a timer or by the page scroll.