{"slug":"voice-orb","category":"voice","job":"oversee","added":"2026-11-07T22:09Z","title":"Voice Orb","description":"A 3D particle sphere that breathes and spins with the voice conversation.","url":"https://ai-patterns.app/patterns/voice/voice-orb","markdown":"https://ai-patterns.app/patterns/voice/voice-orb.md","json":"https://ai-patterns.app/api/patterns/voice/voice-orb","seenIn":[{"team":"ElevenLabs","source":"https://ui.elevenlabs.io/docs/components/orb"},{"team":"chatgpt","source":"https://help.openai.com/en/articles/8400625-voice-mode"}],"spec":"# Voice Orb\n\n## Summary\nA slowly rotating 3D sphere of particles that stands in for the assistant during a voice conversation. It swells, ripples and spins with the live audio level, and changes its motion by turn — listening, thinking, speaking — so the whole screen reads as \"a presence you're talking to\" rather than a form you're filling in.\n\n## When to use\n- As the hero of a full-screen or panel-sized voice mode, where there's no text to read and the user needs one calm focal point that shows the conversation is alive.\n- When the interface has to communicate turn-taking at a glance: the orb's motion changes between the user's turn (listening), the gap (thinking) and the assistant's turn (speaking).\n- When brand presence matters in a voice product — the orb is the assistant's \"face\", and its tint and density can carry the brand.\n\n## When not to use\n- In a compact composer or a toolbar — the orb needs room to read as 3D. Use Voice Waveform or Listening State there instead.\n- When the user needs to monitor exact mic levels (dictation, recording, audio setup) — a waveform or meter is more legible than a breathing sphere.\n- On low-power or text-first screens where a continuously animating canvas costs battery for little information.\n\n## Anatomy\n- Sphere: several hundred particles spread evenly over a sphere's surface (golden-angle spiral), lightly varied in size so it reads as dust, not a grid.\n- Depth cue: particles on the near side are larger and brighter; those on the far side shrink and fade, which is what makes a flat canvas read as a ball.\n- Surface ripple: a slow travelling wave displaces each particle along its radius; its amplitude grows with the voice level.\n- Tint: one color (`currentColor`) that follows the theme — dark particles on a light surface, warm white on a dark one — and updates live when the theme changes. No gradients or per-state hues — state is carried by motion.\n\n## Behavior\n- `idle`: the sphere sits at rest size and turns very slowly, with almost no ripple — present but quiet.\n- `listening`: reacts to the user's mic level — it swells and ripples with each syllable.\n- `thinking`: the level is ignored and the sphere twists like a Rubik's cube — one third of it (a slice along x, y or z) turns 90° with an ease-in-out, pauses briefly, then another slice turns. The mechanical, step-by-step motion reads as \"working on it\" and is clearly different from the organic breathing of listening and speaking. A turn in progress always finishes before the orb leaves this state, so it never stops half-twisted.\n- `speaking`: reacts to the assistant's outgoing audio with the largest swell and ripple, so the assistant's turn feels more energetic than the user's.\n- The level is smoothed with a fast attack and slow release: the orb jumps with a syllable and settles gently instead of jittering.\n- Sample the level from a real source (an `AnalyserNode` on the mic or the playback element) and pass it as a ref, so the canvas reads it every frame without re-rendering the tree. Without a level, the orb runs a synthetic rhythm for prototypes — never mix the two.\n- State changes don't cut: rotation and swell carry over, so moving between turns feels continuous.\n\n## Content guidelines\n- The orb carries no text. Pair it with a short state caption (\"Listening…\", \"Thinking…\") or a Live Transcript when the user needs words.\n- Keep a visible, labeled control (mute, end call) near the orb — it's not a button itself.\n\n## Accessibility\n- The canvas has `role=\"img\"` and an `aria-label` naming the current state (\"Listening\", \"Speaking\"). Announce turn changes through a separate polite live region, not by relabeling the canvas alone.\n- Respect `prefers-reduced-motion`: stop the rotation, ripple and slice turns, and let only the size change gently with the level.\n- Don't rely on the orb alone to signal turn-taking — pair it with a text state or a speaker label.\n- Keep contrast between the particles and the backdrop high enough that the sphere's outline is visible at rest.\n\n## Related patterns\n- **Voice Waveform** — Use it instead where the orb doesn't fit and a compact, literal level display is enough.\n- **Listening State** — Use it instead in a composer, where a pill-sized mic-is-on indicator fits better.\n- **Live Transcript** — Use it as well below or beside the orb to show what's being said.\n- **Thinking Loader** — Use it instead in a text-only interface for the orb's thinking state.\n","code":"\"use client\";\n\nimport * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\n\nexport type VoiceOrbState = \"idle\" | \"listening\" | \"thinking\" | \"speaking\";\n\nexport interface VoiceOrbProps {\n  state: VoiceOrbState;\n  /**\n   * Live audio level (0-1), sampled by the caller from a real source\n   * (an AnalyserNode on the mic, or on the assistant's playback). Read on\n   * every frame, so pass a ref to avoid re-rendering 60 times a second.\n   * When omitted, the orb drives itself with a synthetic rhythm.\n   */\n  level?: React.RefObject<number> | number;\n  /** Number of particles on the sphere. */\n  particles?: number;\n  className?: string;\n}\n\nconst STATE_LABEL: Record<VoiceOrbState, string> = {\n  idle: \"Voice idle\",\n  listening: \"Listening\",\n  thinking: \"Thinking\",\n  speaking: \"Speaking\",\n};\n\n/** How much the sphere is allowed to swell and how fast it spins, per state. */\nconst STATE_MOTION: Record<VoiceOrbState, { gain: number; spin: number; ripple: number }> = {\n  idle: { gain: 0, spin: 0.12, ripple: 0.01 },\n  listening: { gain: 0.22, spin: 0.25, ripple: 0.05 },\n  thinking: { gain: 0, spin: 0.15, ripple: 0 },\n  speaking: { gain: 0.32, spin: 0.35, ripple: 0.08 },\n};\n\n/**\n * A 3D particle sphere that breathes with the voice signal. Drawn on a 2D\n * canvas with a tiny hand-rolled projection — no WebGL or 3D library — and\n * colored by `currentColor`, so a `text-*` class on the orb sets its tint\n * (and it follows light/dark theme changes live). While thinking, the sphere\n * twists like a Rubik's cube: one third of it turns 90° at a time.\n */\nexport function VoiceOrb({ state, level, particles = 900, className }: VoiceOrbProps) {\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n  const stateRef = React.useRef(state);\n  const levelRef = React.useRef(level);\n\n  React.useEffect(() => {\n    stateRef.current = state;\n    levelRef.current = level;\n  });\n\n  React.useEffect(() => {\n    const canvas = canvasRef.current;\n    const ctx = canvas?.getContext(\"2d\");\n    if (!canvas || !ctx) return;\n\n    const reducedMotion = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    const points = fibonacciSphere(particles);\n\n    let width = 0;\n    let height = 0;\n    const resize = () => {\n      const dpr = Math.min(window.devicePixelRatio || 1, 2);\n      width = canvas.clientWidth;\n      height = canvas.clientHeight;\n      canvas.width = Math.round(width * dpr);\n      canvas.height = Math.round(height * dpr);\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n    };\n    resize();\n    const observer = new ResizeObserver(resize);\n    observer.observe(canvas);\n\n    let frame = 0;\n    let angle = 0;\n    let smoothed = 0;\n    let last = performance.now();\n    let turn: Turn | null = null;\n    let pause = 0;\n\n    const draw = (now: number) => {\n      const dt = Math.min((now - last) / 1000, 0.05);\n      last = now;\n      const t = now / 1000;\n      const current = stateRef.current;\n      const motion = STATE_MOTION[current];\n\n      const raw = readLevel(levelRef.current, current, t);\n      // Fast attack, slow release: the orb jumps with a syllable and settles gently.\n      smoothed += (raw - smoothed) * (raw > smoothed ? 0.35 : 0.08);\n\n      if (!reducedMotion) angle += dt * motion.spin;\n\n      // Rubik's-cube twist: while thinking, keep turning slices one after another.\n      // A turn in progress always finishes, so leaving \"thinking\" never leaves a half-twisted ball.\n      if (turn) {\n        turn.progress = Math.min(1, turn.progress + dt / TURN_SECONDS);\n        if (turn.progress >= 1) {\n          for (const p of points) if (inSlice(p, turn)) rotateAxis(p, turn.axis, turn.direction * (Math.PI / 2));\n          turn = null;\n          pause = TURN_PAUSE;\n        }\n      } else if (current === \"thinking\" && !reducedMotion) {\n        pause -= dt;\n        if (pause <= 0) turn = randomTurn();\n      }\n      const twist = turn ? turn.direction * (Math.PI / 2) * easeInOut(turn.progress) : 0;\n\n      const color = getComputedStyle(canvas).color;\n      ctx.clearRect(0, 0, width, height);\n      ctx.fillStyle = color;\n\n      const cx = width / 2;\n      const cy = height / 2;\n      const baseRadius = Math.min(width, height) * 0.3;\n      const swell = 1 + smoothed * motion.gain;\n      const ripple = reducedMotion ? 0 : motion.ripple * (0.4 + smoothed);\n      const cosY = Math.cos(angle);\n      const sinY = Math.sin(angle);\n      const tilt = 0.35;\n      const cosX = Math.cos(tilt);\n      const sinX = Math.sin(tilt);\n      const focal = 4;\n\n      // The turning slice lifts slightly off the ball and settles back, so the twist reads at a glance.\n      const lift = turn ? 1 + 0.1 * Math.sin(Math.PI * turn.progress) : 1;\n\n      for (const base of points) {\n        const turning = turn !== null && inSlice(base, turn);\n        const p = turn && turning ? rotatedCopy(base, turn.axis, twist) : base;\n        // Per-particle displacement: a slow wave travelling over the surface.\n        const wave = Math.sin(p.x * 4 + t * 2.1) + Math.sin(p.y * 5 - t * 1.7) + Math.sin(p.z * 3 + t * 1.3);\n        const r = swell * (1 + ripple * wave * p.jitter) * (turning ? lift : 1);\n\n        // Rotate around Y, then tilt around X.\n        const x1 = p.x * cosY + p.z * sinY;\n        const z1 = -p.x * sinY + p.z * cosY;\n        const y2 = p.y * cosX - z1 * sinX;\n        const z2 = p.y * sinX + z1 * cosX;\n\n        const depth = (z2 + 1) / 2; // 0 = back, 1 = front\n        const scale = focal / (focal - z2 * r);\n        const sx = cx + x1 * r * baseRadius * scale;\n        const sy = cy + y2 * r * baseRadius * scale;\n        const size = (0.5 + depth * 1.3 + smoothed * 0.5) * p.size;\n\n        ctx.globalAlpha = 0.15 + depth * 0.85;\n        ctx.beginPath();\n        ctx.arc(sx, sy, size, 0, Math.PI * 2);\n        ctx.fill();\n      }\n      ctx.globalAlpha = 1;\n\n      frame = requestAnimationFrame(draw);\n    };\n    frame = requestAnimationFrame(draw);\n\n    return () => {\n      cancelAnimationFrame(frame);\n      observer.disconnect();\n    };\n  }, [particles]);\n\n  return (\n    <canvas\n      ref={canvasRef}\n      role=\"img\"\n      aria-label={STATE_LABEL[state]}\n      className={cn(\"block aspect-square w-full text-foreground\", className)}\n    />\n  );\n}\n\ntype Point = { x: number; y: number; z: number; size: number; jitter: number };\ntype Axis = \"x\" | \"y\" | \"z\";\ntype Turn = { axis: Axis; slice: -1 | 0 | 1; direction: 1 | -1; progress: number };\n\nconst TURN_SECONDS = 0.45;\nconst TURN_PAUSE = 0.12;\nconst SLICE_EDGE = 1 / 3;\n\nfunction randomTurn(): Turn {\n  const axes: Axis[] = [\"x\", \"y\", \"z\"];\n  const slices = [-1, 0, 1] as const;\n  return {\n    axis: axes[Math.floor(Math.random() * 3)],\n    slice: slices[Math.floor(Math.random() * 3)],\n    direction: Math.random() < 0.5 ? 1 : -1,\n    progress: 0,\n  };\n}\n\n/** Which third of the sphere (along the turn's axis) a point sits in. */\nfunction inSlice(p: Point, turn: Turn) {\n  const c = p[turn.axis];\n  const slice = c < -SLICE_EDGE ? -1 : c > SLICE_EDGE ? 1 : 0;\n  return slice === turn.slice;\n}\n\nfunction rotateAxis(p: Point, axis: Axis, a: number) {\n  const cos = Math.cos(a);\n  const sin = Math.sin(a);\n  const { x, y, z } = p;\n  if (axis === \"x\") {\n    p.y = y * cos - z * sin;\n    p.z = y * sin + z * cos;\n  } else if (axis === \"y\") {\n    p.x = x * cos + z * sin;\n    p.z = -x * sin + z * cos;\n  } else {\n    p.x = x * cos - y * sin;\n    p.y = x * sin + y * cos;\n  }\n}\n\nfunction rotatedCopy(p: Point, axis: Axis, a: number): Point {\n  const copy = { ...p };\n  rotateAxis(copy, axis, a);\n  return copy;\n}\n\nfunction easeInOut(t: number) {\n  return t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;\n}\n\n/** Evenly distributes points over a unit sphere (golden-angle spiral). */\nfunction fibonacciSphere(count: number): Point[] {\n  const golden = Math.PI * (3 - Math.sqrt(5));\n  return Array.from({ length: count }, (_, i) => {\n    const y = 1 - (i / (count - 1)) * 2;\n    const ring = Math.sqrt(1 - y * y);\n    const theta = golden * i;\n    // Deterministic pseudo-random size so the sphere looks like scattered dust, not a grid.\n    const seed = Math.abs(Math.sin(i * 12.9898) * 43758.5453) % 1;\n    return { x: Math.cos(theta) * ring, y, z: Math.sin(theta) * ring, size: 0.6 + seed * 0.9, jitter: 0.5 + seed };\n  });\n}\n\nfunction readLevel(level: VoiceOrbProps[\"level\"], state: VoiceOrbState, t: number) {\n  if (typeof level === \"number\") return clamp(level);\n  if (level && typeof level.current === \"number\") return clamp(level.current);\n  // Synthetic rhythm for prototypes with no audio wired up yet.\n  if (state === \"idle\") return 0;\n  if (state === \"thinking\") return 0;\n  const syllables = Math.max(0, Math.sin(t * 7.3) * Math.sin(t * 2.3) + 0.25 * Math.sin(t * 13.1));\n  return clamp(state === \"speaking\" ? syllables * 1.1 : syllables * 0.8);\n}\n\nfunction clamp(v: number) {\n  return Math.min(1, Math.max(0, v));\n}\n","dependencies":[],"localImports":["@/lib/utils"]}