AI Patterns

Voice Orb

A 3D particle sphere that breathes and spins with the voice conversation.

"use client";

import * as React from "react";

import { cn } from "@/lib/utils";

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

export interface VoiceOrbProps {
  state: VoiceOrbState;
  /**
   * Live audio level (0-1), sampled by the caller from a real source
   * (an AnalyserNode on the mic, or on the assistant's playback). Read on
   * every frame, so pass a ref to avoid re-rendering 60 times a second.
   * When omitted, the orb drives itself with a synthetic rhythm.
   */
  level?: React.RefObject<number> | number;
  /** Number of particles on the sphere. */
  particles?: number;
  className?: string;
}

const STATE_LABEL: Record<VoiceOrbState, string> = {
  idle: "Voice idle",
  listening: "Listening",
  thinking: "Thinking",
  speaking: "Speaking",
};

/** How much the sphere is allowed to swell and how fast it spins, per state. */
const STATE_MOTION: Record<VoiceOrbState, { gain: number; spin: number; ripple: number }> = {
  idle: { gain: 0, spin: 0.12, ripple: 0.01 },
  listening: { gain: 0.22, spin: 0.25, ripple: 0.05 },
  thinking: { gain: 0, spin: 0.15, ripple: 0 },
  speaking: { gain: 0.32, spin: 0.35, ripple: 0.08 },
};

/**
 * A 3D particle sphere that breathes with the voice signal. Drawn on a 2D
 * canvas with a tiny hand-rolled projection — no WebGL or 3D library — and
 * colored by `currentColor`, so a `text-*` class on the orb sets its tint
 * (and it follows light/dark theme changes live). While thinking, the sphere
 * twists like a Rubik's cube: one third of it turns 90° at a time.
 */
export function VoiceOrb({ state, level, particles = 900, className }: VoiceOrbProps) {
  const canvasRef = React.useRef<HTMLCanvasElement>(null);
  const stateRef = React.useRef(state);
  const levelRef = React.useRef(level);

  React.useEffect(() => {
    stateRef.current = state;
    levelRef.current = level;
  });

  React.useEffect(() => {
    const canvas = canvasRef.current;
    const ctx = canvas?.getContext("2d");
    if (!canvas || !ctx) return;

    const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const points = fibonacciSphere(particles);

    let width = 0;
    let height = 0;
    const resize = () => {
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      width = canvas.clientWidth;
      height = canvas.clientHeight;
      canvas.width = Math.round(width * dpr);
      canvas.height = Math.round(height * dpr);
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
    };
    resize();
    const observer = new ResizeObserver(resize);
    observer.observe(canvas);

    let frame = 0;
    let angle = 0;
    let smoothed = 0;
    let last = performance.now();
    let turn: Turn | null = null;
    let pause = 0;

    const draw = (now: number) => {
      const dt = Math.min((now - last) / 1000, 0.05);
      last = now;
      const t = now / 1000;
      const current = stateRef.current;
      const motion = STATE_MOTION[current];

      const raw = readLevel(levelRef.current, current, t);
      // Fast attack, slow release: the orb jumps with a syllable and settles gently.
      smoothed += (raw - smoothed) * (raw > smoothed ? 0.35 : 0.08);

      if (!reducedMotion) angle += dt * motion.spin;

      // Rubik's-cube twist: while thinking, keep turning slices one after another.
      // A turn in progress always finishes, so leaving "thinking" never leaves a half-twisted ball.
      if (turn) {
        turn.progress = Math.min(1, turn.progress + dt / TURN_SECONDS);
        if (turn.progress >= 1) {
          for (const p of points) if (inSlice(p, turn)) rotateAxis(p, turn.axis, turn.direction * (Math.PI / 2));
          turn = null;
          pause = TURN_PAUSE;
        }
      } else if (current === "thinking" && !reducedMotion) {
        pause -= dt;
        if (pause <= 0) turn = randomTurn();
      }
      const twist = turn ? turn.direction * (Math.PI / 2) * easeInOut(turn.progress) : 0;

      const color = getComputedStyle(canvas).color;
      ctx.clearRect(0, 0, width, height);
      ctx.fillStyle = color;

      const cx = width / 2;
      const cy = height / 2;
      const baseRadius = Math.min(width, height) * 0.3;
      const swell = 1 + smoothed * motion.gain;
      const ripple = reducedMotion ? 0 : motion.ripple * (0.4 + smoothed);
      const cosY = Math.cos(angle);
      const sinY = Math.sin(angle);
      const tilt = 0.35;
      const cosX = Math.cos(tilt);
      const sinX = Math.sin(tilt);
      const focal = 4;

      // The turning slice lifts slightly off the ball and settles back, so the twist reads at a glance.
      const lift = turn ? 1 + 0.1 * Math.sin(Math.PI * turn.progress) : 1;

      for (const base of points) {
        const turning = turn !== null && inSlice(base, turn);
        const p = turn && turning ? rotatedCopy(base, turn.axis, twist) : base;
        // Per-particle displacement: a slow wave travelling over the surface.
        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);
        const r = swell * (1 + ripple * wave * p.jitter) * (turning ? lift : 1);

        // Rotate around Y, then tilt around X.
        const x1 = p.x * cosY + p.z * sinY;
        const z1 = -p.x * sinY + p.z * cosY;
        const y2 = p.y * cosX - z1 * sinX;
        const z2 = p.y * sinX + z1 * cosX;

        const depth = (z2 + 1) / 2; // 0 = back, 1 = front
        const scale = focal / (focal - z2 * r);
        const sx = cx + x1 * r * baseRadius * scale;
        const sy = cy + y2 * r * baseRadius * scale;
        const size = (0.5 + depth * 1.3 + smoothed * 0.5) * p.size;

        ctx.globalAlpha = 0.15 + depth * 0.85;
        ctx.beginPath();
        ctx.arc(sx, sy, size, 0, Math.PI * 2);
        ctx.fill();
      }
      ctx.globalAlpha = 1;

      frame = requestAnimationFrame(draw);
    };
    frame = requestAnimationFrame(draw);

    return () => {
      cancelAnimationFrame(frame);
      observer.disconnect();
    };
  }, [particles]);

  return (
    <canvas
      ref={canvasRef}
      role="img"
      aria-label={STATE_LABEL[state]}
      className={cn("block aspect-square w-full text-foreground", className)}
    />
  );
}

type Point = { x: number; y: number; z: number; size: number; jitter: number };
type Axis = "x" | "y" | "z";
type Turn = { axis: Axis; slice: -1 | 0 | 1; direction: 1 | -1; progress: number };

const TURN_SECONDS = 0.45;
const TURN_PAUSE = 0.12;
const SLICE_EDGE = 1 / 3;

function randomTurn(): Turn {
  const axes: Axis[] = ["x", "y", "z"];
  const slices = [-1, 0, 1] as const;
  return {
    axis: axes[Math.floor(Math.random() * 3)],
    slice: slices[Math.floor(Math.random() * 3)],
    direction: Math.random() < 0.5 ? 1 : -1,
    progress: 0,
  };
}

/** Which third of the sphere (along the turn's axis) a point sits in. */
function inSlice(p: Point, turn: Turn) {
  const c = p[turn.axis];
  const slice = c < -SLICE_EDGE ? -1 : c > SLICE_EDGE ? 1 : 0;
  return slice === turn.slice;
}

function rotateAxis(p: Point, axis: Axis, a: number) {
  const cos = Math.cos(a);
  const sin = Math.sin(a);
  const { x, y, z } = p;
  if (axis === "x") {
    p.y = y * cos - z * sin;
    p.z = y * sin + z * cos;
  } else if (axis === "y") {
    p.x = x * cos + z * sin;
    p.z = -x * sin + z * cos;
  } else {
    p.x = x * cos - y * sin;
    p.y = x * sin + y * cos;
  }
}

function rotatedCopy(p: Point, axis: Axis, a: number): Point {
  const copy = { ...p };
  rotateAxis(copy, axis, a);
  return copy;
}

function easeInOut(t: number) {
  return t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2;
}

/** Evenly distributes points over a unit sphere (golden-angle spiral). */
function fibonacciSphere(count: number): Point[] {
  const golden = Math.PI * (3 - Math.sqrt(5));
  return Array.from({ length: count }, (_, i) => {
    const y = 1 - (i / (count - 1)) * 2;
    const ring = Math.sqrt(1 - y * y);
    const theta = golden * i;
    // Deterministic pseudo-random size so the sphere looks like scattered dust, not a grid.
    const seed = Math.abs(Math.sin(i * 12.9898) * 43758.5453) % 1;
    return { x: Math.cos(theta) * ring, y, z: Math.sin(theta) * ring, size: 0.6 + seed * 0.9, jitter: 0.5 + seed };
  });
}

function readLevel(level: VoiceOrbProps["level"], state: VoiceOrbState, t: number) {
  if (typeof level === "number") return clamp(level);
  if (level && typeof level.current === "number") return clamp(level.current);
  // Synthetic rhythm for prototypes with no audio wired up yet.
  if (state === "idle") return 0;
  if (state === "thinking") return 0;
  const syllables = Math.max(0, Math.sin(t * 7.3) * Math.sin(t * 2.3) + 0.25 * Math.sin(t * 13.1));
  return clamp(state === "speaking" ? syllables * 1.1 : syllables * 0.8);
}

function clamp(v: number) {
  return Math.min(1, Math.max(0, v));
}

A UX spec for this pattern — written for agents implementing or reusing it, not the code.

# Voice Orb

## Summary
A 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.

## When to use
- 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.
- 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).
- When brand presence matters in a voice product — the orb is the assistant's "face", and its tint and density can carry the brand.

## When not to use
- In a compact composer or a toolbar — the orb needs room to read as 3D. Use Voice Waveform or Listening State there instead.
- When the user needs to monitor exact mic levels (dictation, recording, audio setup) — a waveform or meter is more legible than a breathing sphere.
- On low-power or text-first screens where a continuously animating canvas costs battery for little information.

## Anatomy
- 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.
- 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.
- Surface ripple: a slow travelling wave displaces each particle along its radius; its amplitude grows with the voice level.
- 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.

## Behavior
- `idle`: the sphere sits at rest size and turns very slowly, with almost no ripple — present but quiet.
- `listening`: reacts to the user's mic level — it swells and ripples with each syllable.
- `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.
- `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.
- The level is smoothed with a fast attack and slow release: the orb jumps with a syllable and settles gently instead of jittering.
- 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.
- State changes don't cut: rotation and swell carry over, so moving between turns feels continuous.

## Content guidelines
- The orb carries no text. Pair it with a short state caption ("Listening…", "Thinking…") or a Live Transcript when the user needs words.
- Keep a visible, labeled control (mute, end call) near the orb — it's not a button itself.

## Accessibility
- 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.
- Respect `prefers-reduced-motion`: stop the rotation, ripple and slice turns, and let only the size change gently with the level.
- Don't rely on the orb alone to signal turn-taking — pair it with a text state or a speaker label.
- Keep contrast between the particles and the backdrop high enough that the sphere's outline is visible at rest.

## Related patterns
- **Voice Waveform** — Use it instead where the orb doesn't fit and a compact, literal level display is enough.
- **Listening State** — Use it instead in a composer, where a pill-sized mic-is-on indicator fits better.
- **Live Transcript** — Use it as well below or beside the orb to show what's being said.
- **Thinking Loader** — Use it instead in a text-only interface for the orb's thinking state.

Do’s and don’ts

Do use it for

  • 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.
  • 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).
  • When brand presence matters in a voice product — the orb is the assistant's "face", and its tint and density can carry the brand.

Don’t use it for

  • In a compact composer or a toolbar — the orb needs room to read as 3D. Use Voice Waveform or Listening State there instead.
  • When the user needs to monitor exact mic levels (dictation, recording, audio setup) — a waveform or meter is more legible than a breathing sphere.
  • On low-power or text-first screens where a continuously animating canvas costs battery for little information.