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/widgetBasic 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:
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
publishableKey | string | Yes | -- | Publishable API key (pk_live_...). The agent is resolved automatically from the key's widget configuration. |
apiBase | string | No | 'https://api.thunderphone.com/v1' | API base URL override. |
language | string | No | -- | Per-session language override -- a language code or locale such as en, es, or fr-FR. When unset, the agent's configured language applies. |
voice | string | No | -- | Per-session voice override -- a voice name such as maria. When unset, the agent's configured voice applies. |
context | string | No | -- | Per-session factual page or site context passed to the agent. Truncated server-side to 12,000 characters. |
onConnect | () => void | No | -- | Called when the voice session connects. |
onDisconnect | () => void | No | -- | Called when the session ends. |
onError | (error) => void | No | -- | Called on errors. Error has error (code) and message fields. |
ringtone | boolean | string | No | false | Play a ringtone while connecting. true for the default ringtone, or a URL string for custom audio. |
Return Value
The hook returns a UseThunderPhoneReturn object:
| Property | Type | Description |
|---|---|---|
state | 'idle' | 'connecting' | 'connected' | 'disconnected' | 'error' | Current connection state. |
connect | () => void | Start a voice session. |
disconnect | () => void | End the current session. |
toggleMute | () => void | Toggle microphone mute on/off. |
isMuted | boolean | Whether the microphone is currently muted. |
error | string | undefined | Error message when state is 'error'. |
agentName | string | undefined | Display name of the connected agent. |
audioLevel | number | Deprecated -- always 0. A static placeholder kept for backwards compatibility; it never updates. Read audioLevelRef.current instead. |
audioLevelRef | React.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. |
audio | ReactNode | Invisible 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)
| State | Description |
|---|---|
idle | No active session. Ready to call connect(). |
connecting | Session is being established. Disable the call button during this state. |
connected | Voice session is active. The user is talking to the agent. |
disconnected | Session has ended cleanly. Transitions back to idle automatically after 1.5 seconds. |
error | Something 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.