Voice,with a human touch.
A minimal React interface for conversations powered by Vapi. Explore the setup and code, then make the component your own.
Explore the code- React + Next.js
- Vapi integration
- CSS Modules
Before you
begin.
Install the dependencies and connect your Vapi assistant.
npm install @vapi-ai/web lucide-reactYour environment
NEXT_PUBLIC_VAPI_API_KEY=your_vapi_public_key
NEXT_PUBLIC_VAPI_ASSISTANT_ID=your_assistant_idUse your Vapi public key, never a private API key. Add these values to .env.local, then restart your development server.
This example is configured for Next.js. In Vite, use import.meta.env.VITE_… and update the environment lookups in the component. Create React App uses the REACT_APP_ prefix.
The
component.
Save these files together as VoiceAgentComponent.tsx and VoiceAgentComponent.module.css.
"use client";
import { useEffect, useRef, useState } from "react";
import { Mic, PhoneOff } from "lucide-react";
import Vapi from "@vapi-ai/web";
import styles from "./VoiceAgentComponent.module.css";
type VoiceStatus = "READY" | "CONNECTING" | "ACTIVE" | "SPEAKING" | "LISTENING" | "ENDING" | "ERROR";
const statusLabels: Record<VoiceStatus, string> = {
READY: "Ready to connect",
CONNECTING: "Connecting",
ACTIVE: "Conversation active",
SPEAKING: "Agent speaking",
LISTENING: "Listening",
ENDING: "Ending conversation",
ERROR: "Unable to connect",
};
const waveform = [14, 28, 46, 32, 62, 88, 52, 74, 40, 56, 26, 16];
export default function VoiceAgentComponent({ className }: { className?: string }) {
const [isVoiceActive, setIsVoiceActive] = useState(false);
const [status, setStatus] = useState<VoiceStatus>("READY");
const [error, setError] = useState("");
const vapi = useRef<Vapi | null>(null);
const busy = useRef(false);
useEffect(() => {
const instance = new Vapi(process.env.NEXT_PUBLIC_VAPI_API_KEY || "");
vapi.current = instance;
instance.on("call-start", () => {
busy.current = false;
setIsVoiceActive(true);
setStatus("ACTIVE");
});
instance.on("call-end", () => {
busy.current = false;
setIsVoiceActive(false);
setStatus("READY");
});
instance.on("speech-start", () => setStatus("SPEAKING"));
instance.on("speech-end", () => setStatus("LISTENING"));
instance.on("error", () => {
busy.current = false;
setStatus("ERROR");
setError("The voice connection could not be established. Check microphone permissions or try again.");
});
return () => {
instance.removeAllListeners();
instance.stop();
vapi.current = null;
};
}, []);
const toggleVoice = async () => {
if (!vapi.current || busy.current) return;
setError("");
if (isVoiceActive) {
busy.current = true;
setStatus("ENDING");
vapi.current.stop();
return;
}
if (!process.env.NEXT_PUBLIC_VAPI_API_KEY || !process.env.NEXT_PUBLIC_VAPI_ASSISTANT_ID) {
setStatus("ERROR");
setError("The voice preview is not configured yet. Add the Vapi public key and assistant ID to enable it.");
return;
}
try {
busy.current = true;
setStatus("CONNECTING");
const call = await vapi.current.start(process.env.NEXT_PUBLIC_VAPI_ASSISTANT_ID);
if (!call) {
busy.current = false;
setStatus("ERROR");
setError("The conversation could not start. Check microphone permissions or try again.");
}
} catch {
busy.current = false;
setStatus("ERROR");
setError("The conversation could not start. Check microphone permissions or try again.");
}
};
return (
<div className={`${styles.preview} ${className || ""}`}>
<span className={styles.status} role="status">{statusLabels[status]}</span>
<svg className={styles.waveform} viewBox="0 0 240 140" fill="none" aria-hidden="true" data-active={isVoiceActive}>
<path d="M24 70h192" stroke="#c2cbbf" />
{waveform.map((height, index) => <path key={index} d={`M${54 + index * 12} ${70 - height / 2}v${height}`} stroke="currentColor" strokeWidth="4" strokeLinecap="round" />)}
</svg>
<p className={styles.prompt}>{isVoiceActive ? "You’re connected." : "A conversation starts here."}</p>
<button type="button" className={styles.control} onClick={toggleVoice} disabled={status === "CONNECTING" || status === "ENDING"}>
{isVoiceActive ? <PhoneOff size={18} aria-hidden="true" /> : <Mic size={18} aria-hidden="true" />}
{status === "CONNECTING" ? "Connecting…" : status === "ENDING" ? "Ending…" : isVoiceActive ? "End conversation" : "Start conversation"}
</button>
<p className={styles.note}>Microphone access begins only when you start a conversation.</p>
{error && <p className={styles.error} role="alert">{error}</p>}
</div>
);
}
The interface shows connection and conversation states. Start and stop controls use Vapi, and the connection is cleaned up when the component unmounts.
One simple
import.
Add the component to your page. Its layout adapts to the available space.
import VoiceAgentComponent from "@/components/VoiceAgentComponent";
export default function Page() {
return <VoiceAgentComponent />;
}
No Tailwind dependency is needed for the component styling. It inherits the surrounding font, or uses a sans-serif fallback.

