This component renders an interactive voice-enabled AI interface powered by Vapi. Click the mic button to start a conversation. The interface visualizes activity in real time and shows system metrics.
"use client";
import React, { useState, useEffect, useRef } from "react";
import { Bot, Mic, X } from "lucide-react";
import Vapi from "@vapi-ai/web";
import clsx from "clsx";
interface VoiceAgentProps {
className?: string;
}
const VoiceAgentComponent: React.FC<VoiceAgentProps> = ({ className }) => {
const [isVoiceActive, setIsVoiceActive] = useState(false);
const [isListening, setIsListening] = useState(false);
const vapi = useRef<Vapi | null>(null);
const [particleStyles, setParticleStyles] = useState<React.CSSProperties[]>([]);
const [systemStatus, setSystemStatus] = useState("INITIALIZING");
const [neuralLoad, setNeuralLoad] = useState(87);
const [responseTime, setResponseTime] = useState(24);
const [accuracy, setAccuracy] = useState(99.9);
// Fake system metrics
useEffect(() => {
const interval = setInterval(() => {
setNeuralLoad(Math.floor(Math.random() * 20 + 70));
setResponseTime(Math.floor(Math.random() * 50 + 10));
setAccuracy(parseFloat((Math.random() * 0.5 + 99.5).toFixed(1)));
}, 2000);
return () => clearInterval(interval);
}, []);
// Particles
useEffect(() => {
const styles = Array.from({ length: 20 }).map(() => ({
width: `${Math.random() * 10 + 2}px`,
height: `${Math.random() * 10 + 2}px`,
top: `${Math.random() * 100}%`,
left: `${Math.random() * 100}%`,
opacity: Math.random() * 0.5 + 0.2,
animation: `float ${Math.random() * 10 + 10}s linear infinite`,
animationDelay: `${Math.random() * 5}s`,
}));
setParticleStyles(styles);
setSystemStatus("INITIALIZING");
setTimeout(() => setSystemStatus("CALIBRATING"), 1000);
setTimeout(() => setSystemStatus("READY"), 2000);
}, []);
// Vapi setup
useEffect(() => {
vapi.current = new Vapi(process.env.NEXT_PUBLIC_VAPI_API_KEY || "");
vapi.current.on("call-start", () => {
setIsVoiceActive(true);
setSystemStatus("ACTIVE");
});
vapi.current.on("call-end", () => {
setIsVoiceActive(false);
setIsListening(false);
setSystemStatus("READY");
});
vapi.current.on("speech-start", () => {
setIsListening(false);
setSystemStatus("LISTENING");
});
vapi.current.on("message", (transcript) => {
if (transcript.role === "user") {
setIsListening(true);
setSystemStatus("PROCESSING");
setTimeout(() => {
setIsListening(false);
setSystemStatus("ACTIVE");
}, 2000);
}
});
return () => {
if (vapi.current) {
vapi.current.stop();
vapi.current.removeAllListeners();
}
};
}, []);
const toggleVoice = async () => {
if (!vapi.current) return;
if (isVoiceActive) {
setSystemStatus("ENDING");
await vapi.current.stop();
} else {
try {
setSystemStatus("CONNECTING");
await vapi.current.start(process.env.NEXT_PUBLIC_VAPI_ASSISTANT_ID || "");
} catch (error) {
console.error("Error starting voice call:", error);
setSystemStatus("ERROR");
}
}
};
const getStatusColor = () => {
switch (systemStatus) {
case "INITIALIZING": return "bg-yellow-400";
case "CALIBRATING": return "bg-blue-400";
case "READY": return "bg-emerald-400";
case "CONNECTING": return "bg-indigo-400";
case "ACTIVE": return "bg-pink-400";
case "LISTENING": return "bg-cyan-400";
case "PROCESSING": return "bg-purple-400";
case "ENDING": return "bg-gray-400";
case "ERROR": return "bg-red-500";
default: return "bg-slate-400";
}
};
return (
<div
className={clsx(
"relative rounded-3xl overflow-hidden bg-gradient-to-br from-gray-900 via-indigo-950 to-gray-950 border border-indigo-500/20",
className
)}
>
{/* Background grid */}
<div
className="absolute inset-0 opacity-20"
style={{
backgroundImage: `url("data:image/svg+xml,%3Csvg width='60' height='60' viewBox='0 0 60 60' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M0 30 L15 0 L45 0 L60 30 L45 60 L15 60' fill='none' stroke='%234F46E5' stroke-width='1'/%3E%3C/svg%3E")`,
backgroundSize: "60px 60px",
}}
></div>
{/* Gradient overlay */}
<div className="absolute inset-0 bg-gradient-to-br from-indigo-600/10 via-purple-600/10 to-pink-600/10 animate-pulse"></div>
{/* Particles */}
<div className="absolute inset-0 overflow-hidden">
{particleStyles.map((style, i) => (
<div
key={i}
className="absolute rounded-full bg-gradient-to-r from-indigo-500/30 to-purple-500/30 backdrop-blur-sm"
style={style}
/>
))}
</div>
{/* Central bot */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="relative w-full h-full max-w-[300px] max-h-[300px]">
{/* Rings */}
<div className="absolute inset-0 animate-spin-slow">
<div className="absolute inset-0 rounded-full border-2 border-indigo-500/20" />
</div>
<div className="absolute inset-0 animate-spin-medium">
<div
className="absolute inset-0 rounded-full border-2 border-purple-500/20"
style={{ transform: "rotate(60deg)" }}
/>
</div>
<div className="absolute inset-0 animate-spin-fast">
<div
className="absolute inset-0 rounded-full border-2 border-pink-500/20"
style={{ transform: "rotate(-45deg)" }}
/>
</div>
{/* Core */}
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2">
<div className="relative group">
<div className={`absolute -inset-6 rounded-full border-2 border-indigo-500/40 animate-pulse ${isVoiceActive ? "opacity-100" : "opacity-0"} transition-opacity duration-500`}></div>
<div className={`absolute -inset-10 rounded-full border-2 border-purple-500/30 animate-pulse delay-300 ${isVoiceActive ? "opacity-100" : "opacity-0"} transition-opacity duration-500`}></div>
<div className="relative w-24 h-24 rounded-full bg-gradient-to-br from-indigo-600 to-purple-700 p-1 shadow-[0_0_40px_rgba(99,102,241,0.6)] transition-all duration-500 group-hover:shadow-[0_0_60px_rgba(99,102,241,0.8)]">
<div className="w-full h-full rounded-full bg-gray-950/70 backdrop-blur-xl flex items-center justify-center relative overflow-hidden">
<Bot size={36} className={`text-indigo-400 relative z-10 transition-all duration-500 ${isVoiceActive ? "scale-110 text-purple-400" : ""}`} />
</div>
</div>
</div>
</div>
</div>
</div>
{/* Voice wave */}
{isVoiceActive && (
<div className="absolute inset-x-0 bottom-20 flex justify-center items-center space-x-1">
{[...Array(8)].map((_, i) => (
<div
key={i}
className="w-1 bg-gradient-to-t from-indigo-500 to-purple-500 rounded-full transition-all duration-300"
style={{
height: `${Math.random() * 18 + 8}px`,
animation: `wave 1s ease-in-out infinite`,
animationDelay: `${i * 0.1}s`,
opacity: isListening ? 0.8 : 0.4,
}}
/>
))}
</div>
)}
{/* Status */}
<div className="absolute top-4 left-4 flex items-center space-x-2 bg-gray-900/40 backdrop-blur-md px-3 py-1.5 rounded-full border border-indigo-500/20">
<div className={`h-2 w-2 rounded-full ${getStatusColor()} animate-pulse`}></div>
<span className="text-xs font-medium">{systemStatus}</span>
</div>
{/* Metrics */}
<div className="absolute bottom-20 right-4 bg-gray-900/40 backdrop-blur-md px-3 py-2 rounded-lg border border-indigo-500/20 hidden sm:block text-xs space-y-1">
<div className="flex justify-between"><span className="text-indigo-300">Load</span><span className="text-emerald-400">{neuralLoad}%</span></div>
<div className="flex justify-between"><span className="text-indigo-300">Resp.</span><span className="text-purple-400">{responseTime}ms</span></div>
<div className="flex justify-between"><span className="text-indigo-300">Acc.</span><span className="text-pink-400">{accuracy}%</span></div>
</div>
{/* Control button */}
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 flex flex-col items-center space-y-2">
{!isVoiceActive && <div className="text-indigo-300/70 text-xs animate-bounce">Tap to talk</div>}
<button
onClick={toggleVoice}
className={`group flex items-center justify-center w-12 h-12 rounded-full transition-all duration-500 ${isVoiceActive
? "bg-gradient-to-r from-purple-500 to-pink-500"
: "bg-gradient-to-r from-indigo-500 to-purple-500"
} hover:shadow-[0_0_20px_rgba(99,102,241,0.5)] hover:scale-110`}
>
{isVoiceActive ? <X size={20} className="text-white" /> : <Mic size={20} className="text-white" />}
</button>
</div>
</div>
);
};
export default VoiceAgentComponent;Ensure you install @vapi-ai/web and lucide-react. Add NEXT_PUBLIC_VAPI_API_KEY and NEXT_PUBLIC_VAPI_ASSISTANT_ID to your environment variables:
.env.local.env (prefix with VITE_ instead of NEXT_PUBLIC_).env (prefix with REACT_APP_)Then restart your development server.
Import the component and use it inside your Next.js pages:
import VoiceAgentComponent from "@/components/VoiceAgentComponent";
export default function Home() {
return (
<main className="flex items-center justify-center h-screen">
<VoiceAgentComponent className="w-[400px] h-[400px]" />
</main>
);
}This component uses TailwindCSS for styling.
Note: For Vite projects, ensure your vite.config.ts includes the React plugin. For Create React App, you may need to adjust the import paths.