ThunderPhone 2.0 is live.Self-serve, from 2¢/min.Read the announcement

Widget

Headless Hook

Build a fully custom voice UI with the useThunderPhone React hook

The useThunderPhone hook gives you complete control over the user interface while ThunderPhone manages the voice session, audio routing, and connection state. Use it when you want a fully custom UI -- your own buttons, layouts, animations, and branding -- while ThunderPhone handles everything under the hood.

When to Use the Headless Hook

The pre-built ThunderPhoneWidget component covers most use cases, but reach for the headless hook when you need:

  • A completely custom call UI that matches your app's design system
  • Audio-reactive visualizations (waveforms, orbs, pulsing indicators) driven by real-time audio levels
  • Custom call flows such as pre-call forms, post-call surveys, or inline chat alongside voice
  • Integration into an existing component library (Material UI, Chakra, Radix, etc.)

Installation

npm install @thunderphone/widget

Basic Usage

import { useThunderPhone } from '@thunderphone/widget'
 
function CustomCallButton() {
  const phone = useThunderPhone({
    publishableKey: 'pk_live_your_publishable_key',
  })
 
  const handleClick = () => {
    if (phone.state === 'connected') {
      phone.disconnect()
    } else {
      phone.connect()
    }
  }
 
  return (
    <>
      <button onClick={handleClick} disabled={phone.state === 'connecting'}>
        {phone.state === 'connecting'
          ? 'Connecting...'
          : phone.state === 'connected'
            ? 'End call'
            : 'Start call'}
      </button>
      {phone.audio}
    </>
  )
}

Options

Pass these options to useThunderPhone via UseThunderPhoneOptions:

OptionTypeRequiredDefaultDescription
publishableKeystringYes--Publishable API key (pk_live_...). The agent is resolved automatically from the key's widget configuration.
apiBasestringNo'https://api.thunderphone.com/v1'API base URL override.
languagestringNo--Per-session language override -- a language code or locale such as en, es, or fr-FR. When unset, the agent's configured language applies.
voicestringNo--Per-session voice override -- a voice name such as maria. When unset, the agent's configured voice applies.
contextstringNo--Per-session factual page or site context passed to the agent. Truncated server-side to 12,000 characters.
onConnect() => voidNo--Called when the voice session connects.
onDisconnect() => voidNo--Called when the session ends.
onError(error) => voidNo--Called on errors. Error has error (code) and message fields.
ringtoneboolean | stringNofalsePlay a ringtone while connecting. true for the default ringtone, or a URL string for custom audio.

Return Value

The hook returns a UseThunderPhoneReturn object:

PropertyTypeDescription
state'idle' | 'connecting' | 'connected' | 'disconnected' | 'error'Current connection state.
connect() => voidStart a voice session.
disconnect() => voidEnd the current session.
toggleMute() => voidToggle microphone mute on/off.
isMutedbooleanWhether the microphone is currently muted.
errorstring | undefinedError message when state is 'error'.
agentNamestring | undefinedDisplay name of the connected agent.
audioLevelnumberDeprecated -- always 0. A static placeholder kept for backwards compatibility; it never updates. Read audioLevelRef.current instead.
audioLevelRefReact.RefObject<number>A mutable ref containing the real-time audio level (0--1) -- the louder of the agent's voice and the visitor's microphone -- updated on every animation frame, outside of React's render cycle. Read audioLevelRef.current inside requestAnimationFrame loops for smooth, jank-free animations, or sample it on an interval when you need the value in React state.
audioReactNodeInvisible element that handles the audio connection -- must be rendered.

Audio-Reactive UI

The audioLevelRef ref gives you frame-rate audio levels without triggering React re-renders, making it ideal for driving smooth waveform visualizations, pulsing orbs, or any animation tied to the conversation. The level reflects whichever is louder: the agent's voice or the visitor's microphone.

Waveform Example

import { useRef, useEffect } from 'react'
import { useThunderPhone } from '@thunderphone/widget'
 
function WaveformCall() {
  const phone = useThunderPhone({
    publishableKey: 'pk_live_your_publishable_key',
  })
  const canvasRef = useRef<HTMLCanvasElement>(null)
 
  useEffect(() => {
    if (phone.state !== 'connected') return
    const canvas = canvasRef.current
    if (!canvas) return
    const ctx = canvas.getContext('2d')!
 
    let animId: number
    const draw = () => {
      const level = phone.audioLevelRef.current ?? 0
      ctx.clearRect(0, 0, canvas.width, canvas.height)
 
      // Draw bars that react to audio level
      const barCount = 24
      const barWidth = canvas.width / barCount
      for (let i = 0; i < barCount; i++) {
        const distance = Math.abs(i - barCount / 2) / (barCount / 2)
        const height = level * canvas.height * (1 - distance * 0.6)
        const y = (canvas.height - height) / 2
        ctx.fillStyle = '#0ea5e9'
        ctx.fillRect(i * barWidth + 1, y, barWidth - 2, height)
      }
 
      animId = requestAnimationFrame(draw)
    }
    animId = requestAnimationFrame(draw)
    return () => cancelAnimationFrame(animId)
  }, [phone.state, phone.audioLevelRef])
 
  return (
    <div>
      {phone.state === 'connected' && (
        <canvas ref={canvasRef} width={240} height={80} />
      )}
      <button
        onClick={phone.state === 'connected' ? phone.disconnect : phone.connect}
        disabled={phone.state === 'connecting'}
      >
        {phone.state === 'connected' ? 'End call' : 'Start call'}
      </button>
      {phone.audio}
    </div>
  )
}

Pulsing Orb Example

import { useRef, useEffect } from 'react'
import { useThunderPhone } from '@thunderphone/widget'
 
function PulsingOrb() {
  const phone = useThunderPhone({
    publishableKey: 'pk_live_your_publishable_key',
  })
  const orbRef = useRef<HTMLDivElement>(null)
 
  useEffect(() => {
    if (phone.state !== 'connected') return
    let animId: number
    const animate = () => {
      const level = phone.audioLevelRef.current ?? 0
      if (orbRef.current) {
        const scale = 1 + level * 0.5
        orbRef.current.style.transform = `scale(${scale})`
        orbRef.current.style.opacity = `${0.6 + level * 0.4}`
      }
      animId = requestAnimationFrame(animate)
    }
    animId = requestAnimationFrame(animate)
    return () => cancelAnimationFrame(animId)
  }, [phone.state, phone.audioLevelRef])
 
  return (
    <div style={{ textAlign: 'center' }}>
      <div
        ref={orbRef}
        style={{
          width: 80,
          height: 80,
          borderRadius: '50%',
          background: '#0ea5e9',
          margin: '20px auto',
          transition: 'transform 0.05s ease-out',
        }}
      />
      <button
        onClick={phone.state === 'connected' ? phone.disconnect : phone.connect}
        disabled={phone.state === 'connecting'}
      >
        {phone.state === 'connected' ? 'End call' : 'Call'}
      </button>
      {phone.audio}
    </div>
  )
}

Speaking Indicator Example

For React-rendered UI that changes with volume -- like a threshold-based "speaking" badge -- sample audioLevelRef.current on an interval and store the result in state:

import { useEffect, useState } from 'react'
import { useThunderPhone } from '@thunderphone/widget'
 
function SpeakingBadge() {
  const phone = useThunderPhone({
    publishableKey: 'pk_live_your_publishable_key',
  })
  const [speaking, setSpeaking] = useState(false)
 
  useEffect(() => {
    if (phone.state !== 'connected') {
      setSpeaking(false)
      return
    }
    const interval = setInterval(() => {
      setSpeaking((phone.audioLevelRef.current ?? 0) > 0.1)
    }, 100)
    return () => clearInterval(interval)
  }, [phone.state, phone.audioLevelRef])
 
  return (
    <div>
      {phone.state === 'connected' && (
        <span>{speaking ? 'Speaking' : 'Listening'}</span>
      )}
      <button onClick={phone.state === 'connected' ? phone.disconnect : phone.connect}>
        {phone.state === 'connected' ? 'End call' : 'Start call'}
      </button>
      {phone.audio}
    </div>
  )
}

State Machine

The state property follows this lifecycle:

idle --> connecting --> connected --> disconnected --> idle (after 1.5s)
                  \
                   --> error (stays until connect() is called again)
StateDescription
idleNo active session. Ready to call connect().
connectingSession is being established. Disable the call button during this state.
connectedVoice session is active. The user is talking to the agent.
disconnectedSession has ended cleanly. Transitions back to idle automatically after 1.5 seconds.
errorSomething went wrong. Check phone.error for the message. The state does not clear on its own -- calling connect() again starts a fresh attempt and resets the error.

Examples

With Mute Control

import { useThunderPhone } from '@thunderphone/widget'
 
function CallWithMute() {
  const phone = useThunderPhone({
    publishableKey: 'pk_live_your_publishable_key',
  })
 
  return (
    <div>
      {phone.state === 'connected' && (
        <div>
          <p>Talking to {phone.agentName ?? 'Agent'}</p>
          <button onClick={phone.toggleMute}>
            {phone.isMuted ? 'Unmute' : 'Mute'}
          </button>
          <button onClick={phone.disconnect}>End call</button>
        </div>
      )}
 
      {phone.state !== 'connected' && (
        <button
          onClick={phone.connect}
          disabled={phone.state === 'connecting'}
        >
          {phone.state === 'connecting' ? 'Connecting...' : 'Call support'}
        </button>
      )}
 
      {phone.state === 'error' && (
        <p style={{ color: 'red' }}>{phone.error}</p>
      )}
 
      {phone.audio}
    </div>
  )
}

With Ringtone

Play a ringing sound while connecting to simulate a phone call:

import { useThunderPhone } from '@thunderphone/widget'
 
function PhoneCallButton() {
  const phone = useThunderPhone({
    publishableKey: 'pk_live_your_publishable_key',
    ringtone: true, // or a custom URL: 'https://example.com/ringtone.mp3'
  })
 
  return (
    <>
      <button
        onClick={phone.state === 'connected' ? phone.disconnect : phone.connect}
        disabled={phone.state === 'connecting'}
      >
        {phone.state === 'connecting'
          ? 'Ringing...'
          : phone.state === 'connected'
            ? 'Hang up'
            : 'Call'}
      </button>
      {phone.audio}
    </>
  )
}

The ringtone loops during the connecting state and fades out when the agent connects. Pass true for the built-in default ringtone, or a URL string to use your own audio file.

With Event Callbacks

import { useThunderPhone } from '@thunderphone/widget'
 
function TrackedCallButton() {
  const phone = useThunderPhone({
    publishableKey: 'pk_live_your_publishable_key',
    onConnect: () => {
      analytics.track('call_started')
    },
    onDisconnect: () => {
      analytics.track('call_ended')
    },
    onError: (error) => {
      analytics.track('call_error', { code: error.error, message: error.message })
    },
  })
 
  return (
    <>
      <button
        onClick={phone.state === 'connected' ? phone.disconnect : phone.connect}
        disabled={phone.state === 'connecting'}
      >
        {phone.state === 'connected' ? 'Hang up' : 'Talk to AI'}
      </button>
      {phone.audio}
    </>
  )
}

Full Custom UI

import { useThunderPhone } from '@thunderphone/widget'
 
function FullCustomUI() {
  const phone = useThunderPhone({
    publishableKey: 'pk_live_your_publishable_key',
  })
 
  return (
    <div className="call-panel">
      <div className="call-status">
        {phone.state === 'idle' && <span>Ready</span>}
        {phone.state === 'connecting' && <span className="pulse">Connecting...</span>}
        {phone.state === 'connected' && (
          <span>On call with {phone.agentName}</span>
        )}
        {phone.state === 'error' && <span className="error">{phone.error}</span>}
      </div>
 
      <div className="call-controls">
        {phone.state === 'connected' ? (
          <>
            <button className="mute-btn" onClick={phone.toggleMute}>
              {phone.isMuted ? 'Unmute' : 'Mute'}
            </button>
            <button className="end-btn" onClick={phone.disconnect}>
              End
            </button>
          </>
        ) : (
          <button
            className="start-btn"
            onClick={phone.connect}
            disabled={phone.state === 'connecting'}
          >
            Start call
          </button>
        )}
      </div>
 
      {/* Required -- handles audio under the hood */}
      {phone.audio}
    </div>
  )
}

Tips

Always render phone.audio

The phone.audio element is invisible but required. Place it anywhere in your JSX -- it renders no visible DOM but manages the WebRTC audio connection internally.

Disable the button while connecting

The connecting state can last 1-3 seconds. Disable the call button during this state to prevent duplicate connection attempts.

Handle the error state gracefully

When the state is error, display phone.error to the user and keep your call button enabled. The hook does not leave the error state on its own -- calling connect() again starts a fresh attempt and clears the previous error.

Use callbacks for side effects

The onConnect, onDisconnect, and onError callbacks are ideal for analytics, logging, or triggering other application logic without polling the state.

Read audio levels from audioLevelRef

audioLevelRef is the only live audio-level source. Read audioLevelRef.current inside requestAnimationFrame for smooth animations like waveforms (reading a ref does not cause re-renders), or sample it on an interval and store the result in state for React-rendered UI. The audioLevel number is deprecated and always 0 -- do not build logic on it.