Velocity UI
Loading…
Menu

AI Voice Waveform Visualizer

A Siri-inspired audio visualizer with organic blobs, multi-layer motion, and token-aware glass controls. Light/dark ready.

Installation

Add this component to your project using the CLI:

terminal
npx vui-registry-cli-v1 add ai-voice-waveform

Source Code

ai-voice-waveform.tsx

"use client"

import { useEffect, useState } from "react"
import { motion, AnimatePresence } from "framer-motion"
import { Mic, Square, GripHorizontal, Sparkles, Volume2, MicOff, Settings, X, Activity, Play, Pause, Download, VolumeX } from "lucide-react"
import { cn } from "@/lib/utils"

export default function AiVoiceWaveform() {
  const [isListening, setIsListening] = useState(false)
  const [mode, setMode] = useState<"siri" | "blob">("siri")
  const [isMuted, setIsMuted] = useState(false)
  const [volume, setVolume] = useState(50)
  const [showSettings, setShowSettings] = useState(false)
  const [voiceType, setVoiceType] = useState<"friendly" | "professional" | "robot">("friendly")
  const [playbackState, setPlaybackState] = useState<"playing" | "paused">("paused")

  // Toggle listening state
  const toggleListening = () => {
      setIsListening(!isListening)
      if (!isListening) setPlaybackState("playing")
      else setPlaybackState("paused")
  }

  const togglePlayback = () => {
      setPlaybackState(playbackState === "playing" ? "paused" : "playing")
      if (playbackState === "paused" && !isListening) setIsListening(true)
      if (playbackState === "playing") setIsListening(false)
  }

  return (
    <div className="relative flex min-h-[700px] w-full flex-col items-center justify-center overflow-hidden rounded-3xl bg-background shadow-2xl transition-colors duration-500 font-sans">
      {/* Background Elements */}
      <div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-foreground/10 via-foreground/5 to-transparent opacity-60" />
      <div className="absolute inset-0 bg-[url('https://grainy-gradients.vercel.app/noise.svg')] opacity-20 mix-blend-overlay" />

      {/* Main Container */}
      <div className="relative z-10 flex flex-col items-center justify-between h-full w-full max-w-4xl px-4 py-8 md:py-12">
        
        {/* Top Status Bar */}
        <div className="w-full flex justify-between items-center mb-8 md:mb-12">
            <div className="flex items-center gap-3 bg-foreground/5 px-4 py-2 rounded-full border border-border/20 backdrop-blur-sm">
                <div className={cn("h-2 w-2 rounded-full animate-pulse shadow-[0_0_10px_currentColor]", isListening ? "bg-green-500 text-green-500" : "bg-red-500 text-red-500")} />
                <span className="text-xs font-medium text-neutral-300 uppercase tracking-widest">{isListening ? "Live" : "Offline"}</span>
            </div>
            <button 
                onClick={() => setShowSettings(!showSettings)} 
                className="p-3 rounded-full bg-foreground/5 hover:bg-foreground/10 transition-colors border border-border/20 backdrop-blur-sm group"
            >
                <Settings className="h-5 w-5 text-neutral-400 group-hover:text-white transition-colors" />
            </button>
        </div>

        {/* Visualizer Area - Flexible Height */}
        <div className="relative flex-1 w-full flex items-center justify-center min-h-[300px]">
          <AnimatePresence mode="wait">
            {isListening ? (
              <motion.div
                key="active"
                initial={{ scale: 0.8, opacity: 0, filter: "blur(10px)" }}
                animate={{ scale: 1, opacity: 1, filter: "blur(0px)" }}
                exit={{ scale: 0.8, opacity: 0, filter: "blur(10px)" }}
                transition={{ duration: 0.8, ease: "easeOut" }}
                className="relative w-full h-full flex items-center justify-center"
              >
                {mode === "siri" ? <SiriWaveform voiceType={voiceType} /> : <LiquidBlob voiceType={voiceType} />}
              </motion.div>
            ) : (
              <motion.button
                key="idle"
                onClick={toggleListening}
                initial={{ scale: 0.9, opacity: 0 }}
                animate={{ scale: 1, opacity: 1 }}
                exit={{ scale: 0.9, opacity: 0 }}
                whileHover={{ scale: 1.05 }}
                whileTap={{ scale: 0.95 }}
                transition={{ duration: 0.3 }}
                className="relative h-40 w-40 rounded-full bg-card backdrop-blur-xl flex items-center justify-center border border-border shadow-[0_0_50px_hsl(var(--foreground)/0.05)] group cursor-pointer"
              >
                <div className="absolute inset-0 rounded-full border border-white/5 animate-ping opacity-20" />
                <Mic className="h-12 w-12 text-neutral-500 group-hover:text-white transition-colors" />
              </motion.button>
            )}
          </AnimatePresence>
        </div>

        {/* Dynamic Status Text */}
        <div className="h-12 mt-8 mb-8 flex items-center justify-center overflow-hidden">
            <AnimatePresence mode="wait">
                <motion.div
                    key={isListening ? "listening" : "idle"}
                    initial={{ y: 20, opacity: 0 }}
                    animate={{ y: 0, opacity: 1 }}
                    exit={{ y: -20, opacity: 0 }}
                    className="flex flex-col items-center gap-1"
                >
                    {isListening ? (
                        <>
                            <p className="text-lg font-medium text-white tracking-tight flex items-center gap-2">
                                <Activity className="h-4 w-4 text-green-500 animate-bounce" />
                                Listening...
                            </p>
                            <p className="text-xs text-neutral-500 font-mono">Process ID: 8X-291</p>
                        </>
                    ) : (
                        <p className="text-lg font-medium text-neutral-500">Tap microphone to start</p>
                    )}
                </motion.div>
            </AnimatePresence>
        </div>

        {/* Premium Control Dock - Capsule Shaped & Aligned */}
        <div className="relative z-20 w-full max-w-lg mx-auto">
            <motion.div 
                className="relative flex items-center justify-between gap-6 rounded-full border border-border bg-card p-3 px-6 backdrop-blur-2xl shadow-2xl ring-1 ring-foreground/5"
                initial={{ y: 50, opacity: 0 }}
                animate={{ y: 0, opacity: 1 }}
                transition={{ type: "spring", damping: 20, stiffness: 100 }}
            >
            
            {/* Play/Pause Controls */}
            <div className="flex items-center gap-4">
                <button 
                    onClick={togglePlayback}
                    className="flex h-12 w-12 items-center justify-center rounded-full bg-foreground text-background hover:scale-105 active:scale-95 transition-all shadow-lg shadow-foreground/10"
                >
                    {playbackState === "playing" ? (
                        <Pause className="h-5 w-5 fill-current" />
                    ) : (
                        <Play className="h-5 w-5 fill-current ml-1" />
                    )}
                </button>
                
                <div className="flex flex-col gap-1">
                    <span className="text-xs font-medium text-foreground">Voice Assistant</span>
                    <div className="h-1 w-24 bg-foreground/10 rounded-full overflow-hidden">
                        <motion.div 
                            className="h-full bg-foreground"
                            initial={{ width: "0%" }}
                            animate={{ width: isListening ? "100%" : "0%" }}
                            transition={{ duration: 20, ease: "linear", repeat: isListening ? Infinity : 0 }}
                        />
                    </div>
                </div>
            </div>

            {/* Middle Separator */}
            <div className="h-8 w-[1px] bg-foreground/10" />

            {/* Volume & Settings */}
            <div className="flex items-center gap-4">
                <button 
                    onClick={() => setIsMuted(!isMuted)}
                    className="p-2 rounded-full hover:bg-white/10 text-neutral-400 hover:text-white transition-colors"
                >
                    {isMuted ? <VolumeX className="h-5 w-5" /> : <Volume2 className="h-5 w-5" />}
                </button>
                
                {/* Mode Toggles */}
                <div className="flex bg-foreground/5 rounded-full p-1 border border-border/20">
                    <button
                        onClick={() => setMode("siri")}
                        className={cn(
                            "p-2 rounded-full transition-all duration-300 relative", 
                            mode === "siri" ? "text-white bg-white/10" : "text-neutral-500 hover:text-white"
                        )}
                        title="Siri Mode"
                    >
                        <Sparkles className="h-4 w-4" />
                    </button>
                    <button
                        onClick={() => setMode("blob")}
                        className={cn(
                            "p-2 rounded-full transition-all duration-300 relative", 
                            mode === "blob" ? "text-white bg-white/10" : "text-neutral-500 hover:text-white"
                        )}
                        title="Blob Mode"
                    >
                        <GripHorizontal className="h-4 w-4" />
                    </button>
                </div>

                <button className="p-2 rounded-full hover:bg-white/10 text-neutral-400 hover:text-white transition-colors">
                    <Download className="h-5 w-5" />
                </button>
            </div>
            </motion.div>
        </div>

        {/* Settings Panel Overlay */}
        <AnimatePresence>
            {showSettings && (
                <motion.div
                    initial={{ opacity: 0, y: 50, scale: 0.95 }}
                    animate={{ opacity: 1, y: 0, scale: 1 }}
                    exit={{ opacity: 0, y: 50, scale: 0.95 }}
                    transition={{ type: "spring", damping: 20, stiffness: 300 }}
                    className="absolute bottom-32 w-[90%] max-w-sm bg-neutral-900/90 border border-white/10 rounded-3xl p-6 shadow-2xl z-50 backdrop-blur-xl ring-1 ring-white/5"
                >
                    <div className="flex justify-between items-center mb-6">
                        <h3 className="text-base font-semibold text-white">Voice Settings</h3>
                        <button onClick={() => setShowSettings(false)} className="p-1 hover:bg-white/10 rounded-full transition-colors"><X className="h-4 w-4 text-neutral-400" /></button>
                    </div>
                    
                    <div className="space-y-6">
                        <div>
                            <label className="text-xs font-medium text-neutral-500 mb-3 block uppercase tracking-wider">Personality</label>
                            <div className="grid grid-cols-3 gap-2">
                                {['friendly', 'professional', 'robot'].map((type) => (
                                    <button
                                        key={type}
                                        onClick={() => setVoiceType(type as any)}
                                        className={cn(
                                            "px-3 py-2 rounded-xl text-xs font-medium capitalize transition-all duration-200 border",
                                            voiceType === type 
                                                ? "bg-white text-black border-white shadow-lg scale-105" 
                                                : "bg-neutral-800 text-neutral-400 border-transparent hover:bg-neutral-700 hover:text-white"
                                        )}
                                    >
                                        {type}
                                    </button>
                                ))}
                            </div>
                        </div>
                        <div>
                            <div className="flex justify-between mb-3">
                                <label className="text-xs font-medium text-neutral-500 uppercase tracking-wider">Sensitivity</label>
                                <span className="text-xs font-mono text-white">{volume}%</span>
                            </div>
                            <div className="relative h-2 bg-neutral-800 rounded-full overflow-hidden group">
                                <motion.div 
                                    className="absolute top-0 left-0 h-full bg-white rounded-full" 
                                    style={{ width: `${volume}%` }} 
                                />
                                <input 
                                    type="range" 
                                    min="0" max="100" 
                                    value={volume} 
                                    onChange={(e) => setVolume(Number(e.target.value))}
                                    className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" 
                                />
                            </div>
                        </div>
                    </div>
                </motion.div>
            )}
            </AnimatePresence>
        </div>
    </div>
  )
}

function SiriWaveform({ voiceType }: { voiceType: string }) {
  const colors = {
      friendly: ["bg-blue-500", "bg-purple-500", "bg-cyan-400", "bg-pink-500"],
      professional: ["bg-emerald-500", "bg-teal-500", "bg-cyan-500", "bg-blue-600"],
      robot: ["bg-orange-500", "bg-red-500", "bg-amber-500", "bg-yellow-500"]
  }[voiceType] || ["bg-blue-500", "bg-purple-500", "bg-cyan-400", "bg-pink-500"]

  return (
    <div className="relative h-80 w-80 md:h-96 md:w-96 flex items-center justify-center">
      <div className="absolute inset-0 rounded-full bg-white/5 blur-3xl animate-pulse" />
      <div className="relative h-full w-full flex items-center justify-center mix-blend-screen">
        {[0, 1, 2, 3].map((i) => (
             <motion.div
                key={i}
                animate={{
                    scale: [1, 1.1 + (i * 0.1), 1],
                    rotate: [0, i % 2 === 0 ? 360 : -360],
                    x: [0, (i % 2 === 0 ? 20 : -20), 0],
                    y: [0, (i % 2 === 0 ? -20 : 20), 0],
                }}
                transition={{ 
                    duration: 3 + i, 
                    repeat: Infinity, 
                    ease: "easeInOut",
                    times: [0, 0.5, 1]
                }}
                className={cn(
                    "absolute rounded-full blur-2xl opacity-60",
                    colors[i],
                    i === 0 ? "h-48 w-48" : i === 1 ? "h-40 w-40" : i === 2 ? "h-32 w-32" : "h-24 w-24"
                )}
            />
        ))}
        {/* Core Glow */}
        <div className="absolute h-20 w-20 bg-white blur-xl opacity-50 animate-pulse" />
      </div>
    </div>
  )
}

function LiquidBlob({ voiceType }: { voiceType: string }) {
    const gradient = {
        friendly: "from-indigo-500 via-purple-500 to-pink-500",
        professional: "from-emerald-500 via-teal-500 to-cyan-500",
        robot: "from-orange-500 via-red-500 to-amber-500"
    }[voiceType]

    return (
        <div className="relative h-80 w-80 md:h-96 md:w-96 flex items-center justify-center filter url(#goo)">
            <svg style={{ position: 'absolute', width: 0, height: 0 }}>
                <defs>
                <filter id="goo">
                    <feGaussianBlur in="SourceGraphic" stdDeviation="10" result="blur" />
                    <feColorMatrix in="blur" mode="matrix" values="1 0 0 0 0  0 1 0 0 0  0 0 1 0 0  0 0 0 18 -7" result="goo" />
                    <feComposite in="SourceGraphic" in2="goo" operator="atop"/>
                </filter>
                </defs>
            </svg>

            {/* Main Blob */}
            <motion.div
                animate={{
                    borderRadius: ["50%", "40% 60% 70% 30% / 40% 50% 60% 50%", "60% 40% 30% 70% / 60% 30% 70% 40%", "50%"],
                    rotate: [0, 360],
                    scale: [1, 1.05, 0.95, 1]
                }}
                transition={{ duration: 10, repeat: Infinity, ease: "linear" }}
                className={`absolute h-56 w-56 bg-gradient-to-br ${gradient} opacity-90 blur-sm`}
            />
            
            {/* Satellite Blobs */}
            {[...Array(3)].map((_, i) => (
                <motion.div
                    key={i}
                    animate={{
                        x: [0, (Math.random() - 0.5) * 100, 0],
                        y: [0, (Math.random() - 0.5) * 100, 0],
                        scale: [0.8, 1.2, 0.8]
                    }}
                    transition={{ 
                        duration: 5 + Math.random() * 3, 
                        repeat: Infinity, 
                        ease: "easeInOut",
                        delay: i * 0.5
                    }}
                    className={`absolute h-16 w-16 bg-gradient-to-br ${gradient} opacity-80 blur-sm rounded-full`}
                />
            ))}
        </div>
    )
}

Dependencies

  • framer-motion: latest
  • lucide-react: latest
  • clsx: latest
  • tailwind-merge: latest

Props

Component property reference.

NameTypeDefaultDescription
classNamestringundefinedAdditional CSS classes for the container.
initialMode'siri' | 'blob''siri'Initial visualization mode.
onListeningChange(listening: boolean) => voidundefinedCallback when listening state toggles.
Context Worth Keeping In Orbit

Most components here are inspired by outstanding libraries and creators in the ecosystem. I don’t claim to be the original author — this is my space for learning, rebuilding, and understanding great work at a deeper level.

I’m still a student of the craft, constantly studying the best and translating what I learn through my own perspective. Every piece reflects curiosity, respect for the community, and small creative touches that feel true to me.

I’ve done my best to credit inspirations properly. If anything is missing or inaccurate, I truly appreciate a message so it can be corrected with care.