Skip to main content
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
The headless hook does not require importing @thunderphone/widget/style.css since you are providing your own UI. However, you must still install the same @thunderphone/widget package.

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}
    </>
  )
}
You must render phone.audio somewhere in your component tree. It is an invisible React element that manages the underlying audio connection. If you omit it, no audio will play and the session will not work.

Options

Pass these options to useThunderPhone via UseThunderPhoneOptions:
OptionTypeRequiredDefaultDescription
publishableKeystringYesPublishable API key (pk_live_...). The agent is resolved automatically from the key’s widget configuration.
theme'light' | 'dark'No'light'Color scheme hint. Does not affect headless rendering, but is available for your custom UI logic.
primaryColorstringNo'#6366f1'Accent color hint. Use this in your custom UI to stay consistent with widget theming.
titlestringNo'Voice assistant'Title hint. Use this in your custom UI to display a label.
position'bottom-right' | 'bottom-left' | 'top-right' | 'top-left'No'bottom-right'Position hint. Use this in your custom UI if you want positional awareness.
apiBasestringNo'https://api.thunderphone.com/v1'API base URL override.
onConnect() => voidNoCalled when the voice session connects.
onDisconnect() => voidNoCalled when the session ends.
onError(error) => voidNoCalled 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.
audioLevelnumberCurrent audio level snapshot (0—1). Updates on each React render cycle. Suitable for non-animation use cases like level indicators or threshold checks.
audioLevelRefReact.RefObject<number>A React ref containing the real-time audio level (0—1), updated at ~60 fps outside of React’s render cycle. Use this inside requestAnimationFrame loops for smooth, jank-free animations.
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 agent’s voice.

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 = '#6366f1'
        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: '#6366f1',
          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>
  )
}
Use audioLevelRef (the ref) inside requestAnimationFrame for smooth animations. Use audioLevel (the number) when you want the level in normal React rendering — e.g., for conditional styling or threshold-based logic.

State Machine

The state property follows this lifecycle:
idle --> connecting --> connected --> disconnected --> idle
                  \
                   --> error --> idle (after timeout)
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.
errorSomething went wrong. Check phone.error for the message. Automatically returns to idle after a timeout.

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

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.
The connecting state can last 1-3 seconds. Disable the call button during this state to prevent duplicate connection attempts.
When the state is error, display phone.error to the user. The state will automatically return to idle after a few seconds, so the user can try again.
The onConnect, onDisconnect, and onError callbacks are ideal for analytics, logging, or triggering other application logic without polling the state.
Use audioLevel (the number) for React-rendered UI that changes based on volume — e.g., a threshold-based “speaking” badge. Use audioLevelRef (the ref) inside requestAnimationFrame for smooth 60fps animations like waveforms, since reading a ref does not cause re-renders.