Voice Agent Interface

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.

Preview

INITIALIZING
Tap to talk

Code

"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:

  • Next.js: Add to .env.local
  • Vite/React: Add to .env (prefix with VITE_ instead of NEXT_PUBLIC_)
  • Create React App: Add to .env (prefix with REACT_APP_)

Then restart your development server.

Usage

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.