@livqeno/react ships a video UI kit: everything a call screen needs,
from a one-line <VideoConference> down to single pieces like a tile or a
mic button. Use the whole thing, swap parts out, or use only the hooks
underneath and write your own markup.
- Unstyled by default. Every element has a stable
livqeno-*class and exposes its state asdata-*attributes. - An optional stylesheet,
@livqeno/react/video/styles.css, gives you Livqeno's dark-first look, themed with CSS variables. - Hooks only cost what you use. Importing
useTracksdoesn't pull a single component into your bundle.
Where this fits: your backend mints an RTC token
and your page renders <LivqenoRoom> with it.
Everything on this page goes inside that provider, except <PreJoin>,
which runs before a room exists.
Install
pnpm add @livqeno/rtc @livqeno/reactnpm install @livqeno/rtc @livqeno/reactyarn add @livqeno/rtc @livqeno/reactbun add @livqeno/rtc @livqeno/reactNeeds react ^18 || ^19.
A call in one component
'use client';
import { LivqenoRoom, VideoConference } from '@livqeno/react';
import '@livqeno/react/video/styles.css';
export function Call({ grant, onDone }) {
return (
<div style={{ height: '100dvh' }}>
<LivqenoRoom
token={grant.token}
endpoint={grant.endpoint}
iceServers={grant.iceServers}
room={grant.roomName}
fallback={<p>Connecting…</p>}
>
<VideoConference onLeave={onDone} />
</LivqenoRoom>
</div>
);
}That gives you:
- a grid of everyone, paginated beyond what fits on screen
- a focus layout whenever someone shares their screen, or you pin a tile
- a speaking ring on whoever is talking
- a control bar with mic, camera and screen-share toggles, device menus, and Leave
- remote audio for everyone, whichever tiles are on screen
- a Start audio button if the browser blocked autoplay
- toasts for "Reconnecting…", "Connection restored", and why the call ended ("You were removed from the call")
<VideoConference> fills its parent, so give the parent a height.
<VideoConference> props
| Prop | Type | Default | What it does |
|---|---|---|---|
onLeave | () => void | — | Called after the user leaves with the Leave button. |
userChoices | Partial<LocalUserChoices> | — | From <PreJoin>: turns the camera and mic on (or not) once connected, on the chosen devices. |
chat | ReactNode | — | Content for a chat side panel. Adds a Chat button to the control bar. See Adding chat. |
defaultChatOpen | boolean | false | Start with the chat panel open. |
maxTilesPerPage | number | by width | Grid tiles per page: 4 on a phone, 9 mid-size, 16 wide. |
controls | ControlBarControls | all on | Hide controls: { screenShare: false, chat: false }. |
tileProps | Partial<ParticipantTileProps> | — | Passed to every default tile, e.g. { showConnectionQuality: false }. |
renderTile | (ref: TrackReference) => ReactNode | — | Replace the default tile entirely. |
theme | 'dark' | 'light' | follows the OS | Force a color scheme for the stylesheet. |
captions | boolean | CaptionsProps & { defaultEnabled? } | — | Adds a Captions button and a live caption overlay. See Captions and AI agents. |
children | ReactNode | — | Rendered over the stage, for your own banners or badges. |
Lobby: <PreJoin>
Let people check their camera and mic before joining. <PreJoin> shows a
camera preview, a live mic level meter, device pickers and a name field.
It needs no room, so render it before <LivqenoRoom>:
import { PreJoin, LivqenoRoom, VideoConference, type LocalUserChoices } from '@livqeno/react';
function Meeting({ grant }) {
const [choices, setChoices] = useState<LocalUserChoices>();
if (!choices) {
return <PreJoin onSubmit={setChoices} persistUserChoices />;
}
return (
<LivqenoRoom token={grant.token} endpoint={grant.endpoint} room={grant.roomName}>
<VideoConference userChoices={choices} />
</LivqenoRoom>
);
}onSubmit receives { username, videoEnabled, audioEnabled, videoDeviceId, audioDeviceId }. The preview tracks are stopped before it's called, so
the camera is free for the call.
The name you collect is yours to use: a participant's identity comes from
their token, so send username to your backend when you mint it (and
put it in the token's metadata as {"name": "…"} if you want tiles to show
it instead of the identity).
Network test
Pass connectionTest to add a Test connection button. It runs
runConnectionTest() and shows the results: camera,
microphone, server and media checks, the route media took (direct or
relayed), TURN over UDP/TCP/TLS, round trip, jitter, packet loss, upload
speed, and any issues worth fixing.
<PreJoin
onSubmit={setChoices}
connectionTest={async () => {
// A token for a throwaway room — never the call's own.
const res = await fetch('/api/preflight-token', { method: 'POST' });
return res.json(); // { token, endpoint, iceServers }
}}
/>Pass a function so the token is fetched only when someone actually clicks.
<PreJoin> props
| Prop | Type | Default | What it does |
|---|---|---|---|
onSubmit | (choices: LocalUserChoices) => void | required | Called when the user presses Join. |
defaults | Partial<LocalUserChoices> | camera and mic on | Starting values. |
showUsername | boolean | true | Show the name field. When shown, Join needs a name. |
onValidate | (choices) => boolean | — | Your own rule for enabling Join. |
persistUserChoices | boolean | false | Remember choices in localStorage for next time. |
connectionTest | ConnectionTestOptions | () => Promise<ConnectionTestOptions> | — | Enables the network test. |
onError | (error) => void | — | Camera or mic failed to start (permission denied, in use, missing). The message is also shown inline. |
joinLabel, usernameLabel | ReactNode, string | 'Join', 'Your name' | Text. |
Building your own layout
Every part of <VideoConference> is exported. This is roughly what it
renders:
import {
ControlBar, ConnectionStateToast, FocusLayout, GridLayout, LayoutContextProvider,
RoomAudioRenderer, StartAudio, usePinnedTracks, useTracks,
} from '@livqeno/react';
function MyCall() {
return (
<LayoutContextProvider>
<div className="livqeno-video" style={{ height: '100%', display: 'flex', flexDirection: 'column' }}>
<Stage />
<ControlBar controls={{ screenShare: false }} />
<RoomAudioRenderer />
<StartAudio />
<ConnectionStateToast />
</div>
</LayoutContextProvider>
);
}
function Stage() {
const tracks = useTracks(); // everyone's camera (or a placeholder), plus screen shares
const { pinned } = usePinnedTracks();
const focus = pinned[0] ?? tracks.find((t) => t.source === 'screenShare' && !t.isLocal);
return focus
? <FocusLayout trackRef={focus} others={tracks.filter((t) => t !== focus)} />
: <GridLayout tracks={tracks} maxTilesPerPage={12} />;
}Put exactly one <RoomAudioRenderer> on the page. Tiles never play audio
themselves, so audio keeps working for people on another page of the grid.
Components
| Component | What it does | Key props |
|---|---|---|
<GridLayout> | Responsive grid that paginates beyond maxTilesPerPage, with Previous/Next buttons and PageUp/PageDown. Videos on other pages are paused at the server, so a 50-person call only costs one page of video. | tracks, maxTilesPerPage, renderTile, tileProps |
<FocusLayout> | One large tile plus a scrollable strip of the rest. Tiles scrolled out of view pause on their own. | trackRef, others, renderTile, tileProps |
<ParticipantTile> | Video, or an initials avatar when the camera is off. Name, mic-off icon, speaking ring, connection bars, an E2EE lock (or a warning when someone can't be decrypted), and "Video paused to save bandwidth" when the server pauses their video. A pin button inside a layout context. | trackRef, name, showConnectionQuality, showPinButton, mirrorLocalCamera, children |
<ControlBar> | Mic and camera toggles with device menus, screen share (hidden where the browser can't share), chat, Leave. Arrow keys move between buttons. | controls, variation ('verbose' | 'minimal'), showDeviceMenus, onLeave, onDeviceError, children |
<TrackToggle> | One on/off button for 'microphone', 'camera' or 'screenShare'. | source, showLabel, onChange, onError |
<MediaDeviceMenu> | A menu of devices to switch to. Switching a live track doesn't republish it. Picking while the track is off is remembered for when it's turned on. | kind or kinds ('videoinput' | 'audioinput' | 'audiooutput'), onActiveDeviceChange |
<DisconnectButton> | Leaves, then calls onLeave. | onLeave |
<ChatToggle> | Opens the chat panel. Renders nothing when there isn't one. | — |
<ConnectionQualityIndicator> | Three signal bars from the server's score for a participant. showLabel shows text from the exported CONNECTION_QUALITY_LABELS map — import that same map if you want to write your own labels elsewhere. | participant, showLabel, hideWhenUnknown |
<RoomAudioRenderer> | Plays every remote microphone. | volume, muted |
<StartAudio> | Appears only when the browser blocked autoplay, and unblocks audio on click. | label |
<ConnectionStateToast> | "Reconnecting…", "Connection restored", and why the server ended the call — using the text in the exported DISCONNECT_REASON_MESSAGES map, unless you override it with messages.disconnected. Shows nothing when you leave yourself. | messages, resumedDurationMs |
<Toast> | A plain status message, for your own notices. Errors are announced immediately (role="alert"). | kind, busy |
<LayoutContextProvider> | Holds the pinned tile and the chat panel's open state, for a custom layout. | chatAvailable, chatOpen, onChatOpenChange, onPinChange |
A TrackReference is { participant, source, track?, isLocal }. track is
undefined for a placeholder: someone with their camera off still gets a
tile.
Hooks
The components are built from these. All of them work in your own markup,
and none throws outside <LivqenoRoom>: they return an empty or idle state.
| Hook | Returns |
|---|---|
useTracks(sources?, { onlySubscribed }) | TrackReference[], local participant first. The default is everyone's camera with placeholders plus screen shares. Pass sources like ['screenShare'] or [{ source: 'camera', withPlaceholder: true }]. Stable between renders until something changes. |
useParticipants() / useLocalParticipant() | The roster, and you. See React SDK. |
useTrackToggle(source) | { enabled, pending, error, track, toggle(force?) } for 'camera', 'microphone' or 'screenShare'. A mic the server muted gets unmuted rather than republished. |
useScreenShare() | useTrackToggle('screenShare') plus supported, screenShares and remoteScreenShares. |
useMediaDevices(kind) | The devices of one kind, kept current as they're plugged in and out. Labels appear once the user has granted a permission. |
useMediaDeviceSelect({ kind }) | { devices, activeDeviceId, setActiveMediaDevice, refresh, pending, error }. |
useRoomConnectionState() | { state, reconnectCount, disconnectReason }. |
useDisconnectButton({ onLeave }) | { leave, pending, buttonProps }. |
useIsMuted(trackRef) | Whether the track is off (missing or muted). |
useIsSpeaking(participant), useActiveSpeakers(), useConnectionQuality(participant), useTrackSubscriptionState(track), useE2EEState(participant) | Per-participant signals. See React SDK. |
useAudioPlayback() | { canPlayAudio, startAudio }, for your own autoplay-unlock button. |
useAudioLevel(mediaStreamTrack) | The live input level, 0–1, for a mic meter. |
usePinnedTracks() / useChatToggle() | Pin state and the chat panel, inside a layout context. |
useLayoutContext() | The LayoutContextValue that the two hooks above read from. Returns null if there's no <LayoutContextProvider> above it. |
usePagination(items, perPage) / useGridLayout(ref) | The grid's own paging and sizing. |
useParticipantTrack(participant, source, room?) | One participant's Track for a source ('camera', 'microphone', 'screenShare'), or undefined. |
function MicButton() {
const mic = useTrackToggle('microphone');
return (
<button aria-pressed={mic.enabled} aria-busy={mic.pending} onClick={() => mic.toggle()}>
{mic.enabled ? 'Mute' : 'Unmute'}
{mic.error && <span role="alert">{mic.error.message}</span>}
</button>
);
}Adding chat
<VideoConference chat={…}> takes any content for its side panel. Use the
chat UI kit in its own provider:
import { ChatProvider, MessageComposer, MessageList, VideoConference } from '@livqeno/react';
import '@livqeno/react/chat/styles.css';
<VideoConference
chat={
<ChatProvider token={chatGrant.token} apiUrl={chatGrant.apiUrl}>
<MessageList room={conversationId} />
<MessageComposer room={conversationId} />
</ChatProvider>
}
/>;The panel stays mounted while it's closed, so the chat connection and scroll position survive toggling it.
Two more chat UI kit components, also inside <ChatProvider>:
| Component | What it does | Key props |
|---|---|---|
<ChannelList> | This user's conversations, newest activity first, from useInbox(). | selectedId, onSelect, renderItem, emptyState, inboxOptions |
<Thread> | One message's replies, for threaded chat. | room, messageId, pageSize (50), mentionCandidates, renderMessage, onClose |
import { ChannelList, ChatProvider, Thread } from '@livqeno/react';
<ChatProvider token={chatGrant.token} apiUrl={chatGrant.apiUrl}>
<ChannelList selectedId={activeRoom} onSelect={(c) => setActiveRoom(c.id)} />
{openThreadId && <Thread room={activeRoom} messageId={openThreadId} onClose={() => setOpenThreadId(undefined)} />}
</ChatProvider>;Captions and AI agents
When your backend runs a live transcription or an AI voice agent in the room, these show it:
import { AgentVisualizer, Captions, VideoConference, useRemoteParticipants } from '@livqeno/react';
<VideoConference captions /> // CC button + overlay, captions on
<VideoConference captions={{ defaultEnabled: false, maxLines: 2 }} />
<Captions /> // the overlay on its own
function Agent() {
const agent = useRemoteParticipants().find((p) => p.isAgent);
return agent ? <AgentVisualizer participant={agent} /> : null;
}| Component or hook | What it does | Key props / returns |
|---|---|---|
<Captions> | Live captions with the speaker's name. Interim text is italic and replaced as it firms up; the final line replaces it and fades after fadeAfterMs. An aria-live="polite" region that announces final lines only. | participant, maxLines (3), fadeAfterMs (6000), showSpeaker |
<CaptionsToggle> | The CC button <VideoConference captions> adds to the control bar. | enabled, onEnabledChange, showLabel |
<AgentVisualizer> | A ring for the agent's state (data-state: listening, thinking, speaking, ended) and level bars from its audio while it speaks, via useAudioLevel. Announces state changes. | participant, barCount (5), showLabel |
useTranscription({ participant?, maxSegments? }) | Caption state, from the room's transcriptionSegment events. | { segments, interim, active, state }: finals (bounded, default 100), each speaker's open interim by identity, whether a transcription runs |
useAgentState(participant) | The agent's state. | 'listening' | 'thinking' | 'speaking' | 'ended' | undefined |
useTranscription is built on CaptionStore from @livqeno/rtc
(re-exported here), for a caption UI outside React. Captions start from
when the hook mounts. Style hooks: .livqeno-captions,
.livqeno-captions__line[data-interim], .livqeno-agent-visualizer, and the
--livqeno-caption-bg / --livqeno-caption-text properties.
Styling and theming
Nothing is styled until you import the stylesheet:
import '@livqeno/react/video/styles.css';Its rules are scoped to .livqeno-video, which <VideoConference> and
<PreJoin> put on their root element. If you render a piece on its own,
wrap it in any element with class="livqeno-video".
Every color and size is a CSS variable. Override them on .livqeno-video (or
any parent) to retheme:
.livqeno-video {
--livqeno-bg: #0a0a0a; /* canvas */
--livqeno-surface: #141414; /* buttons, menus */
--livqeno-tile-bg: #111111; /* empty tiles */
--livqeno-border: #262626; /* hairlines */
--livqeno-text: #fafafa;
--livqeno-text-muted: #a3a3a3;
--livqeno-primary: #fafafa; /* Join, active toggles */
--livqeno-link: #f97316; /* links only */
--livqeno-danger: #ef4444; /* Leave, mic off */
--livqeno-speaking: #fafafa; /* the speaking ring */
--livqeno-radius: 10px;
--livqeno-gap: 8px;
--livqeno-control-height: 40px;
--livqeno-chat-width: 360px;
--livqeno-font: 'Inter', system-ui, sans-serif;
}It's dark by default and switches to light for a light OS setting. Force
one with theme="light" on <VideoConference> or data-theme="light" on
any parent.
To style from scratch, skip the stylesheet and target the classes and data attributes:
| Element | Class | State attributes |
|---|---|---|
| Tile | livqeno-participant-tile | data-speaking, data-video-muted, data-audio-muted, data-local, data-source, data-pinned, data-subscription, data-e2ee |
| Grid | livqeno-grid-layout | data-paginated, data-tiles, plus --livqeno-grid-columns / --livqeno-grid-rows inline |
| Toggle | livqeno-track-toggle | data-source, data-enabled, data-pending, data-error, aria-pressed |
| Quality bars | livqeno-connection-quality | data-quality; each bar has data-active |
| Toast | livqeno-toast | data-kind, data-reason |
| Conference | livqeno-video-conference | data-layout (grid | focus), data-chat-open |
Accessibility
- Every control is a real
<button>with an accessible name. Toggles usearia-pressed; a toggle that's working showsaria-busyand stays focusable instead of being disabled. - Device menus follow the menu pattern: arrow keys, Home and End to move, Enter or Space to pick, Escape to close and return focus to the button.
- The control bar is a
role="toolbar"; arrow keys move between buttons. - Tiles are labelled groups ("Ada (you)", "Sam, screen share"). Mic-off, E2EE and connection icons have text alternatives.
- Toasts and the network test's progress are announced through live regions.
- Focus rings are always visible for keyboard users. With
prefers-reduced-motion, the stylesheet turns off transitions and the spinner animation.
Bandwidth
Video you can't see shouldn't cost you anything. The grid tells the server which videos are on the current page, and the server stops sending the rest. Tiles scrolled out of view in the focus strip, and every video while the tab is hidden, pause on their own. When the server pauses a video because your connection can't carry it, the tile says "Video paused to save bandwidth" instead of freezing on the last frame.
Next steps
- React SDK:
<LivqenoRoom>and the lower-level hooks - Chat UI components
- Pre-call test
- End-to-end encryption