A glass orb of drifting theme-colored light that shows whether the assistant is idle, listening, thinking or speaking, and swells with a live voice level.
"use client"
import * as React from "react"
import { AiOrb, type OrbState } from "@/components/ballmac/ai-orb"
const states: OrbState[] = ["idle", "listening", "thinking", "speaking"]
export default function AiOrbDemo() {
const [state, setState] = React.useState<OrbState>("thinking")
return (
<div className="flex w-full max-w-md flex-col items-center gap-6">
<AiOrb size="xl" state={state} level={state === "speaking" ? 0.35 : 0} />
<div role="group" aria-label="Orb state" className="inline-flex gap-1 rounded-lg bg-muted p-1">
{states.map((s) => (
<button
key={s}
type="button"
aria-pressed={state === s}
onClick={() => setState(s)}
className="h-8 rounded-md px-3 text-[13px] font-medium text-muted-foreground capitalize outline-none transition-colors hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-pressed:bg-background aria-pressed:text-foreground aria-pressed:shadow-xs"
>
{s}
</button>
))}
</div>
</div>
)
}Installation
$ pnpm dlx shadcn@latest add @ballmac/ai-orbInstall the dependencies.
$ pnpm add motion@^12 class-variance-authorityCopy the source into your project.
components/ballmac/ai-orb.tsx// Ballmac UI: AI Orb. https://ui.ballmac.com/components/ai-orb "use client" import * as React from "react" import { cva, type VariantProps } from "class-variance-authority" import { motion, useReducedMotion } from "motion/react" import { cn } from "@/lib/utils" const orbVariants = cva("relative isolate inline-block shrink-0 rounded-full", { variants: { size: { sm: "size-8", default: "size-16", lg: "size-28", xl: "size-44", }, }, defaultVariants: { size: "default" }, }) type OrbState = "idle" | "listening" | "thinking" | "speaking" type AiOrbProps = Omit<React.ComponentProps<"div">, "children"> & VariantProps<typeof orbVariants> & { /** What the assistant is doing. Each state changes how fast the colors drift and how the orb breathes. */ state?: OrbState /** Live voice level from 0 to 1. While listening or speaking the orb swells with it. */ level?: number /** Accessible name. Defaults to a sentence built from `state`. Pass `null` when text next to the orb already says it, which hides the orb from assistive tech. */ label?: string | null } const speeds: Record<OrbState, number> = { idle: 26, listening: 16, thinking: 6, speaking: 10 } const breathing: Record<OrbState, { scale: number[]; duration: number }> = { idle: { scale: [1, 1.03, 1], duration: 5 }, listening: { scale: [1, 1.05, 1], duration: 2.4 }, thinking: { scale: [1, 0.96, 1], duration: 1.6 }, speaking: { scale: [1, 1.07, 0.98, 1], duration: 1.3 }, } const stateNames: Record<OrbState, string> = { idle: "ready", listening: "listening", thinking: "thinking", speaking: "speaking", } function AiOrb({ state = "idle", level = 0, label, size, className, style, ...props }: AiOrbProps) { const reduce = useReducedMotion() const clamped = Math.min(1, Math.max(0, Number.isFinite(level) ? level : 0)) const live = state === "listening" || state === "speaking" const swell = live ? 1 + clamped * 0.14 : 1 const name = label === undefined ? `Assistant is ${stateNames[state]}` : label const spin = speeds[state] const breath = breathing[state] return ( <div data-slot="ai-orb" data-state={state} role={name === null ? undefined : "img"} aria-label={name ?? undefined} aria-hidden={name === null ? true : undefined} className={cn(orbVariants({ size }), className)} style={style} {...props} > {/* Soft halo that follows the same swell. */} <motion.span aria-hidden="true" className="pointer-events-none absolute -inset-[18%] -z-10 rounded-full bg-[radial-gradient(closest-side,color-mix(in_oklab,var(--chart-1)_45%,transparent),transparent)] opacity-70 blur-xl dark:opacity-60" animate={{ scale: reduce ? 1 : swell * 1.05 }} transition={{ type: "spring", stiffness: 160, damping: 18 }} /> <motion.div aria-hidden="true" className="absolute inset-0 overflow-hidden rounded-full bg-background shadow-[inset_0_0_0_1px_rgb(255_255_255/0.22),inset_0_-8px_18px_-6px_rgb(0_0_0/0.25),0_6px_18px_-8px_rgb(0_0_0/0.35)]" animate={reduce ? { scale: 1 } : { scale: breath.scale.map((v) => v * swell) }} transition={ reduce ? undefined : live && clamped > 0 ? { type: "spring", stiffness: 220, damping: 16 } : { duration: breath.duration, repeat: Infinity, ease: "easeInOut" } } > <motion.div className="absolute -inset-[30%]" animate={reduce ? undefined : { rotate: 360 }} transition={reduce ? undefined : { duration: spin, repeat: Infinity, ease: "linear" }} > <span className="absolute top-[12%] start-[10%] size-[62%] rounded-full bg-chart-1 opacity-90 blur-[14px]" /> <span className="absolute end-[6%] bottom-[8%] size-[58%] rounded-full bg-chart-2 opacity-90 blur-[14px]" /> <span className="absolute top-[38%] start-[34%] size-[48%] rounded-full bg-chart-4 opacity-80 blur-[12px]" /> </motion.div> <motion.div className="absolute -inset-[30%]" animate={reduce ? undefined : { rotate: -360 }} transition={reduce ? undefined : { duration: spin * 1.4, repeat: Infinity, ease: "linear" }} > <span className="absolute top-[6%] end-[18%] size-[40%] rounded-full bg-chart-5 opacity-70 blur-[12px]" /> <span className="absolute bottom-[14%] start-[16%] size-[36%] rounded-full bg-chart-3 opacity-70 blur-[12px]" /> </motion.div> {/* Glass highlight. */} <span className="absolute inset-0 rounded-full bg-[radial-gradient(120%_120%_at_30%_14%,rgb(255_255_255/0.55),transparent_46%)]" /> <span className="absolute inset-[6%] rounded-full bg-[radial-gradient(closest-side,transparent_68%,rgb(255_255_255/0.16))]" /> </motion.div> </div> ) } export { AiOrb, orbVariants, type AiOrbProps, type OrbState }Update the import paths to match your project setup.
Usage
import { AiOrb } from "@/components/ballmac/ai-orb"The full example is in the Code tab above.
Examples
Reacting to voice level
Listening…
"use client"
import * as React from "react"
import { AiOrb } from "@/components/ballmac/ai-orb"
/** Stand-in for a microphone: a smooth, speech-like level. Swap in useMicrophoneLevel from voice-input. */
function useFakeVoice(active: boolean) {
const [level, setLevel] = React.useState(0)
React.useEffect(() => {
if (!active) {
setLevel(0)
return
}
let t = 0
const id = setInterval(() => {
t += 0.35
setLevel(Math.max(0, Math.min(1, 0.45 + Math.sin(t) * 0.3 + Math.sin(t * 2.7) * 0.2)))
}, 80)
return () => clearInterval(id)
}, [active])
return level
}
export default function AiOrbVoice() {
const [on, setOn] = React.useState(true)
const level = useFakeVoice(on)
return (
<div className="flex w-full max-w-sm flex-col items-center gap-5">
<AiOrb size="lg" state={on ? "listening" : "idle"} level={level} />
<p className="text-sm text-muted-foreground" aria-live="polite">
{on ? "Listening…" : "Tap to talk"}
</p>
<button
type="button"
onClick={() => setOn((v) => !v)}
className="h-9 rounded-md border bg-background px-4 text-sm font-medium shadow-xs outline-none hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-ring/50"
>
{on ? "Stop" : "Start talking"}
</button>
</div>
)
}API reference
| Prop | Type | Default |
|---|---|---|
stateWhat the assistant is doing. Each state changes how fast the colors drift and how the orb breathes. | OrbState | "idle" |
levelLive voice level from 0 to 1. While listening or speaking the orb swells with it. | number | 0 |
labelAccessible name. Defaults to a sentence built from `state`. Pass `null` when text next to the orb already says it, which hides the orb from assistive tech. | string | null | — |
size | "sm" | "default" | "lg" | "xl" | "default" |
Also accepts the standard attributes of its root element.
Accessibility
| Key | Action |
|---|---|
| Screen readers | role=img named 'Assistant is listening' and so on. Pass label={null} to hide it when text beside it says the same |
| Reduced motion | Static orb, no drift or breathing |
Use with AI
state is idle | listening | thinking | speaking. Pass level (0 to 1) to make it swell with audio. size is sm | default | lg | xl. Colors come from chart-1 to chart-5, so it follows the theme. With the shadcn MCP server set up (guide), ask your agent:
Add the Ballmac UI AI Orb (@ballmac/ai-orb) to this project with the shadcn MCP, then use it where it fits.
Use it for
- The face of a voice assistant or a full-screen chat
- A status mark beside an assistant's name
Not for
- A plain spinner (use spinner)
- Anything that must be readable as text; pair it with thinking-indicator
Registry JSON: https://ui.ballmac.com/r/ai-orb.json
Credits
Free to use in personal and commercial projects.
- Registry
- shadcn/utils
Pairs well with
Voice Input
A microphone control as a round button or an expanding recorder with a scrolling waveform, timer, discard and send, plus a hook that reads the real microphone level.
Thinking Indicator
A waiting indicator for assistants with four animations (dots, bars, wave, shimmer), labels that take turns, an optional timer and one calm announcement for screen readers.
AI Chat
The layout for a chat UI: a message log that sticks to the bottom while replies stream unless the reader scrolls up, a jump-to-latest button, an empty state with suggestions and a footer.
Agent Plan
A live task plan for agents: a vertical timeline with pending, running, done, failed and skipped steps, nested substeps, expandable output, segmented progress and retry on failure.