Skip to content

Voice Orb

A blob that reacts to audio level.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/voice-orb.json

Manual

  1. Install the dependencies

    npm install clsx motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts. See Installation if you have not set them up yet.

  3. Copy the source

    components/ui/voice-orb/voice-orb.tsx
    "use client";
    
    import { useEffect, useId, useRef, type ComponentProps } from "react";
    import { motion, useMotionValue, useReducedMotion, useSpring, useTransform } from "motion/react";
    import { cn } from "@/lib/utils";
    
    export type VoiceOrbState = "idle" | "listening" | "thinking" | "speaking";
    
    export interface VoiceOrbProps
      extends Omit<ComponentProps<"div">, "children" | "onAnimationStart" | "onDrag" | "onDragStart" | "onDragEnd"> {
      /** What the assistant is doing; sets how much the orb moves. @default "idle" */
      state?: VoiceOrbState;
      /** Audio level from 0 to 1, e.g. from your own analyser. Ignored when `stream` is set. */
      level?: number;
      /** A microphone (or any audio) stream to measure the level from. */
      stream?: MediaStream | null;
      /** Diameter in px. @default 160 */
      size?: number;
      /** Three gradient colors. @default ["var(--chart-1)", "#8b5cf6", "#22d3ee"] */
      colors?: [string, string, string];
      /** Accessible name; the state is appended. @default "Voice assistant" */
      label?: string;
    }
    
    const STATE_TEXT: Record<VoiceOrbState, string> = { idle: "idle", listening: "listening", thinking: "thinking", speaking: "speaking" };
    const POINTS = 10;
    
    /** Smooth closed path through points (Catmull-Rom converted to cubic Béziers). */
    function blobPath(points: [number, number][]) {
      const n = points.length;
      let d = `M${points[0][0].toFixed(2)},${points[0][1].toFixed(2)}`;
      for (let i = 0; i < n; i++) {
        const p0 = points[(i - 1 + n) % n];
        const p1 = points[i];
        const p2 = points[(i + 1) % n];
        const p3 = points[(i + 2) % n];
        const c1 = [p1[0] + (p2[0] - p0[0]) / 6, p1[1] + (p2[1] - p0[1]) / 6];
        const c2 = [p2[0] - (p3[0] - p1[0]) / 6, p2[1] - (p3[1] - p1[1]) / 6];
        d += `C${c1[0].toFixed(2)},${c1[1].toFixed(2)} ${c2[0].toFixed(2)},${c2[1].toFixed(2)} ${p2[0].toFixed(2)},${p2[1].toFixed(2)}`;
      }
      return `${d}Z`;
    }
    
    export function VoiceOrb({
      state = "idle",
      level = 0,
      stream,
      size = 160,
      colors = ["var(--chart-1)", "#8b5cf6", "#22d3ee"],
      label = "Voice assistant",
      className,
      style,
      ...props
    }: VoiceOrbProps) {
      const reduceMotion = useReducedMotion();
      const rootRef = useRef<HTMLDivElement>(null);
      const pathRef = useRef<SVGPathElement>(null);
      const gradientId = useId();
      const live = useRef({ state, level, reduce: !!reduceMotion });
      useEffect(() => {
        live.current = { ...live.current, state, level, reduce: !!reduceMotion };
      });
      // The orb swells with the (smoothed) level; the glow brightens with it.
      const loudness = useMotionValue(0);
      const smooth = useSpring(loudness, { stiffness: 220, damping: 24 });
      const scale = useTransform(smooth, (v) => 1 + v * 0.18);
      const glow = useTransform(smooth, (v) => 0.35 + v * 0.5);
    
      // Measure the stream's level with a Web Audio analyser. The drawing loop below reads it once per
      // frame, so there is one loop, and nothing is measured while the orb is offscreen.
      const meter = useRef<(() => number) | null>(null);
      useEffect(() => {
        if (!stream) return;
        const AudioCtx = window.AudioContext ?? (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext;
        const context = new AudioCtx();
        const analyser = context.createAnalyser();
        analyser.fftSize = 512;
        context.createMediaStreamSource(stream).connect(analyser);
        const data = new Uint8Array(analyser.fftSize);
        meter.current = () => {
          analyser.getByteTimeDomainData(data);
          let sum = 0;
          for (const v of data) sum += ((v - 128) / 128) ** 2;
          return Math.min(1, Math.sqrt(sum / data.length) * 4);
        };
        return () => {
          meter.current = null;
          context.close();
        };
      }, [stream]);
    
      // Redraw the blob each frame straight into the DOM (no React renders); pause while offscreen.
      useEffect(() => {
        const root = rootRef.current;
        const path = pathRef.current;
        if (!root || !path) return;
        const phases = Array.from({ length: POINTS }, (_, i) => [i * 1.7, i * 2.9, i * 0.6]);
        let frame = 0;
        let visible = true;
        let lv = 0;
        const draw = (t: number) => {
          const { state: s, level, reduce } = live.current;
          const target = meter.current ? meter.current() : level;
          lv += (target - lv) * 0.2;
          loudness.set(s === "idle" ? 0 : lv);
          const time = t / 1000;
          const base = reduce ? 0 : s === "idle" ? 0.025 : s === "thinking" ? 0.06 : 0.035;
          // Under reduced motion the outline holds still; the glow still follows the voice.
          const amp = reduce ? 0 : base + (s === "listening" || s === "speaking" ? lv * 0.16 : 0);
          const speed = s === "thinking" ? 1.6 : s === "idle" ? 0.6 : 1.1;
          const points = phases.map(([a, b, c], i): [number, number] => {
            const angle = (i / POINTS) * Math.PI * 2;
            const wobble = 0.55 * Math.sin(time * speed * 1.3 + a) + 0.3 * Math.sin(time * speed * 2.1 + b) + 0.15 * Math.sin(time * speed * 3.7 + c);
            const r = 40 * (1 + amp * wobble);
            return [50 + Math.cos(angle) * r, 50 + Math.sin(angle) * r];
          });
          path.setAttribute("d", blobPath(points));
        };
        const tick = (t: number) => {
          draw(t);
          frame = requestAnimationFrame(tick);
        };
        const observer = new IntersectionObserver(([entry]) => {
          visible = entry.isIntersecting;
          cancelAnimationFrame(frame);
          if (visible) frame = requestAnimationFrame(tick);
        });
        observer.observe(root);
        draw(0);
        return () => {
          cancelAnimationFrame(frame);
          observer.disconnect();
        };
      }, [loudness]);
    
      return (
        <div
          ref={rootRef}
          role="img"
          aria-label={`${label}: ${STATE_TEXT[state]}`}
          className={cn("relative inline-flex shrink-0 items-center justify-center", className)}
          style={{ ...style, width: size, height: size }}
          {...props}
        >
          <motion.span
            aria-hidden="true"
            className="absolute inset-[12%] rounded-full blur-2xl"
            style={{ opacity: glow, background: `radial-gradient(circle, ${colors[1]}, transparent 70%)` }}
          />
          <motion.svg
            viewBox="0 0 100 100"
            width={size}
            height={size}
            aria-hidden="true"
            className="relative overflow-visible"
            style={{ scale: reduceMotion ? 1 : scale }}
            // While thinking, the gradient slowly turns.
            animate={state === "thinking" && !reduceMotion ? { rotate: 360 } : { rotate: 0 }}
            transition={state === "thinking" && !reduceMotion ? { duration: 6, repeat: Infinity, ease: "linear" } : { duration: 0.8 }}
          >
            <defs>
              <linearGradient id={gradientId} x1="0" y1="0" x2="1" y2="1">
                <stop offset="0%" stopColor={colors[0]} />
                <stop offset="55%" stopColor={colors[1]} />
                <stop offset="100%" stopColor={colors[2]} />
              </linearGradient>
            </defs>
            <path ref={pathRef} fill={`url(#${gradientId})`} d="M10,50a40,40 0 1,0 80,0a40,40 0 1,0 -80,0" />
            <ellipse cx="38" cy="34" rx="16" ry="10" fill="white" opacity={0.28} />
          </motion.svg>
        </div>
      );
    }

Usage

import { VoiceOrb } from "@/components/ui/voice-orb/voice-orb";

// Give it a microphone stream…
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
<VoiceOrb state="listening" stream={stream} />

// …or a level you measure yourself (0–1)
<VoiceOrb state="speaking" level={outputLevel} />

API reference

PropTypeDefaultDescription
stateVoiceOrbState"idle"What the assistant is doing; sets how much the orb moves.
levelnumber–Audio level from 0 to 1, e.g. from your own analyser. Ignored when `stream` is set.
streamMediaStream | null–A microphone (or any audio) stream to measure the level from.
sizenumber160Diameter in px.
colors[string, string, string]["var(--chart-1)", "#8b5cf6", "#22d3ee"]Three gradient colors.
labelstring"Voice assistant"Accessible name; the state is appended.

Also accepts every native <div> attribute, including ref and className.

  • The blob is a smooth closed curve whose wobble grows with the level; the whole orb swells and its glow brightens as the level rises.
  • With `stream`, the level is measured with a Web Audio analyser. Requesting the microphone is up to you, so the browser's permission prompt stays under your control.
  • The shape is redrawn every frame directly in the DOM (no React re-renders) by a single loop that also reads the analyser. The loop pauses while the orb is offscreen, so a live stream isn't measured there.

Accessibility

  • Exposed as an image named with the current state (“Voice assistant: listening”). Pair it with visible text or controls for the state.
  • With reduced motion enabled, the orb doesn't wobble on its own; it only swells with the audio level.