Back to resources
UI component / Voice

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
01 / Set up

Before you
begin.

Install the dependencies and connect your Vapi assistant.

Terminal
npm install @vapi-ai/web lucide-react

Your environment

.env.local
NEXT_PUBLIC_VAPI_API_KEY=your_vapi_public_key
NEXT_PUBLIC_VAPI_ASSISTANT_ID=your_assistant_id

Use 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.

02 / Make it yours

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.

03 / Put it to work

One simple
import.

Add the component to your page. Its layout adapts to the available space.

page.tsx
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.

Beyond the component

A voice for
your business.

Need an agent built around your workflows? Let’s talk about what a useful conversation looks like for your team.