Skip to main content
Back to discovery

Live preview recorded from Mellow UI; component and demo published by Mellow UI. Preview: platform recorded · rights authorized.

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 rotatin…

MSource LandMellow UI

Why it stands out

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.

Prompt

You are given a task to integrate a React component into your codebase.
Please verify your project has the following setup:
- shadcn/ui project structure
- Tailwind CSS v4.0
- TypeScript
- motion/react v11

If any of these are missing, provide instructions on how to setup project via shadcn CLI, install Tailwind or TypeScript.

Determine the default path for components and styles. Mellow components live in `components/mellow/`. If that folder does not exist yet, create it.

Copy-paste these files:

File location: components/voice-orb-demo.tsx

File content: "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-5 p-4 sm:gap-8 sm: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>
  );
}



File location: components/mellow/voice-orb.tsx

File content: "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;



Usage notes:
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.

Discovery vocabulary

Related by governed terms

Continue comparing

More from Mellow UI