# Voice Reactive Orb

> Speak and the aurora swells into something that feels alive.

A shadcn/ui block from the [@wensity](https://ui.wensity.com/components) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/wensity/voice-aurora-wave).

- Page, with a live preview: https://shadcn.exchange/r/wensity/voice-aurora-wave
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/wensity/voice-aurora-wave-light-02f3aa16246a8267.png
- Registry item: https://raw.githubusercontent.com/wensity/registry/main/voice-aurora-wave.json
- Categories: Agentic AI Interfaces, wensity, free
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://raw.githubusercontent.com/wensity/registry/main/voice-aurora-wave.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `@tabler/icons-react`, `clsx`, `framer-motion`, `tailwind-merge`

## Notes from the registry

Free Wensity component. Installs to @components/wensity/voice-aurora-wave.tsx. For Pro components and updates, use pnpm dlx wensity@latest add voice-aurora-wave.

## Files

### `registry/wensity/lib/utils.ts`

```ts
import { type ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs));
}
```

### `registry/wensity/voice-aurora-wave.tsx`

```tsx
"use client";

import * as React from "react";
import { motion, useSpring, useTransform, type MotionValue } from "framer-motion";
import { IconMicrophone, IconPlayerStopFilled } from "@tabler/icons-react";
import { cn } from "@/lib/utils";

export interface VoiceAuroraWaveProps {
  /** Outer size in px (square). */
  size?: number;
  /**
   * Optional controlled audio level [0..1]. When provided the component
   * does NOT touch the microphone — it just visualises the value.
   */
  level?: number;
  /** Default visualizer state (uncontrolled). */
  defaultListening?: boolean;
  /** When true, requesting mic permission is disabled (demo mode). */
  demo?: boolean;
  /** Fired when the user toggles listening on / off. */
  onListeningChange?: (listening: boolean) => void;
  /** Optional caption text shown under the orb. */
  label?: string;
  className?: string;
}

/**
 * VoiceAuroraWave — ChatGPT / Gemini style liquid voice orb.
 *
 * Three SVG blobs morph in concentric layers using `feTurbulence` +
 * `feDisplacementMap` so the surface ripples like mercury. Microphone
 * amplitude is run through a `useSpring` low-pass filter that drives:
 *
 *   • the global orb scale (subtle 0.94 → 1.18 breath),
 *   • the displacement strength (20 → 90, so the blob "dances"),
 *   • the conic-gradient saturation (idle = soft, loud = vibrant chili).
 *
 * Performance:
 *   • All movement is `transform` / `opacity` / SVG filter primitives.
 *   • Mic stream is fully released when `listening` flips off.
 */
export function VoiceAuroraWave({
  size = 240,
  level,
  defaultListening = false,
  demo = false,
  onListeningChange,
  label,
  className,
}: VoiceAuroraWaveProps) {
  const controlled = typeof level === "number";

  const [listening, setListening] = React.useState(defaultListening);
  const [error, setError] = React.useState<string | null>(null);
  const filterId = React.useId().replace(/[:]/g, "");

  // Spring acts as a physics-based low-pass filter for raw audio.
  // Tighter than before so loud spikes punch through visually instead of
  // getting smoothed flat — we still keep enough damping to avoid jitter.
  const amp = useSpring(0, { stiffness: 160, damping: 16, mass: 0.55 });

  // Derived motion values for the visual layers.
  // Ranges keep the orb visibly CIRCULAR — displacement is intentionally
  // small so the silhouette breathes rather than tearing into a blob.
  const orbScale = useTransform(amp, [0, 1], [0.92, 1.32]);
  const innerScale = useTransform(amp, [0, 1], [0.84, 1.18]);
  const dispScale = useTransform(amp, [0, 1], [4, 28]);
  const sheen = useTransform(amp, [0, 1], [0.45, 1]);
  // Outer "shouting" halo only blooms when amp crosses ≈0.4 — gives a
  // clear visual jump between "talking" and "shouting".
  const shoutScale = useTransform(amp, [0.4, 1], [1, 1.55]);
  const shoutOpacity = useTransform(amp, [0.4, 0.7, 1], [0, 0.5, 0.85]);

  /* ── Drive amp from controlled level ─────────────────────────── */
  React.useEffect(() => {
    if (!controlled) return;
    amp.set(Math.max(0, Math.min(1, level ?? 0)));
  }, [amp, controlled, level]);

  /* ── Drive amp from microphone (uncontrolled, non-demo) ──────── */
  React.useEffect(() => {
    if (controlled || !listening || demo) return;

    let cancelled = false;
    let raf = 0;
    let stream: MediaStream | null = null;
    let ctx: AudioContext | null = null;
    let analyser: AnalyserNode | null = null;
    const buffer = new Uint8Array(64);

    async function start() {
      try {
        stream = await navigator.mediaDevices.getUserMedia({ audio: true });
        if (cancelled) {
          stream.getTracks().forEach((t) => t.stop());
          return;
        }
        const AudioCtx =
          window.AudioContext ||
          (window as unknown as { webkitAudioContext: typeof AudioContext })
            .webkitAudioContext;
        ctx = new AudioCtx();
        const src = ctx.createMediaStreamSource(stream);
        analyser = ctx.createAnalyser();
        analyser.fftSize = 128;
        analyser.smoothingTimeConstant = 0.65;
        src.connect(analyser);

        const tick = () => {
          if (!analyser) return;
          analyser.getByteFrequencyData(buffer as unknown as Uint8Array<ArrayBuffer>);
          let sum = 0;
          for (let i = 0; i < buffer.length; i++) sum += buffer[i];
          const avg = sum / buffer.length / 255;
          amp.set(Math.min(1, avg * 1.6));
          raf = requestAnimationFrame(tick);
        };
        raf = requestAnimationFrame(tick);
      } catch (e) {
        const msg = e instanceof Error ? e.message : "Microphone unavailable";
        setError(msg);
        setListening(false);
        onListeningChange?.(false);
      }
    }

    start();

    return () => {
      cancelled = true;
      cancelAnimationFrame(raf);
      analyser?.disconnect();
      ctx?.close().catch(() => {});
      stream?.getTracks().forEach((t) => t.stop());
      amp.set(0);
    };
  }, [amp, controlled, listening, demo, onListeningChange]);

  /* ── Demo-mode synthetic breathing wave ──────────────────────── */
  // Synth designed to walk the orb through a clearly-readable arc:
  //   whisper (≈0.15) → conversational (≈0.45) → shouting bursts (≈0.95).
  // A periodic "shout envelope" injects ~1.2s spikes every ~5.5s so the
  // viewer always sees the full visual range, not just gentle breathing.
  React.useEffect(() => {
    if (controlled || !listening || !demo) return;
    let raf = 0;
    const start = performance.now();
    const tick = (t: number) => {
      const dt = (t - start) / 1000;

      // Base conversational waveform — multiple sines for organic feel.
      const base =
        0.32 +
        0.22 * Math.sin(dt * 1.4) +
        0.16 * Math.sin(dt * 3.1 + 1.2) +
        0.08 * Math.sin(dt * 6.4 + 0.6);

      // Shout envelope: a smooth bell every ~5.5s, peak ≈0.65 added on top.
      const cycle = 5.5;
      const phase = (dt % cycle) / cycle; // 0..1
      // Bell centred at 0.55, width ~0.18 of the cycle.
      const bell = Math.exp(-Math.pow((phase - 0.55) / 0.09, 2));
      const shout = bell * 0.7;

      // Tiny high-frequency jitter so the surface stays "alive" mid-shout.
      const jitter = (Math.sin(dt * 22) + Math.sin(dt * 31.7)) * 0.02;

      const v = base + shout + jitter;
      amp.set(Math.max(0.05, Math.min(1, v)));
      raf = requestAnimationFrame(tick);
    };
    raf = requestAnimationFrame(tick);
    return () => {
      cancelAnimationFrame(raf);
      amp.set(0);
    };
  }, [amp, controlled, listening, demo]);

  function toggle() {
    setError(null);
    const next = !listening;
    setListening(next);
    onListeningChange?.(next);
  }

  return (
    <div
      className={cn(
        "flex select-none flex-col items-center justify-center gap-4",
        className
      )}
    >
      <div
        className="relative grid place-items-center"
        style={{ width: size, height: size }}
      >
        {/* SVG defs: turbulence + displacement → liquid surface */}
        <svg
          aria-hidden
          width="0"
          height="0"
          className="absolute"
          style={{ position: "absolute", width: 0, height: 0 }}
        >
          <defs>
            <filter id={`liquid-${filterId}`} x="-15%" y="-15%" width="130%" height="130%">
              <feTurbulence
                type="fractalNoise"
                baseFrequency="0.022 0.028"
                numOctaves="2"
                seed="3"
                result="noise"
              >
                <animate
                  attributeName="baseFrequency"
                  dur="18s"
                  values="0.022 0.028;0.026 0.032;0.022 0.028"
                  repeatCount="indefinite"
                />
              </feTurbulence>
              <DisplacementMap dispScale={dispScale} />
            </filter>

            <radialGradient id={`g-core-${filterId}`} cx="50%" cy="50%" r="50%">
              {/* Iridescent voice-orb palette — premium AI aesthetic
                  (Apple Siri, ChatGPT voice mode, Anthropic Claude).
                  Voice UIs universally use cool/iridescent gradients;
                  red orbs read as "muted" or "system error". */}
              <stop offset="0%" stopColor="#ffffff" stopOpacity="0.95" />
              <stop offset="35%" stopColor="#bae6fd" stopOpacity="0.9" />
              <stop offset="70%" stopColor="#0ea5e9" stopOpacity="0.92" />
              <stop offset="100%" stopColor="#0c4a6e" stopOpacity="1" />
            </radialGradient>
            <radialGradient id={`g-halo-${filterId}`} cx="50%" cy="50%" r="50%">
              <stop offset="0%" stopColor="#7dd3fc" stopOpacity="0.55" />
              <stop offset="60%" stopColor="#0ea5e9" stopOpacity="0.32" />
              <stop offset="100%" stopColor="#0ea5e9" stopOpacity="0" />
            </radialGradient>
          </defs>
        </svg>

        {/* Outer halo — soft sky breath (premium voice-AI signature). */}
        <motion.div
          aria-hidden
          className="absolute inset-[12%] rounded-full"
          style={{
            scale: orbScale,
            opacity: sheen,
            background:
              "radial-gradient(circle at 50% 50%, rgba(255,255,255,0.45) 0%, rgba(14,165,233,0.32) 38%, rgba(14,165,233,0.10) 60%, transparent 75%)",
            filter: "blur(18px)",
            willChange: "transform, opacity",
          }}
        />

        {/* "Shouting" bloom — only visible when amp > 0.4. */}
        <motion.div
          aria-hidden
          className="absolute inset-[8%] rounded-full"
          style={{
            scale: shoutScale,
            opacity: shoutOpacity,
            background:
              "radial-gradient(circle at 50% 50%, rgba(255,255,255,0.42) 0%, rgba(56,189,248,0.34) 35%, rgba(14,165,233,0.10) 60%, transparent 78%)",
            filter: "blur(28px)",
            willChange: "transform, opacity",
          }}
        />

        {/* The liquid orb — SVG with displacement filter */}
        <motion.svg
          viewBox="0 0 200 200"
          width={size * 0.86}
          height={size * 0.86}
          className="absolute"
          style={{ scale: orbScale, willChange: "transform" }}
        >
          <g filter={`url(#liquid-${filterId})`}>
            <circle cx="100" cy="100" r="78" fill={`url(#g-core-${filterId})`} />
            <circle cx="100" cy="100" r="92" fill={`url(#g-halo-${filterId})`} opacity="0.7" />
          </g>
        </motion.svg>

        {/* Inner shimmering core — lit white with a hint of sky at the rim. */}
        <motion.div
          aria-hidden
          className="absolute rounded-full"
          style={{
            width: size * 0.42,
            height: size * 0.42,
            scale: innerScale,
            background:
              "radial-gradient(circle at 35% 30%, rgba(255,255,255,0.95) 0%, rgba(186,230,253,0.5) 30%, rgba(14,165,233,0.0) 70%)",
            mixBlendMode: "screen",
            willChange: "transform",
          }}
        />

        {/* Tap-to-talk button — neutral glassy chip with sky-toned icon
            (matches the orb's iridescent palette). Apple Siri / OpenAI
            voice mode use neutral surfaces; the icon picks up the orb's
            ambient hue rather than the company brand. */}
        <motion.button
          type="button"
          onClick={toggle}
          aria-pressed={listening}
          aria-label={listening ? "Stop listening" : "Start listening"}
          style={{
            width: size * 0.32,
            height: size * 0.32,
            willChange: "transform",
          }}
          whileTap={{ scale: 0.92 }}
          transition={{ type: "spring", stiffness: 400, damping: 22 }}
          className={cn(
            "relative z-10 grid place-items-center rounded-full",
            "bg-white/95 text-sky-600 backdrop-blur-md",
            "shadow-[0_0_0_1px_rgba(0,0,0,0.06),0_8px_24px_-8px_rgba(0,0,0,0.45)]",
            "dark:bg-white/[0.10] dark:text-sky-300",
            "dark:shadow-[0_0_0_1px_rgba(255,255,255,0.10),0_18px_50px_-12px_rgba(0,0,0,0.65)]",
            "outline-none focus-visible:ring-2 focus-visible:ring-sky-500/60"
          )}
        >
          {listening ? (
            <IconPlayerStopFilled size={Math.round(size * 0.13)} />
          ) : (
            <IconMicrophone size={Math.round(size * 0.13)} stroke={1.8} />
          )}
        </motion.button>
      </div>

      {(label || error) && (
        <div className="min-h-[20px] text-center text-[12.5px]">
          {error ? (
            // Error gets a TRUE error-red (#ef4444) — distinct from the brand
            // chili so users can tell broken-state vs brand-state at a glance.
            <span className="text-[#ef4444]">{error}</span>
          ) : (
            <span className="text-[var(--muted-foreground)]">
              {label ??
                (listening ? "Listening. Speak naturally" : "Tap to speak")}
            </span>
          )}
        </div>
      )}
    </div>
  );
}

/* ─────── Live-binding for SVG `feDisplacementMap` `scale` attr ────── */

function DisplacementMap({ dispScale }: { dispScale: MotionValue<number> }) {
  const ref = React.useRef<SVGFEDisplacementMapElement>(null);
  React.useEffect(() => {
    return dispScale.on("change", (v) => {
      ref.current?.setAttribute("scale", String(v));
    });
  }, [dispScale]);
  return (
    <feDisplacementMap
      ref={ref}
      in="SourceGraphic"
      in2="noise"
      scale="40"
      xChannelSelector="R"
      yChannelSelector="G"
    />
  );
}

/* Helper export for consumers that want to pipe a MotionValue in. */
export type VoiceAuroraLevel = MotionValue<number>;
```
