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…

"use client"

import * as React from "react"
import { cn } from "@/lib/utils"

/* -------------------------------------------------------------------------- *
 * Keyframes
 *
 * They ship with the component through a React 19 hoisted <style> — no Tailwind
 * config edits, and duplicate instances dedupe by href.
 *
 * Every drift translate is a PERCENTAGE of the blob's own box and every blur is
 * a fraction of --zy-orb-size, so a 64px orb and a 248px orb are the same
 * picture at two zoom levels instead of two different-looking components.

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/voice-orb.json

Prompt

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 amplitudelevelRef lets 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: paused so 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.

On This Page