sip-phone-lib
v0.1.8
Published
SIP softphone React component library built on SIP.js
Maintainers
Readme
sip-phone-lib
An embeddable WebRTC softphone for React. Drop a single component into any web portal to get a floating dialer (FAB), incoming-call screen, in-call controls, DTMF keypad, blind transfer, attended add-call, device selection, call history, and a self-contained ringtone — all driven by SIP.js over a WebSocket SIP proxy.
The UI is rendered as compact 320×480 cards (light/dark themes) with no external CSS or asset dependencies. Everything — including the ringtone — is generated in code.
Features
- 📞 Place & receive calls over SIP/WebRTC (audio).
- 🟢 Floating widget (FAB) that opens a dialer and auto-opens for incoming/active calls.
- ⌨️ Dial pad with
+key, keyboard input, ripple feedback, and collapsible call history. - 📲 Incoming call screen with custom synthesised ringtone, accept/decline, and blind transfer (SIP 302 redirect to any extension/number).
- 🎚️ In-call controls: mute, hold, attended transfer (REFER), add call / conference, and a DTMF keypad that sends tones over RTP (RFC 4733) with SIP INFO fallback.
- 🔈 Teams-style audio device picker — choose speaker (output) and microphone (input) mid-call.
- 🟡🟢🔴 Live registration status indicator (connecting / connected / disconnected).
- ⚠️ Error toasts — busy, declined, no answer, connection lost, etc.
- 🎨 Light & dark themes; FAB position is configurable.
- 🖌️ Fully replaceable UI — swap the dialer, in-call, and incoming-call screens for your own React components via the
componentsprop. - 🧩 Two usage tiers: a one-tag widget, or composable
SipProvider+useSip()+ individual components.
Installation
npm install sip-phone-libPeer dependencies (provide these in your app):
npm install react react-dom prop-typesReact 16.7+, 17, 18, or 19 is supported.
sip.jsis bundled.
Quick start — the all-in-one widget
The fastest path: drop <SoftphoneWidget> anywhere in your app. It wraps its own
SIP connection, renders a FAB (bottom-right by default), and handles every call
state internally.
import { SoftphoneWidget } from 'sip-phone-lib';
function App()
{
return (
<SoftphoneWidget
theme="dark"
position="bottom-right"
config={{
displayName : 'Jane Doe',
token : 'YOUR_AGENT_TOKEN',
outboundProxy : 'wss://your-sip-proxy.example.com/ws',
autoRegister : true,
}}
onNotify={(n) => console.log('[sip]', n.type, n.text)}
/>
);
}That's it — the agent registers on mount, the FAB appears, clicking it opens the dialer, and incoming calls pop the ringing screen automatically.
<SoftphoneWidget> props
| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| config | object | — (required) | SIP connection config — see Configuration. |
| theme | 'light' \| 'dark' | 'dark' | UI theme. |
| position | 'bottom-right' \| 'bottom-left' \| 'top-right' \| 'top-left' | 'bottom-right' | FAB corner. |
| style | object | — | Raw CSS offsets to override position, e.g. { bottom: 80, right: 40 }. |
| onNotify | (n) => void | — | Callback for every notification ({ type, text }). |
| components | { Dialer?, InCall?, IncomingCall? } | — | Replace any built-in screen with your own React component — see Custom UI. |
Click to call — trigger calls from your own buttons
Any element in your app (a contact card, a CRM row, a "Call support" button) can start a call programmatically. There are two ways, depending on how you embed the phone.
1. With <SoftphoneWidget> — imperative ref. The widget forwards a ref
exposing call / open / close:
import { useRef } from 'react';
import { SoftphoneWidget } from 'sip-phone-lib';
function App()
{
const phone = useRef(null);
const contacts = [
{ name : 'Sales', number : '+9611234567' },
{ name : 'Support', number : '2001' },
];
return (
<>
{contacts.map((c) => (
<button key={c.number} onClick={() => phone.current.call(c.number)}>
📞 Call {c.name}
</button>
))}
<button onClick={() => phone.current.open()}>Open dialer</button>
<SoftphoneWidget ref={phone} config={{ /* … */ }} />
</>
);
}| Method | Description |
| --- | --- |
| call(number) | Dial a number/extension and open the panel. |
| open() | Open the dialer panel. |
| close() | Close the dialer panel. |
2. Inside a <SipProvider> — the useSip() hook. If you use the
composable setup (see Composable usage), any component in
the tree can dial directly — no ref plumbing:
import { SipProvider, SoftphoneFab, useSip } from 'sip-phone-lib';
function CallButton({ number, children })
{
const { call, registered } = useSip();
return (
<button disabled={!registered} onClick={() => call(number)}>
{children}
</button>
);
}
export default function App()
{
return (
<SipProvider config={{ /* … */ }}>
<CallButton number='+9611234567'>📞 Call sales</CallButton>
<SoftphoneFab theme='dark' position='bottom-right' />
</SipProvider>
);
}Custom UI — bring your own components
You are not locked into the built-in design. Every screen the widget renders —
the dialer, the in-call card, and the incoming-call overlay — can be
replaced with your own React component via the components prop. The widget
keeps handling all SIP signalling, state, ringtones, the FAB, and screen
switching; your components only render UI and call the handlers they're given.
import { useState } from 'react';
import { SoftphoneWidget } from 'sip-phone-lib';
function MyDialer({ registered, onCall })
{
const [ number, setNumber ] = useState('');
return (
<div className="my-dialer">
<span>{registered ? 'Online' : 'Offline'}</span>
<input value={number} onChange={(e) => setNumber(e.target.value)} />
<button onClick={() => onCall(number)}>Call</button>
</div>
);
}
function MyInCall({ session, onHangup, onMute, onSendDtmf })
{
return (
<div className="my-in-call">
<h3>{session?.displayName}</h3>
<button onClick={() => onMute(true)}>Mute</button>
<button onClick={() => onSendDtmf('1')}>1</button>
<button onClick={onHangup}>Hang up</button>
</div>
);
}
function MyIncomingCall({ contact, onAccept, onDecline, onForward })
{
return (
<div className="my-incoming">
<h3>{contact.name} ({contact.number})</h3>
<button onClick={onAccept}>Answer</button>
<button onClick={onDecline}>Decline</button>
</div>
);
}
<SoftphoneWidget
config={{ /* … */ }}
components={{
Dialer : MyDialer,
InCall : MyInCall,
IncomingCall : MyIncomingCall,
}}
/>Override any subset — screens you omit keep the built-in design.
Full example — a completely different design
A styled set of overrides that looks nothing like the built-in kit (gradient card, pill buttons, glass keypad), showing the typical wiring for each screen:
import { useState } from 'react';
import { SoftphoneWidget } from 'sip-phone-lib';
// ── Shared styles ──────────────────────────────────────────────
const card = {
width : 300, padding : 20, borderRadius : 24,
background : 'linear-gradient(160deg,#1e1b4b 0%,#4c1d95 100%)',
color : '#ede9fe', display : 'flex', flexDirection : 'column', gap : 14,
boxShadow : '0 16px 48px rgba(30,27,75,.55)', boxSizing : 'border-box',
};
const pill = (bg) => ({
border : 'none', borderRadius : 999, padding : '12px 0', width : '100%',
fontSize : 15, fontWeight : 600, color : '#fff', background : bg, cursor : 'pointer',
});
const key = {
border : '1px solid rgba(255,255,255,.14)', borderRadius : 16,
background : 'rgba(255,255,255,.08)', color : '#ede9fe',
fontSize : 18, padding : '12px 0', cursor : 'pointer',
};
// ── Dialer ─────────────────────────────────────────────────────
function MyDialer({ registered, onCall, sessionHistory })
{
const [ number, setNumber ] = useState('');
return (
<div style={card}>
<div style={{ fontSize : 13 }}>
{registered ? '🟢 Ready to call' : '🔴 Offline'}
</div>
<input
value={number}
onChange={(e) => setNumber(e.target.value)}
placeholder='Enter a number…'
style={{
border : 'none', borderRadius : 14, padding : '13px 16px',
fontSize : 20, background : 'rgba(0,0,0,.30)', color : '#fff',
}}
/>
<div style={{ display : 'grid', gridTemplateColumns : 'repeat(3,1fr)', gap : 8 }}>
{[ '1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '0', '#' ].map((d) => (
<button key={d} style={key} onClick={() => setNumber((n) => n + d)}>
{d}
</button>
))}
</div>
<button
style={pill('#7c3aed')}
disabled={!registered || !number}
onClick={() => onCall(number)}
>
📞 Call
</button>
</div>
);
}
// ── In-call ────────────────────────────────────────────────────
function MyInCall({ session, onHangup, onHold, onUnhold, onMute, onSendDtmf })
{
const [ muted, setMuted ] = useState(false);
const [ held, setHeld ] = useState(false);
const toggleMute = () => { onMute(!muted); setMuted(!muted); };
const toggleHold = () => { held ? onUnhold() : onHold(); setHeld(!held); };
return (
<div style={card}>
<div style={{ textAlign : 'center' }}>
<div style={{ fontSize : 18, fontWeight : 700 }}>
{session?.displayName || 'In call'}
</div>
<div style={{ fontSize : 12, opacity : .7 }}>
{held ? 'On hold' : 'Connected'}
</div>
</div>
<div style={{ display : 'flex', gap : 8 }}>
<button style={pill('rgba(255,255,255,.14)')} onClick={toggleMute}>
{muted ? 'Unmute' : 'Mute'}
</button>
<button style={pill('rgba(255,255,255,.14)')} onClick={toggleHold}>
{held ? 'Resume' : 'Hold'}
</button>
</div>
<div style={{ display : 'grid', gridTemplateColumns : 'repeat(6,1fr)', gap : 6 }}>
{[ '1', '2', '3', '4', '5', '6' ].map((d) => (
<button key={d} style={{ ...key, padding : '8px 0', fontSize : 14 }}
onClick={() => onSendDtmf(d)}>
{d}
</button>
))}
</div>
<button style={pill('#ef4444')} onClick={onHangup}>
End call
</button>
</div>
);
}
// ── Incoming call (replaces the whole overlay + ringtone) ──────
function MyIncomingCall({ contact, onAccept, onDecline })
{
return (
<div style={{
position : 'fixed', inset : 0, zIndex : 2147483000,
background : 'rgba(15,10,40,.72)', backdropFilter : 'blur(6px)',
display : 'flex', alignItems : 'center', justifyContent : 'center',
}}>
<div style={{ ...card, textAlign : 'center', width : 320 }}>
<div style={{ fontSize : 13, letterSpacing : 2, opacity : .7 }}>
INCOMING CALL
</div>
<div style={{ fontSize : 24, fontWeight : 800 }}>{contact.name}</div>
<div style={{ fontSize : 14, opacity : .75 }}>{contact.number}</div>
<div style={{ display : 'flex', gap : 10, marginTop : 6 }}>
<button style={pill('#ef4444')} onClick={onDecline}>Decline</button>
<button style={pill('#22c55e')} onClick={onAccept}>Answer</button>
</div>
</div>
</div>
);
}
// ── Wire everything up ─────────────────────────────────────────
export default function App()
{
return (
<SoftphoneWidget
theme='dark'
position='bottom-right'
components={{
Dialer : MyDialer,
InCall : MyInCall,
IncomingCall : MyIncomingCall,
}}
config={{ /* … */ }}
/>
);
}Props your components receive
Every override receives theme ('light' | 'dark') and sip — the full
useSip() context (all state + all actions), so nothing is
out of reach. On top of that, each screen gets ready-made props:
Dialer
| Prop | Type | Description |
| --- | --- | --- |
| onCall | (number) => void | Dial a number/extension. |
| registered | boolean | Registration status. |
| registerInProgress | boolean | REGISTER in flight. |
| displayName | string | The configured line label. |
| sessionHistory | array | Recent calls (for a recents list). |
InCall
| Prop | Type | Description |
| --- | --- | --- |
| session | object | The active session wrapper (displayName, sipSession, sessionState, …). |
| onHangup | () => void | Terminate the call. |
| onHold / onUnhold | () => void | Hold / resume. |
| onMute | (mute: boolean) => void | Mute / unmute the microphone. |
| onSendDtmf | (tones: string) => void | Send DTMF digits. |
For transfer, add-call, or device switching, use the sip prop
(sip.refer(...), sip.addCall(...), sip.getAudioDevices(), …).
IncomingCall
| Prop | Type | Description |
| --- | --- | --- |
| contact | { name, number } | Who is calling. |
| isActive | boolean | A call is ringing. |
| onAccept | () => void | Answer the call. |
| onDecline | () => void | Reject the call. |
| onForward | (uri) => void | Blind-transfer the ringing call. |
| recents | array | Last 10 calls ({ displayName, number, time, direction }). |
| lineName | string | The configured line label. |
| session | object | The raw ringing session wrapper. |
Your
IncomingCallreplaces the whole overlay, including the built-in synthesised ringtone — play your own sound if you want one.
Prefer to own the entire layout (no FAB, no floating panel)? Skip the widget and
compose SipProvider + useSip() directly — see
Composable usage. The components prop is for keeping the
widget's orchestration while swapping the visuals.
Configuration
The config object is passed to the underlying SIP client.
| Field | Type | Description |
| --- | --- | --- |
| outboundProxy | string | (required) WebSocket URL of your SIP proxy, e.g. wss://host/ws. |
| displayName | string | The agent's display name / line label shown in the UI. |
| token | string | Agent token sent as X-Agent-Token on every REGISTER. The proxy validates it, resolves the real SIP credentials, and answers the digest challenge — so the browser never holds the SIP password. |
| password | string | SIP auth password (only if you authenticate directly instead of via token). |
| autoRegister | boolean | Register automatically on mount. |
| videoEnabled | boolean | Reserved for video (audio-only today). |
| iceServers | array | Custom RTCIceServer[] for STUN/TURN. Falls back to window.iceServers. |
| headers | { name, value }[] | Extra SIP headers added to outbound INVITEs (e.g. trunk / caller-id routing). |
Identity model: the browser sends a fixed placeholder AOR; the SIP proxy rewrites it to the real
sipUsername@sipDomain(resolved fromtoken) in both directions. Configure your proxy's placeholder user/host to match the library's.
Composable usage
For full control over layout, wrap your tree in SipProvider and use the
useSip() hook and/or the individual components.
import {
SipProvider, useSip,
DialPad, InCall, IncomingCall, SoftphoneFab,
sessionStates,
} from 'sip-phone-lib';
function Phone()
{
const { currentSession, ringingSession } = useSip();
const state = currentSession?.sessionState;
return (
<>
{ringingSession && <IncomingCall />}
{state === sessionStates.ACCEPTED && <InCall />}
{!currentSession && !ringingSession && <DialPad />}
</>
);
}
export default function App()
{
return (
<SipProvider config={{ /* … */ }} onNotify={(n) => console.log(n)}>
<Phone />
{/* or: <SoftphoneFab theme="dark" position="bottom-right" /> */}
</SipProvider>
);
}
SoftphoneFabis the FAB/floating panel without its own provider — use it when you already render aSipProvideryourself.SoftphoneWidget=SipProvider
SoftphoneFabin one.
Exports
| Export | Description |
| --- | --- |
| SoftphoneWidget | All-in-one widget (provider + FAB + all screens). Default for embedding. |
| SoftphoneFab | FAB + floating panel; expects a surrounding SipProvider. |
| SipProvider | Context provider that owns the SIP connection. |
| useSip() | Hook returning SIP state + actions (must be inside SipProvider). |
| DialPad | Dialer card (keypad + collapsible recents). |
| InCall | In-call card (controls, DTMF keypad, device picker, transfer/add-call). |
| IncomingCall | Ringing screen (ringtone, accept/decline, blind transfer). Accepts component + theme props to render a custom UI with the same wiring. |
| sessionStates | Symbols for session states (see below). |
The useSip() API
useSip() returns the SIP context: reactive state plus actions.
State
| Field | Type | Description |
| --- | --- | --- |
| displayName | string | Configured agent name. |
| registered | boolean | Whether the agent is registered. |
| registerInProgress | boolean | Registration in flight. |
| registrationMessage | string | Last registration result/cause. |
| currentSession | object \| null | Active call session wrapper. |
| ringingSession | object \| null | Incoming, not-yet-answered session. |
| sessions | object | Map of all sessions (for conference/add-call). |
| sessionHistory | array | Recent calls ({ displayName, sipUri, direction, startTime }). |
| lastNotification | { type, text, id } \| null | Most recent notification (errors surface as a toast in the widget). |
Actions
| Method | Description |
| --- | --- |
| register() / unRegister() | Manage SIP registration. |
| call(number) / invite(uri) | Place an outbound call. |
| accept(sipSession) | Answer an incoming call. |
| terminate(sipSession) | Hang up / decline. |
| hold(sipSession) / unhold(sipSession) | Hold / resume. |
| addCall(uri) | Hold the current call and dial a second party (conference). |
| redirect(sipSession, uri) | Blind-transfer an incoming (unanswered) call via SIP 302. |
| refer(sipSession, uri) | Attended/blind transfer of an active call via REFER. |
| sendDtmf(sipSession, tones) | Send DTMF over RTP (RFC 4733), INFO fallback. |
| toggleMyMedia(session, type, mute) | Mute/unmute 'audio'/'video'. |
| setSpeakerOutput(on) | Legacy speaker on/off toggle. |
| getAudioDevices() | Promise<{ inputs, outputs }> of available mics/speakers. |
| setAudioOutputDevice(deviceId) | Route call audio to a speaker. |
| setAudioInputDevice(session, deviceId) | Switch microphone mid-call (replaceTrack). |
sessionStates
NEW, PROGRESS, REJECTED, IGNORED, ACCEPTED, CANCELED, FAILED,
TERMINATED, INCOMING, OUTGOING, plus REFER-related states. They are JS
Symbols — compare with ===:
import { sessionStates } from 'sip-phone-lib';
if (currentSession?.sessionState === sessionStates.ACCEPTED) { /* in call */ }Notifications & call-failure messages
Every significant event emits a notification ({ type: 'success' | 'error' | 'info',
text }). The widget shows error notifications as an auto-dismissing toast, and
they're also stored in useSip().lastNotification. Call failures are classified
into readable text — e.g. Busy, Declined, No answer, Unavailable,
Server error (5xx), Connection lost. Pass onNotify to also log or surface
them yourself:
<SoftphoneWidget
config={{ /* … */ }}
onNotify={(n) => { if (n.type === 'error') showBanner(n.text); }}
/>Feature flags (optional)
A few in-call/dialer affordances can be hidden via globals set before render:
window.showDialpad = false; // hide the dial pad
window.showHoldButton = false; // hide Hold
window.showTransferButton = false; // hide Transfer
window.showAddCallButton = false; // hide Add CallScripts
| Script | Description |
| --- | --- |
| npm run dev | Start the Vite dev app (src/main.jsx) for local testing. |
| npm run build | Build the library to dist/ (ES + UMD). |
| npm test | Run the test suite (Vitest). |
Build outputs: dist/sip-phone-lib.es.js (ESM) and dist/sip-phone-lib.umd.js (UMD),
referenced by the package module / main fields.
Browser requirements
- WebRTC + Web Audio (modern Chromium, Firefox, Safari).
- A reachable WebSocket SIP proxy (
wss://…) — required for registration and calls. - Output-device selection (
setSinkId) is best-supported in Chromium; mic switching works broadly.
License
See repository for license details.
