AI Voice Waveform Visualizer
A Siri-inspired audio visualizer with organic blobs, multi-layer motion, and token-aware glass controls. Light/dark ready.
Preview
Live interactive preview.
Installation
Add this component to your project using the CLI:
npx -y vui-registry-cli-v1@latest add ai-voice-waveformSource Code
"use client"
import { useState } from "react"
import { motion, AnimatePresence } from "framer-motion"
import { Mic, Volume2, Settings, X, Play, Pause, VolumeX } from "lucide-react"
import { cn } from "@/lib/utils"
type VoiceType = "friendly" | "professional" | "robot"
type VisualizerMode = "siri" | "blob"
type PlaybackState = "playing" | "paused"
export default function AiVoiceWaveform() {
const [isListening, setIsListening] = useState(false)
const [mode, setMode] = useState<VisualizerMode>("siri")
const [isMuted, setIsMuted] = useState(false)
const [volume, setVolume] = useState(50)
const [showSettings, setShowSettings] = useState(false)
const [voiceType, setVoiceType] = useState<VoiceType>("friendly")
const [playbackState, setPlaybackState] = useState<PlaybackState>("paused")
const toggleListening = () => {
setIsListening(!isListening)
setPlaybackState(isListening ? "paused" : "playing")
}
const togglePlayback = () => {
setPlaybackState(playbackState === "playing" ? "paused" : "playing")
if (playbackState === "paused" && !isListening) setIsListening(true)
if (playbackState === "playing") setIsListening(false)
}
return (
<div className="relative mx-auto flex w-full max-w-5xl aspect-video flex-col items-center justify-center overflow-hidden rounded-[2.5rem] bg-background text-foreground ring-1 ring-foreground/10 transition-colors duration-500 font-sans selection:bg-foreground/20">
{/* Original Theme-Aware Background Elements */}
<div className="absolute inset-0 bg-[radial-gradient(circle_at_center,_var(--tw-gradient-stops))] from-foreground/5 via-transparent to-transparent opacity-100" />
<div className="absolute inset-0 bg-[url('https://grainy-gradients.vercel.app/noise.svg')] opacity-20 mix-blend-overlay" />
{/* Floating Settings Trigger */}
<button
onClick={() => setShowSettings(!showSettings)}
aria-label="Toggle Settings"
className="absolute top-6 right-6 z-50 p-3 rounded-full bg-foreground/5 hover:bg-foreground/10 transition-colors border border-border/50 backdrop-blur-md group"
>
<Settings className="h-5 w-5 text-foreground/50 group-hover:text-foreground transition-colors" />
</button>
{/* Main Container */}
<div className="relative z-10 flex flex-col items-center justify-center h-full w-full px-6 py-8">
{/* Visualizer Area */}
<div className="relative flex-1 w-full flex items-center justify-center">
<AnimatePresence mode="wait">
{isListening ? (
<motion.div
key="active"
initial={{ scale: 0.9, opacity: 0, filter: "blur(8px)" }}
animate={{ scale: 1, opacity: 1, filter: "blur(0px)" }}
exit={{ scale: 0.9, opacity: 0, filter: "blur(8px)" }}
transition={{ type: "spring", bounce: 0, duration: 0.6 }}
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.02 }}
whileTap={{ scale: 0.98 }}
transition={{ type: "spring", bounce: 0, duration: 0.4 }}
className="relative h-32 w-32 rounded-full bg-card backdrop-blur-2xl flex items-center justify-center border border-border group cursor-pointer"
>
<div className="absolute inset-0 rounded-full border border-foreground/10 animate-ping opacity-20 duration-1000" />
<Mic className="h-10 w-10 text-foreground/40 group-hover:text-foreground transition-colors" />
</motion.button>
)}
</AnimatePresence>
</div>
{/* Dynamic Status Text (Clean Typography, No Icons) */}
<div className="h-16 flex items-center justify-center overflow-hidden mb-4">
<AnimatePresence mode="wait">
<motion.div
key={isListening ? "listening" : "idle"}
initial={{ y: 15, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: -15, opacity: 0 }}
transition={{ type: "spring", bounce: 0, duration: 0.4 }}
className="flex flex-col items-center gap-1.5"
>
{isListening ? (
<>
<p className="text-base font-medium tracking-tight">
Listening & Processing...
</p>
<p className="text-[11px] text-muted-foreground font-mono tracking-wider uppercase">Process ID: 8X-291</p>
</>
) : (
<p className="text-sm font-medium text-muted-foreground tracking-wide">Tap microphone to initiate</p>
)}
</motion.div>
</AnimatePresence>
</div>
{/* Premium Control Dock */}
<div className="relative z-20 w-full max-w-md mx-auto">
<motion.div
className="relative flex items-center justify-between gap-4 rounded-full border border-border bg-card/80 p-2.5 px-5 backdrop-blur-2xl ring-1 ring-foreground/5"
initial={{ y: 20, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={{ type: "spring", bounce: 0, duration: 0.6, delay: 0.1 }}
>
{/* Play/Pause Area */}
<div className="flex items-center gap-4 w-1/2">
<button
onClick={togglePlayback}
aria-label={playbackState === "playing" ? "Pause" : "Play"}
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-foreground text-background hover:scale-105 active:scale-95 transition-all"
>
{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.5 w-full">
<span className="text-[11px] font-semibold tracking-wide text-foreground/70 uppercase">AI Assistant</span>
<div className="h-1 w-full 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>
{/* Micro-Separator */}
<div className="h-8 w-[1px] bg-border" />
{/* Tools Area */}
<div className="flex items-center justify-end gap-3 w-1/2">
<button
onClick={() => setIsMuted(!isMuted)}
aria-label="Toggle Mute"
className="p-2 rounded-full hover:bg-foreground/5 text-foreground/50 hover:text-foreground transition-colors"
>
{isMuted ? <VolumeX className="h-4 w-4" /> : <Volume2 className="h-4 w-4" />}
</button>
{/* Mode Toggles - Typography over Icons */}
<div className="flex bg-foreground/5 rounded-full p-1 border border-border/50">
<button
onClick={() => setMode("siri")}
className={cn(
"px-3 py-1 rounded-full text-[11px] font-medium tracking-wide transition-all duration-300",
mode === "siri" ? "text-background bg-foreground" : "text-foreground/50 hover:text-foreground"
)}
>
Wave
</button>
<button
onClick={() => setMode("blob")}
className={cn(
"px-3 py-1 rounded-full text-[11px] font-medium tracking-wide transition-all duration-300",
mode === "blob" ? "text-background bg-foreground" : "text-foreground/50 hover:text-foreground"
)}
>
Fluid
</button>
</div>
</div>
</motion.div>
</div>
{/* Settings Panel Overlay */}
<AnimatePresence>
{showSettings && (
<motion.div
initial={{ opacity: 0, y: 20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.95 }}
transition={{ type: "spring", bounce: 0, duration: 0.5 }}
className="absolute top-20 right-6 w-80 bg-card border border-border rounded-2xl p-5 z-50 backdrop-blur-2xl ring-1 ring-foreground/5"
>
<div className="flex justify-between items-center mb-5">
<h3 className="text-sm font-semibold">Voice Preferences</h3>
<button onClick={() => setShowSettings(false)} className="p-1.5 hover:bg-foreground/5 rounded-full transition-colors"><X className="h-4 w-4 text-muted-foreground" /></button>
</div>
<div className="space-y-6">
<div>
<label className="text-[10px] font-semibold text-muted-foreground mb-2.5 block uppercase tracking-widest">Personality</label>
<div className="grid grid-cols-3 gap-2">
{['friendly', 'professional', 'robot'].map((type) => (
<button
key={type}
onClick={() => setVoiceType(type as VoiceType)}
className={cn(
"px-2 py-2 rounded-lg text-xs font-medium capitalize transition-all duration-200 border",
voiceType === type
? "bg-foreground text-background border-foreground"
: "bg-foreground/5 text-foreground/60 border-transparent hover:bg-foreground/10 hover:text-foreground"
)}
>
{type}
</button>
))}
</div>
</div>
<div>
<div className="flex justify-between mb-2">
<label className="text-[10px] font-semibold text-muted-foreground uppercase tracking-widest">Mic Sensitivity</label>
<span className="text-[10px] font-mono text-foreground/70">{volume}%</span>
</div>
<div className="relative h-1.5 bg-foreground/10 rounded-full overflow-hidden">
<motion.div
className="absolute top-0 left-0 h-full bg-foreground 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: VoiceType }) {
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-foreground/50", "bg-foreground/40", "bg-foreground/30", "bg-foreground/20"]
return (
<div className="relative h-64 w-64 md:h-80 md:w-80 flex items-center justify-center">
<div className="absolute inset-0 rounded-full bg-foreground/5 blur-3xl animate-pulse" />
<div className="relative h-full w-full flex items-center justify-center mix-blend-screen dark:mix-blend-screen light:mix-blend-multiply">
{[0, 1, 2, 3].map((i) => (
<motion.div
key={i}
animate={{
scale: [1, 1.05 + (i * 0.05), 1],
rotate: [0, i % 2 === 0 ? 360 : -360],
x: [0, (i % 2 === 0 ? 15 : -15), 0],
y: [0, (i % 2 === 0 ? -15 : 15), 0],
}}
transition={{
duration: 4 + i,
repeat: Infinity,
ease: "easeInOut",
times: [0, 0.5, 1]
}}
className={cn(
"absolute rounded-full blur-2xl opacity-60 dark:mix-blend-screen light:mix-blend-multiply",
colors[i],
i === 0 ? "h-56 w-56" : i === 1 ? "h-48 w-48" : i === 2 ? "h-36 w-36" : "h-24 w-24"
)}
/>
))}
{/* Core Glow */}
<div className="absolute h-16 w-16 bg-background blur-xl opacity-80 animate-pulse" />
</div>
</div>
)
}
function LiquidBlob({ voiceType }: { voiceType: VoiceType }) {
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-64 w-64 md:h-80 md:w-80 flex items-center justify-center filter url(#goo)">
<svg style={{ position: 'absolute', width: 0, height: 0 }}>
<defs>
<filter id="goo">
<feGaussianBlur in="SourceGraphic" stdDeviation="12" 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%", "45% 55% 65% 35% / 45% 45% 55% 55%", "55% 45% 35% 65% / 55% 35% 65% 45%", "50%"],
rotate: [0, 360],
scale: [1, 1.05, 0.95, 1]
}}
transition={{ duration: 12, repeat: Infinity, ease: "linear" }}
className={`absolute h-48 w-48 bg-gradient-to-br ${gradient} opacity-80 blur-[2px]`}
/>
{/* Satellite Blobs */}
{[...Array(3)].map((_, i) => (
<motion.div
key={i}
animate={{
x: [0, (Math.random() - 0.5) * 80, 0],
y: [0, (Math.random() - 0.5) * 80, 0],
scale: [0.8, 1.1, 0.8]
}}
transition={{
duration: 6 + Math.random() * 2,
repeat: Infinity,
ease: "easeInOut",
delay: i * 0.4
}}
className={`absolute h-12 w-12 bg-gradient-to-br ${gradient} opacity-70 blur-[2px] rounded-full`}
/>
))}
</div>
)
}Dependencies
framer-motion: latestlucide-react: latestclsx: latesttailwind-merge: latest
Props
Component property reference.
| Name | Type | Default | Description |
|---|---|---|---|
| className | string | undefined | Additional CSS classes for the container. |
| initialMode | 'siri' | 'blob' | 'siri' | Initial visualization mode. |
| onListeningChange | (listening: boolean) => void | undefined | Callback when listening state toggles. |
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.