Skip to content

Prebuilt call UI

<tele-call> is a complete call screen in one element. It has a lobby with camera preview and device pickers, video tiles, screen share, pinning, chat, device menus, reconnect handling and a recording banner. It works in any framework or in plain HTML, and there is a thin React wrapper.

It adds no new authentication. You give it a getJoin() function that returns the join response your app already gets from POST /v1/invites/{code}/join or POST /v1/calls/{id}/join (Joining a call). The element never sees your API key or secret.

Distribution

The package is @tele/call-ui, released from the cloudguys-io/tele-ui-pack repository, which also has its full documentation. It is not on the npm registry; install a release tag from GitHub:

npm install github:cloudguys-io/tele-ui-pack#v0.2.1 livekit-client

The ESM build leaves livekit-client to your app. dist/tele-call.iife.js is a single file that includes it, for a <script> tag.

Plain HTML

<script src="/vendor/tele-call.iife.js"></script>

<tele-call id="call" features="lobby chat screenshare devices" lang="en" style="height: 100dvh"></tele-call>

<script>
  const call = document.getElementById("call");
  const code = new URLSearchParams(location.search).get("code");

  // Your backend calls Tele and returns the join response as is.
  call.getJoin = () =>
    fetch("/my-backend/tele/join?code=" + encodeURIComponent(code), { method: "POST" })
      .then((r) => r.ok ? r.json() : r.json().then((b) => Promise.reject(b.error)));

  // Optional: your backend forwards this body to POST /v1/calls/{call_id}/diagnostics.
  call.reportDiagnostic = (kind, body) =>
    fetch("/my-backend/tele/diagnostics", { method: "POST", body: JSON.stringify(body), keepalive: true });

  call.addEventListener("tele-leave", () => location.assign("/calls"));
  call.addEventListener("tele-error", (e) => console.error(e.detail.code, e.detail.message));
</script>

If getJoin() rejects with an object that has a code (a Tele error body), the element shows a matching message; too_early gets "This call isn't open yet."

React and Next.js

"use client";
import { TeleCall } from "@tele/call-ui/react";

export function Room({ callId }: { callId: string }) {
  return (
    <TeleCall
      style={{ height: "100dvh" }}
      getJoin={() => fetch(`/api/calls/${callId}/join`, { method: "POST" }).then((r) => r.json())}
      onLeave={() => location.assign(`/calls/${callId}`)}
    />
  );
}

The wrapper loads the element only in the browser, so server rendering is safe. The demo app's room page (demo/web/app/room/[id]/page.tsx) is the full reference integration, including chat history, recording and "End for all".

Attributes

Attribute Values Default
features Space-separated: lobby, chat, screenshare, devices, recording-indicator all of them
lang en, bn (a tag like bn-BD uses bn) en
theme light, dark, auto (follows the system) auto
layout grid (even tiles) or speaker (the active speaker large, others in a strip). In either layout someone else's screen share, or a tile you pinned, goes large grid

What goes large, in order: a tile the viewer pinned (every tile has a pin button, shown on hover and always on touch screens, and a double-click pins too; pinning again unpins), then someone else's screen share (your own stays small unless you pin it), then the active speaker in speaker layout. Pins are per viewer and never sent to anyone. The other tiles sit in a strip below the large tile, or beside it once the stage is 900px wide.

Without lobby, the element joins as soon as getJoin is set and turns on the default microphone and camera. Keep the lobby for iPhone browsers: Safari only asks for camera and microphone reliably after a tap.

Properties

Property Purpose
getJoin() Required. Resolves to the join response (ws_url, token, ice_servers, grant_id, call, role).
reportDiagnostic(kind, body) Called on connect, every 15 seconds, and on media errors. body is exactly the JSON for POST /v1/calls/{id}/diagnostics. Reject with { code: "session_pending" } and it retries.
loadChatHistory(callId) Resolves to the messages array of GET /v1/calls/{id}/messages, so latecomers see earlier chat.
setRecording(on) Hosts only. Shows Record and Stop recording buttons; call your backend.
endForAll() Hosts only. Shows an "End for all" button; call your backend.
strings Per-label overrides on top of the language, for example { exit: "Back to the call" }.

Events and methods

Event detail
tele-joined { call, role, grantId }
tele-leave { reason }: left, or why the call ended (room_deleted, removed, duplicate_identity, disconnected, error) when the person closes that screen
tele-error { code, message } when joining fails
tele-participant { action: "joined" \| "left", identity, name, count }
tele-chat the new message { id, userId, name, body, mine }, for unread badges outside the element

Methods: leave(), setMicrophone(on), setCamera(on). The phase property is lobby, joining, live, ended, error or left.

Extra buttons can go in the control bar with slot="controls".

Theming

The element uses Shadow DOM, so your CSS cannot break it. Set these custom properties on the element or any ancestor:

Variable Default
--tele-accent vermilion, oklch(0.54 0.19 33) (lighter in dark mode)
--tele-radius 16px (tiles; smaller elements scale from it)
--tele-font ui-sans-serif, system-ui, sans-serif
--tele-bg, --tele-surface, --tele-text, --tele-muted, --tele-line light or dark palette from theme
tele-call {
  --tele-accent: #0a7cff;
  --tele-radius: 8px;
  --tele-font: "Inter", sans-serif;
}
tele-call::part(tile) { box-shadow: 0 1px 2px rgb(0 0 0 / 0.1); }

Parts: call, header, stage, tile, tile-speaking, tile-pinned, tile-name, tile-pin, avatar, controls, control, button, button-primary, leave, join, chat, chat-bubble, chat-bubble-mine, input, select, lobby, preview, device-menu, notice, recording, status.