# Voice Orb (`voice-orb`)

> A voice-assistant orb of concentric wobbling rings — idle breathes, listening reacts to amplitude, thinking swirls, and speaking folds the rings into a rotating wireframe sphere.

- **Docs:** https://www.mellowui.com/components/voice-orb
- **Markdown:** https://www.mellowui.com/components/voice-orb.md
- **Registry:** https://www.mellowui.com/r/voice-orb.json
- **Tool prompt:** https://www.mellowui.com/api/prompt/voice-orb
- **Categories:** display, canvas, ai, animation
- **Dependencies:** none

## AI prompt

Add a VoiceOrb component from the mellow library — a canvas orb of concentric wobbling rings for voice-assistant UIs. Drive it with the `state` prop ('idle' | 'listening' | 'speaking' | 'thinking'): idle breathes slowly, listening swells with a simulated speech envelope, thinking swirls faster, and speaking morphs the flat rings into a slowly rotating wireframe sphere so the two voice states read differently at a glance. For real audio, pass `level` (0–1, e.g. from a Web Audio AnalyserNode) to override the simulated envelope. Colors follow the theme automatically; respects prefers-reduced-motion.

## Install

```bash
npx shadcn@latest add https://mellowui.com/r/voice-orb.json
```

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `state` | `"idle" \| "listening" \| "speaking" \| "thinking"` | `"idle"` | Conversation state — drives the orb's motion character. |
| `level` | `number` | — | External amplitude 0–1 (e.g. from an AnalyserNode). Overrides the simulated envelope. |
| `size` | `number` | `160` | Orb diameter in px. |
| `color` | `string` | `themed ink` | Ring color — defaults to the themed ink and flips with the theme. |
| `className` | `string` | — | Additional CSS classes. |

## Design notes

- Client component (`"use client"`) — required for animation and refs.
- Uses Mellow design tokens: `--ink`, `--ink-rgb`, `--background`, `--background-rgb`, `--rule`, `--font-sans`, `--font-serif`, `--font-mono`. Tokens auto-flip between light and dark themes — never hardcode colors.
- Respects `prefers-reduced-motion: reduce` where applicable.
- Self-contained — no shared utilities. Copy-paste, not a package.

## Source

```tsx
"use client";

import React, { useEffect, useRef, useState } from "react";

export type VoiceOrbState = "idle" | "listening" | "speaking" | "thinking";

export interface VoiceOrbProps {
  /** Conversation state — drives the orb's motion character. */
  state?: VoiceOrbState;
  /**
   * External amplitude 0–1 (e.g. from an AnalyserNode). When provided it
   * overrides the built-in simulated envelope for listening / speaking.
   */
  level?: number;
  /** Orb diameter in px. */
  size?: number;
  /** Ring color — defaults to the themed ink. */
  color?: string;
  className?: string;
  style?: React.CSSProperties;
}

/** Reads --ink-rgb at runtime and re-reads on theme change. */
function useInkRgb(): string {
  const [inkRgb, setInkRgb] = useState("235, 235, 228");
  useEffect(() => {
    const read = () => {
      const v = getComputedStyle(document.documentElement)
        .getPropertyValue("--ink-rgb")
        .trim();
      if (v) setInkRgb(v);
    };
    read();
    const observer = new MutationObserver(read);
    observer.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class", "data-theme"],
    });
    return () => observer.disconnect();
  }, []);
  return inkRgb;
}

/**
 * A voice-assistant orb — concentric rings that wobble and swell with the
 * voice. Idle breathes, listening reacts, thinking swirls — and while
 * speaking the rings fold into a slowly rotating wireframe sphere.
 */
export function VoiceOrb({
  state = "idle",
  level,
  size = 160,
  color,
  className,
  style,
}: VoiceOrbProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const stateRef = useRef<VoiceOrbState>(state);
  const levelRef = useRef<number | undefined>(level);
  stateRef.current = state;
  levelRef.current = level;
  const inkRgb = useInkRgb();

  useEffect(() => {
    if (!canvasRef.current) return;
    const canvas: HTMLCanvasElement = canvasRef.current;
    const ctxOrNull = canvas.getContext("2d");
    if (!ctxOrNull) return;
    const ctx: CanvasRenderingContext2D = ctxOrNull;

    const reduced = window.matchMedia(
      "(prefers-reduced-motion: reduce)"
    ).matches;
    const dpr = Math.min(window.devicePixelRatio || 1, 2);
    canvas.width = size * dpr;
    canvas.height = size * dpr;
    ctx.scale(dpr, dpr);

    const cx = size / 2;
    const cy = size / 2;
    const R = size * 0.42;
    const rings = [1, 0.84, 0.68, 0.53];
    const POINTS = 96;

    const wob = (a: number, t: number, seed: number) =>
      Math.sin(3 * a + t * 1.3 + seed * 7) * 0.5 +
      Math.sin(5 * a - t * 0.9 + seed * 3) * 0.3 +
      Math.sin(2 * a + t * 0.6 + seed) * 0.2;

    // Simulated speech envelope — syllable-ish bursts from summed sines.
    const envelope = (t: number, pulse: number) => {
      const raw =
        Math.sin(t * 4.1) * 0.5 +
        Math.sin(t * 7.3 + 1.7) * 0.3 +
        Math.sin(t * 1.9 + 0.4) * 0.2;
      return Math.max(0, raw) * pulse;
    };

    const strokeAlpha = [0.55, 0.38, 0.26, 0.16];

    // Latitude angles for the speaking-sphere — one band per ring, mirrored.
    const latitudes = [0, 0.42, -0.42, 0.84];
    const TILT = 0.42;

    /**
     * sph 0 → flat concentric rings; sph 1 → latitude bands of a rotating
     * wireframe sphere. Geometry is interpolated per point so the morph reads
     * as the rings folding into a globe.
     */
    const drawFrame = (t: number, amp: number, sph: number, spin: number) => {
      ctx.clearRect(0, 0, size, size);

      // Movement-reactive ambient glow — shared by flat rings and sphere.
      if (!color) {
        const glowR = R * (1.05 + amp * 0.18 + sph * 0.08);
        const grd = ctx.createRadialGradient(cx, cy, 0, cx, cy, glowR);
        const core = (0.035 + amp * 0.09) * (1 + sph * 0.35);
        grd.addColorStop(0, `rgba(${inkRgb}, ${core})`);
        grd.addColorStop(0.55, `rgba(${inkRgb}, ${core * 0.28})`);
        grd.addColorStop(1, `rgba(${inkRgb}, 0)`);
        ctx.fillStyle = grd;
        ctx.beginPath();
        ctx.arc(cx, cy, glowR, 0, Math.PI * 2);
        ctx.fill();
      }

      const cosT = Math.cos(TILT);
      const sinT = Math.sin(TILT);
      const bandPaths: { path: Path2D; ri: number; avgZ: number }[] = [];

      rings.forEach((ring, ri) => {
        const lat = latitudes[ri] * Math.PI * 0.5;
        const latR = Math.cos(lat);
        const latY = Math.sin(lat);
        const path = new Path2D();
        let depthSum = 0;
        for (let i = 0; i <= POINTS; i++) {
          const a = (i / POINTS) * Math.PI * 2;
          const w = wob(a, t + ri * 0.7, ri);
          const swell = 1 + w * (0.03 + amp * 0.2);

          const fr = R * ring * swell;
          const fx = Math.cos(a) * fr;
          const fy = Math.sin(a) * fr;

          const theta = a + spin;
          const sr = R * (0.94 + amp * 0.1) * swell;
          let sx = Math.cos(theta) * latR;
          let sy = latY;
          let sz = Math.sin(theta) * latR;
          const ry = sy * cosT - sz * sinT;
          const rz = sy * sinT + sz * cosT;
          sx *= sr;
          sy = ry * sr;
          sz = rz;
          depthSum += sz;

          const x = cx + fx + (sx - fx) * sph;
          const y = cy + fy + (sy - fy) * sph;
          if (i === 0) path.moveTo(x, y);
          else path.lineTo(x, y);
        }
        path.closePath();
        bandPaths.push({ path, ri, avgZ: depthSum / (POINTS + 1) });
      });

      // Inner wash — ring fill while flat, sphere core glow while morphed.
      if (!color) {
        const fillA = 0.03 + amp * 0.05;
        if (sph < 0.55) {
          ctx.fillStyle = `rgba(${inkRgb}, ${fillA * (1 - sph * 1.8)})`;
          ctx.fill(bandPaths[0].path);
        }
        if (sph > 0.2) {
          const coreGrd = ctx.createRadialGradient(
            cx,
            cy - sph * R * 0.1,
            0,
            cx,
            cy,
            R * (0.88 + amp * 0.12)
          );
          const a = fillA * (1.2 + sph * 1.4);
          coreGrd.addColorStop(0, `rgba(${inkRgb}, ${a})`);
          coreGrd.addColorStop(0.65, `rgba(${inkRgb}, ${a * 0.22})`);
          coreGrd.addColorStop(1, `rgba(${inkRgb}, 0)`);
          ctx.fillStyle = coreGrd;
          ctx.beginPath();
          ctx.arc(cx, cy, R * (0.88 + amp * 0.12), 0, Math.PI * 2);
          ctx.fill();
        }
      }

      bandPaths.forEach(({ path, ri, avgZ }) => {
        const frontness =
          sph < 0.05
            ? 1
            : 0.42 + 0.58 * Math.max(0, Math.min(1, (avgZ / (R * 0.38) + 1) * 0.5));
        const alpha = strokeAlpha[ri] * (0.7 + amp * 0.5) * frontness;
        ctx.strokeStyle = color ?? `rgba(${inkRgb}, ${alpha})`;
        ctx.lineWidth = ri === 0 ? 1.2 : 1;
        ctx.stroke(path);

        // Bright accent pass on front-facing bands while moving.
        if (!color && amp > 0.12 && frontness > 0.55) {
          ctx.save();
          ctx.shadowBlur = 6 + amp * 14 * (0.5 + sph * 0.5);
          ctx.shadowColor = `rgba(${inkRgb}, ${0.18 + amp * 0.42})`;
          ctx.strokeStyle = `rgba(${inkRgb}, ${alpha * (0.35 + amp * 0.45)})`;
          ctx.lineWidth = ri === 0 ? 1.4 : 1.1;
          ctx.stroke(path);
          ctx.restore();
        }
      });

      if (sph > 0.01) {
        const rimR = R * (0.94 + amp * 0.1);
        ctx.beginPath();
        ctx.arc(cx, cy, rimR, 0, Math.PI * 2);
        ctx.strokeStyle = color ?? `rgba(${inkRgb}, ${(0.22 + amp * 0.28) * sph})`;
        ctx.lineWidth = 1;
        ctx.stroke();
        if (!color && amp > 0.1) {
          ctx.save();
          ctx.shadowBlur = 10 + amp * 10;
          ctx.shadowColor = `rgba(${inkRgb}, ${0.15 + amp * 0.3})`;
          ctx.strokeStyle = `rgba(${inkRgb}, ${(0.12 + amp * 0.2) * sph})`;
          ctx.stroke();
          ctx.restore();
        }
      }
    };

    if (reduced) {
      drawFrame(0, 0.15, stateRef.current === "speaking" ? 1 : 0, 0);
      return;
    }

    let raf = 0;
    let t = 0;
    let amp = 0.1;
    let sph = stateRef.current === "speaking" ? 1 : 0;
    let spin = 0;
    let last = performance.now();

    const loop = (now: number) => {
      const dt = Math.min((now - last) / 1000, 0.05);
      last = now;

      const s = stateRef.current;
      const phaseSpeed =
        s === "thinking" ? 2.2 : s === "speaking" ? 1.2 : s === "listening" ? 1 : 0.5;
      t += dt * phaseSpeed;

      let target: number;
      if (s === "idle") target = 0.1 + 0.06 * Math.sin(t * 1.6);
      else if (s === "thinking") target = 0.18;
      else {
        const ext = levelRef.current;
        target =
          ext !== undefined
            ? Math.min(1, Math.max(0, ext))
            : envelope(now / 1000, s === "speaking" ? 0.9 : 0.7);
      }
      amp += (target - amp) * 0.09;

      // Fold into a sphere while speaking, unfold otherwise.
      sph += ((s === "speaking" ? 1 : 0) - sph) * Math.min(1, dt * 3.5);
      if (sph > 0.005) spin += dt * (0.5 + amp * 1.4);

      drawFrame(t, amp, sph, spin);
      raf = requestAnimationFrame(loop);
    };
    raf = requestAnimationFrame(loop);
    return () => cancelAnimationFrame(raf);
  }, [size, color, inkRgb]);

  return (
    <canvas
      ref={canvasRef}
      role="img"
      aria-label={`Voice assistant — ${state}`}
      className={className}
      style={{ width: size, height: size, ...style }}
    />
  );
}

export default VoiceOrb;

```

## Demo

```tsx
"use client";

import React, { useState } from "react";
import { VoiceOrb, type VoiceOrbState } from "../mellow/voice-orb";

const STATES: VoiceOrbState[] = ["idle", "listening", "speaking", "thinking"];

export default function VoiceOrbDemo() {
  const [state, setState] = useState<VoiceOrbState>("listening");

  return (
    <div className="flex flex-col items-center gap-8 p-8">
      <VoiceOrb state={state} size={180} />
      <div className="flex flex-wrap items-center justify-center gap-2">
        {STATES.map((s) => (
          <button
            key={s}
            type="button"
            onClick={() => setState(s)}
            aria-pressed={state === s}
            className={[
              "cursor-pointer border px-3 py-1.5 [font-family:var(--font-mono)] text-[0.625rem] font-medium tracking-[0.16em] uppercase transition-colors",
              state === s
                ? "border-[rgba(var(--ink-rgb),0.4)] bg-[rgba(var(--ink-rgb),0.08)] text-[var(--ink)]"
                : "border-[var(--rule)] text-[rgba(var(--ink-rgb),0.45)] hover:text-[var(--ink)]",
            ].join(" ")}
          >
            {s}
          </button>
        ))}
      </div>
      <p className="text-[0.8125rem] text-[rgba(var(--ink-rgb),0.35)]">
        Pass a live mic level to drive it with real audio
      </p>
    </div>
  );
}

```
