"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.
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.