AI
Voice Orb
A voice-session orb — layered token gradients that breathe when idle, ripple to a 0..1 amplitude while listening, swirl while thinking and pulse while speaking.
Preview in your theme
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/voice-orb.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "VoiceOrb": the visual anchor of a voice
session. A stack of blurred gradient blobs breathes when idle, ripples to an
amplitude while listening, swirls while the model thinks and pulses while it
speaks. React plus a cn() class merger is the whole dependency list — no
animation library, no canvas, no SVG.
The component owns NO audio: no getUserMedia, no AudioContext, no <audio>
element, no permission prompt. Amplitude arrives as a number. That boundary is
the feature — the same orb works over WebRTC, a realtime websocket, a
server-side VAD, or with no amplitude at all.
Contract
- forwardRef<HTMLDivElement>, extends React.HTMLAttributes<HTMLDivElement> minus
children. The root spreads the remaining props, merges className through cn()
and exposes data-state for consumer styling.
- state?: "idle" | "listening" | "thinking" | "speaking" | "muted" (default
"idle"). There is no internal state machine: the session owner decides.
- level?: number — amplitude 0..1. Honoured in "listening" and "speaking" (mic
level in one, TTS output level in the other) and ignored in the rest. Clamp
out-of-range values, treat NaN / undefined as 0; never throw on a number a
meter produced.
- levelRef?: React.RefObject<number | null> — the same amplitude, read inside
the animation frame instead of from a prop, for callers polling an
AnalyserNode at 60fps. Wins over `level` when both are given.
- size?: "sm" | "md" | "lg" | "xl" | number (default "lg") -> 64 / 112 / 176 /
248px, or a raw pixel diameter (floor it at ~24px).
- intensity?: "subtle" | "medium" | "bold" (default "medium") — the alpha tier
used by every color-mix: 26% / 40% / 56%.
- reach?: number (default 0.16) — how far amplitude 1.0 pushes the orb out, as a
fraction of its radius. Clamp to 0..0.6.
- speed?: number (default 6) — seconds per idle breathing cycle; every other
cycle is derived from it.
- labels?: Partial<Record<State, string>> — overrides the wording, which is also
what screen readers hear. Defaults: Ready / Listening / Thinking / Speaking /
Microphone off.
- showLabel?: boolean (default false), announce?: boolean (default true),
announceDelay?: number (default 400).
Behavior — amplitude is smoothed, not mirrored
- A raw level mapped straight onto a transform twitches: a mic floor is never
digital silence, and a server VAD may publish only ten values a second. Run a
classic envelope follower instead — fast attack (tau 70ms) so a syllable lands
on the frame it arrives, slow release (tau 260ms) so the orb glides back down
between words rather than strobing.
- Integrate frame-rate independently: next = current + (goal - current) *
(1 - exp(-dt / tau)), with dt clamped to 100ms so a backgrounded tab does not
jump the orb on return. The same ramp then feels identical at 60Hz and 120Hz.
- Gate the noise floor first: <= 0.02 is silence, and everything above it is
rescaled by (v - gate) / (1 - gate) so the gate itself is never a visible step.
- Leaving a reactive state sets the goal to 0 and lets the RELEASE ramp carry
the orb down. Never snap the level to zero on a state change; a snap reads as
a glitch, a release reads as the assistant finishing its breath.
- The follower writes a CSS variable on the orb element (--zy-orb-level, three
decimals) instead of calling setState: sixty renders a second to move one
transform is the difference between a calm panel and a hot fan. React must
never set that variable itself or it would fight the imperative write — seed
the default with a [--zy-orb-level:0] class instead.
- Loop lifecycle: with a `level` prop, stop the rAF as soon as |goal - next| <
0.002 and pin the exact goal, so an idle voice panel costs zero frames; the
next prop change restarts it. With a `levelRef` the value cannot be diffed, so
the loop runs for the whole reactive phase. Cancel the frame on unmount and on
every dependency change.
Behavior — one state, four motions
- idle: the whole stack breathes (scale 1 -> 1.045) over `speed` seconds.
- listening: two expanding rings whose STRENGTH IS THE AMPLITUDE. Nest three
opacities so they multiply — the layer fades in with the state, an inner box
is opacity: calc(floor + level * gain), and the keyframe fades each ring out
as it grows to 1.6. With an amplitude source, floor 0.05 / gain 0.9 make
silence genuinely silent; with NO source (neither prop given) use floor 0.4 /
gain 0 so the ripple runs at a constant idle amplitude instead of miming a
level nobody supplied.
- thinking: a conic sweep turning inside the disc (clipped by an
overflow-hidden rounded-full wrapper) plus a small blurred comet riding the
rim on a slower turn. Motion with no progress claim — the model has not said
how far along it is, so nothing may look like a progress bar.
- speaking: a pulsing halo ring plus a soft outer glow on a second, offset beat.
It pulses with or without a level; amplitude only rides on top of it.
- muted: every animation gets animation-play-state: paused and the orb fades to
~55% with grayscale(0.9). Frozen, not hidden — the session is still open.
- Keep every layer mounted and cross-fade it (transition-opacity ~500ms) rather
than unmounting, so a state change never cuts; pause the animation of a hidden
layer so an invisible ring cannot keep the compositor busy, and let it resume
mid-cycle rather than restart from frame zero.
- Amplitude scaling and the state keyframes cannot share one element (an inline
transform and a keyframe transform overwrite each other): nest an amplitude
scaler around a breathing wrapper and let the two transforms compose.
Behavior — announcing the state
- The orb graphic is aria-hidden: it is decoration, and the state is a word.
- Render the wording twice, with different jobs: a visible span (aria-hidden,
updates instantly, matches the animation) and an sr-only span with
role="status" aria-live="polite" that lags by announceDelay. A voice session
flips states every couple of seconds; without the delay a screen reader
narrates every 120ms "thinking" blip. A zero delay is derived in render, not
through a timer.
- Clear the timer on state change and on unmount. A state that never held long
enough is simply never spoken.
- announce={false} keeps the sr-only text (so browse mode can still read the
state) but drops the live region — use it when the surrounding voice panel
already owns one.
Behavior — reduced motion
- Under prefers-reduced-motion the orb becomes a static layered gradient: every
keyframe is motion-reduce:[animation:none], the transitions are off, the rAF
loop never starts and the amplitude is pinned to 0.
- The label is then forced visible with `sr-only motion-reduce:not-sr-only`, so
the state the animation used to carry is carried by a word instead. The
component must never become ambiguous just because motion is off.
- Detect it with useSyncExternalStore over matchMedia (server snapshot false)
and remove the listener on unmount, so toggling the OS setting re-renders live.
Rendering & styling
- Semantic tokens only, no hex / rgb() / oklch(): var(--chart-1), var(--chart-2)
and var(--chart-4) for the three blobs, var(--primary) for the hot core,
border-primary/15 for the rim, text-muted-foreground for the label. Colors are
composed with color-mix(in oklab, <token> <alpha>, transparent), so light and
dark come free with the theme.
- Alias each hue through an overridable variable — --zy-orb-c1:
var(--zy-orb-hue-a, var(--chart-1)) — so a consumer can retarget the palette
from any ancestor without touching the source.
- Everything scales with the diameter: blurs are calc(var(--zy-orb-size) *
0.05..0.1), blob offsets are asymmetric percentage insets (never transforms,
so they survive animation:none), drift translations are percentages of each
blob's own box. A 64px orb and a 248px orb are the same picture at two zooms.
- Layer order, bottom to top: an outer bleed glow, three drifting blobs on
prime-ish cycles (1.7x / 2.3x / 2.9x the base, two with negative delays so
they never line up), a hot core in --primary, a hairline rim, then the
state-specific layers.
- Ship the keyframes with the component in a React 19 hoisted
<style href precedence> — no Tailwind config edit, and duplicate orbs on one
page dedupe by href.
Customization levers
- Palette: set --zy-orb-hue-a / -b / -c / -core on the orb or any ancestor to
move the gradient onto your brand tokens (e.g. --chart-3 / --chart-5 /
--chart-1). Never introduce a literal color; the token indirection is what
makes dark mode free.
- Loudness: `intensity` is the alpha tier of every mix (26 / 40 / 56%). Push it
to bold for a full-screen voice mode, subtle when the orb sits behind text.
- Physics: `reach` is how much a syllable moves the orb (0 for a completely
still orb that only ripples); ATTACK_MS / RELEASE_MS are the follower's feel —
raise the release to 400ms for a lazier orb, drop the attack to 40ms for a
snappier one, raise the gate for a noisy room.
- Tempo: `speed` scales everything, and the per-state multipliers (base 1 / 0.62
/ 0.78 / 0.5 / 1, effect 1 / 0.42 / 0.3 / 0.22 / 1) are the two tables to edit
if listening should feel more urgent than speaking.
- States: the layers are independent blocks — delete the comet, swap the two
ripple rings for one, or add a sixth state (e.g. "error") by adding a row to
the label map, to the two cycle tables and one layer.
- Labels & voice: `labels` localises the wording for both the visible text and
the announcement; announceDelay tunes how chatty the live region is;
showLabel keeps the word under the orb permanently.
- Size: the named tiers are a convenience; pass a number for a hero orb and
everything (blur, ripple, comet, rim) follows.
- The orb is deliberately not clickable. If you need a control, put it NEXT to
the orb (a mute button, a stop button) rather than wiring onClick onto a
graphic that has no button semantics.Concepts
- Envelope follower — amplitude is never mirrored onto a transform, it is chased: a fast attack (70ms) so a syllable lands on the frame it arrives, a slow release (260ms) so the orb glides down between words; the ramp is integrated against real frame time, so it feels the same at 60Hz and at 120Hz.
- Noise gate — everything at or below 0.02 counts as silence and the rest is rescaled, so room tone cannot keep the orb permanently inflated and the gate itself never shows up as a step.
- Render-free amplitude —
levelReflets a 60fps analyser drive the orb through a CSS variable written inside the animation frame, so React re-renders zero times and a panel with several orbs still idles at nothing. - Settled loop shutdown — a prop-driven orb cancels its own rAF once it is within 0.002 of the target and restarts on the next prop change; a page that is merely open burns no frames.
- Paused, not unmounted — every state layer stays in the tree and cross-fades; hidden layers get
animation-play-state: pausedso an invisible ring costs nothing, and they resume mid-cycle instead of snapping back to frame zero. - Held announcement — the visible word follows the state instantly while the live region waits 400ms, so a state the user never really saw is never spoken; under reduced motion that same word becomes the primary channel, because the animation that used to carry the state is gone.
Web Search Card
One web-search step inline in a conversation — the queries the model typed, the pages that came back, which ones it actually read and which ended up cited, with a data-driven reading shimmer and four data states.
Voice Chat
A realtime voice session panel — speaking orb, word-by-word live captions, mute, mic picker, connection meter, session clock, and an end-of-call summary.