livqenoDocs

SDKsReact Video Components

React Video Components

A prebuilt video call UI for React — VideoConference, PreJoin, layouts, tiles and controls — plus the hooks they are built from.

@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 as data-* 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 useTracks doesn'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/react

Needs 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

PropTypeDefaultWhat it does
onLeave() => void—Called after the user leaves with the Leave button.
userChoicesPartial<LocalUserChoices>—From <PreJoin>: turns the camera and mic on (or not) once connected, on the chosen devices.
chatReactNode—Content for a chat side panel. Adds a Chat button to the control bar. See Adding chat.
defaultChatOpenbooleanfalseStart with the chat panel open.
maxTilesPerPagenumberby widthGrid tiles per page: 4 on a phone, 9 mid-size, 16 wide.
controlsControlBarControlsall onHide controls: { screenShare: false, chat: false }.
tilePropsPartial<ParticipantTileProps>—Passed to every default tile, e.g. { showConnectionQuality: false }.
renderTile(ref: TrackReference) => ReactNode—Replace the default tile entirely.
theme'dark' | 'light'follows the OSForce a color scheme for the stylesheet.
captionsboolean | CaptionsProps & { defaultEnabled? }—Adds a Captions button and a live caption overlay. See Captions and AI agents.
childrenReactNode—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

PropTypeDefaultWhat it does
onSubmit(choices: LocalUserChoices) => voidrequiredCalled when the user presses Join.
defaultsPartial<LocalUserChoices>camera and mic onStarting values.
showUsernamebooleantrueShow the name field. When shown, Join needs a name.
onValidate(choices) => boolean—Your own rule for enabling Join.
persistUserChoicesbooleanfalseRemember choices in localStorage for next time.
connectionTestConnectionTestOptions | () => 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, usernameLabelReactNode, 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

ComponentWhat it doesKey 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.

HookReturns
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>:

ComponentWhat it doesKey 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 hookWhat it doesKey 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:

ElementClassState attributes
Tilelivqeno-participant-tiledata-speaking, data-video-muted, data-audio-muted, data-local, data-source, data-pinned, data-subscription, data-e2ee
Gridlivqeno-grid-layoutdata-paginated, data-tiles, plus --livqeno-grid-columns / --livqeno-grid-rows inline
Togglelivqeno-track-toggledata-source, data-enabled, data-pending, data-error, aria-pressed
Quality barslivqeno-connection-qualitydata-quality; each bar has data-active
Toastlivqeno-toastdata-kind, data-reason
Conferencelivqeno-video-conferencedata-layout (grid | focus), data-chat-open

Accessibility

  • Every control is a real <button> with an accessible name. Toggles use aria-pressed; a toggle that's working shows aria-busy and 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