Build Your Own UI

Last updated Oct 19, 2026 This guide explains how to use RealtimeKit UI Kit components to build a custom meeting interface instead of the default full-screen meeting view.

Prerequisites

This page builds upon the Initialize SDK, Render Default Meeting UI, and UI Kit States guides. First refer to these pages to understand the core concepts.

The code examples on this page assume you have already imported the necessary packages and initialized the SDK.

Build a custom UI with UI Kit

If the default meeting component does not provide enough control over layout or behavior, use UI Kit components to build a custom interface. The UI Kit provides pre-built components on top of the Core SDK. You can mix and match pieces while saving time compared to building from scratch.

A custom UI requires you to manage participant audio, notifications, dialogs, component layout, and screen transitions.

WebMobile

ReactWeb ComponentsAngular

Similar to rtk-meeting, rtk-ui-provider, another UI Kit component that acts as a provider, also listens to states and syncs them with the UI Kit components.

Unlike rtk-meeting, rtk-ui-provider allows you to pass any child components to it. If any child component is a RealtimeKit component starting with rtk-, rtk-ui-provider will coordinate with it to sync the states.

Similar to RtkMeeting, RtkUiProvider, another UI Kit component that acts as a provider, also listens to states and syncs them with the UI Kit components.

Unlike RtkMeeting, RtkUiProvider allows you to pass any child components to it. If any child component is a RealtimeKit component starting with Rtk, RtkUiProvider will coordinate with it to sync the states.

Similar to rtk-meeting, rtk-ui-provider, another UI Kit component that acts as a provider, also listens to states and syncs them with the UI Kit components.

Unlike rtk-meeting, rtk-ui-provider allows you to pass any child components to it. If any child component is a RealtimeKit component starting with rtk-, rtk-ui-provider will coordinate with it to sync the states.

The iOS SDK uses a delegation pattern for custom UIs. Instead of wrapping child views in a provider, you conform to RtkUIFlowCoordinatorDelegate and return your own view controllers for each screen in the meeting flow.

There are two levels of customization:

  • Replace entire screens — Conform to RtkUIFlowCoordinatorDelegate and return your own UIViewController for the setup screen, group call screen, or webinar screen. Return nil to use the default screen.
  • Replace sub-components — Conform to MeetingViewControllerDataSource and swap the topbar, middle view (participant grid), or bottom control bar within the default MeetingViewController.
public protocol RtkUIFlowCoordinatorDelegate {
    func showSetUpScreen(completion: () -> Void) -> SetupViewControllerDataSource?
    func showGroupCallMeetingScreen(meeting: RealtimeKitClient, completion: @escaping () -> Void) -> UIViewController?
    func showWebinarMeetingScreen(meeting: RealtimeKitClient, completion: @escaping () -> Void) -> UIViewController?
}

When the user joins successfully, the SDK calls showGroupCallMeetingScreen(meeting:completion:) or showWebinarMeetingScreen(meeting:completion:). Use the RealtimeKitClient parameter to bind individual UI Kit components (such as RtkMeetingHeaderView, RtkMeetingControlBar, or RtkParticipantTileView) to the meeting.

The Android SDK does not use a provider/wrapper pattern. Instead, each individual View component has an activate(meeting) method that binds it to the meeting.

The pattern is:

  1. Build a RealtimeKitUI instance with RealtimeKitUIBuilder.build().
  2. Access the RealtimeKitClient via rtkUI.meeting.
  3. Place individual UI Kit views in your own XML layout or create them in code.
  4. Call activate(meeting) on each component to bind it to the meeting.

Meeting-level components (grid, header, control bar) take a RealtimeKitClient:

gridView.activate(meeting)
headerView.activate(meeting)
controlBarView.activate(meeting)

Participant-level components (tile, avatar, name tag) take an RtkMeetingParticipant:

tileView.activate(meeting.localUser)
tileView.activate(remoteParticipant)

Similar to RtkMeeting, RtkUIProvider is a wrapper component that provides the design system and UI state context to all child components.

Unlike RtkMeeting, RtkUIProvider allows you to pass any child components to it. All RealtimeKit UI Kit components read from the RtkUIContext for design tokens and state.

Use useContext(RtkUIContext) to read storeStates.meeting and determine which screen to render. When building without RtkMeeting, you must listen to SDK events and call setStates to drive state transitions yourself.

Example code

<!DOCTYPE html>
<html>
    <head>
        <script type="module">
            import { defineCustomElements } from "https://cdn.jsdelivr.net/npm/@cloudflare/realtimekit-ui@latest/loader/index.es2017.js";
            defineCustomElements();
        </script>
        <script src="https://cdn.jsdelivr.net/npm/@cloudflare/realtimekit@latest/dist/browser.js"></script>
    </head>

<body style="margin: 0;">
        <rtk-ui-provider
            id="rtk-ui-provider"
            style="display: flex; flex-direction: column; height: 100vh; margin: 0;"
        >
            <div
                id="meeting-container"
                style="display: flex; flex-direction: column; flex: 1; flex-grow: 1; flex-shrink: 1;"
            >
                Meeting will render here...
            </div>
            <rtk-participants-audio></rtk-participants-audio>
            <rtk-dialog-manager></rtk-dialog-manager>
            <rtk-notifications></rtk-notifications>
        </rtk-ui-provider>
        <script type="module">
            async function initializeMeeting() {
                let currentState = "idle";

const meeting = await RealtimeKitClient.init({
                    authToken: "participant_auth_token",
                });

function renderSetupScreen() {
                    document.querySelector("#meeting-container").innerHTML = `
                    <rtk-setup-screen></rtk-setup-screen>
                `;
                }

function renderWaitingScreen() {
                    document.querySelector("#meeting-container").innerHTML = `
                    <rtk-waiting-screen></rtk-waiting-screen>
                `;
                }

function renderJoinedScreen() {
                    document.querySelector("#meeting-container").innerHTML = `
                    <rtk-header style="display: flex; justify-content: space-between;"></rtk-header>
                    <rtk-stage style="flex: 1; flex-grow: 1; flex-shrink: 1;">
                        <rtk-grid></rtk-grid>
                        <rtk-sidebar style="position: fixed; top:0px;"></rtk-sidebar>
                    </rtk-stage>
                    <rtk-controlbar style="display: flex; justify-content: space-between;"></rtk-controlbar>
                `;
                }

function renderEndedScreen() {
                    document.querySelector("#meeting-container").innerHTML = `
                    <rtk-ended-screen></rtk-ended-screen>
                `;
                }

// Listen for state updates from rtk-ui-provider
                document
                    .querySelector("rtk-ui-provider")
                    .addEventListener("rtkStatesUpdate", (event) => {
                        // Store states to update your custom UI
                        const states = event.detail;

if (states.meeting === "idle" && currentState !== "idle") {
                            currentState = "idle";
                            document
                                .querySelector("rtk-ui-provider")
                                .querySelector("#meeting-container").innerHTML =
                                "Meeting is loading...";
                        } else if (states.meeting === "setup" && currentState !== "setup") {
                            currentState = "setup";
                            renderSetupScreen();
                        } else if (
                            states.meeting === "waiting" &&
                            currentState !== "waiting"
                        ) {
                            currentState = "waiting";
                            renderWaitingScreen();
                        } else if (
                            states.meeting === "joined" &&
                            currentState !== "joined"
                        ) {
                            currentState = "joined";
                            renderJoinedScreen();
                        } else if (states.meeting === "ended" && currentState !== "ended") {
                            currentState = "ended";
                            renderEndedScreen();
                        }

const sidebarComponent = document
                            .querySelector("rtk-ui-provider")
                            .querySelector("#meeting-container")
                            .querySelector("rtk-sidebar");
                        if (sidebarComponent) {
                            if (states.activeSidebar) {
                                sidebarComponent.style.display = "block";
                            } else {
                                sidebarComponent.style.display = "none";
                            }
                        }
                    });

document.querySelector("rtk-ui-provider").showSetupScreen = true;
                document.querySelector("rtk-ui-provider").meeting = meeting;
            }
            initializeMeeting();
        </script>
    </body>

</html>

Note

It is advised to always use rtk-ui-provider to render a custom UI. Without rtk-ui-provider, you will have to set props, such as meeting, to all the UI Kit components manually.

rtk-ui-provider helps you go from

<rtk-header />
<!-- Later do document.querySelector('rtk-header').meeting = meeting; -->

to just

<rtk-header />

It is recommended to use either rtk-meeting or rtk-ui-provider to render a meeting. Using both will result in unexpected behavior. For custom UIs, it is recommended to use rtk-ui-provider always.

First level split of rtk-meeting using rtk-ui-provider has the following components:

rtk-header is the header component that shows the session name and the session controls.
rtk-stage is the container component that contains the grid and sidebar components.
rtk-grid is the grid component that shows the participants in the session.
rtk-sidebar is the sidebar component that shows the sidebar, in which chat, polls content shows up.
rtk-controlbar is the controlbar component that shows the controls, such as camera and microphone.
rtk-notifications is the notifications component that shows the notifications for the session.
rtk-participants-audio is the audio component that helps you listen other participants in the session.
rtk-dialog-manager is the dialog-manager component that shows all supported dialogs, such as settings and breakout rooms.

You can split all of these components further. For more components, refer to the component library.

The UI Kit is open source. Refer to the RealtimeKit UI repository on GitHub ↗︎.

Note

You must manage the CSS that aligns these components yourself. This was previously handled entirely by rtk-meeting. All these components can be styled using CSS.

Note

You must include rtk-notifications, rtk-participants-audio, and rtk-dialog-manager. If you leave them out, features like settings toggles and notifications won’t work, and you won’t hear other participants in the session.

```tsx import { RealtimeKitProvider, useRealtimeKitClient, } from "@cloudflare/realtimekit-react"; import { RtkUiProvider, RtkHeader, RtkStage, RtkGrid, RtkSidebar, RtkControlbar, RtkNotifications, RtkParticipantsAudio, RtkDialogManager, RtkSetupScreen, RtkWaitingScreen, RtkEndedScreen, States, } from "@cloudflare/realtimekit-react-ui"; import { useEffect, useState } from "react";

function MeetingContainer() { const [meeting, initMeeting] = useRealtimeKitClient(); const [currentState, setCurrentState] = useState("idle"); const [showSidebar, setShowSidebar] = useState(false);

useEffect(() => { initMeeting({ authToken: "participant_auth_token", }); }, []);

const renderSetupScreen = () => { return <RtkSetupScreen />; };

const renderWaitingScreen = () => { return <RtkWaitingScreen />; };

const renderJoinedScreen = () => { return ( <> <RtkHeader style={{ display: "flex", justifyContent: "space-between" }} /> <RtkStage style={{ flex: 1, flexGrow: 1, flexShrink: 1 }}> <RtkGrid /> <RtkSidebar style={{ position: "fixed", top: "0px", display: showSidebar ? "block" : "none", }} /> </RtkStage> <RtkControlbar style={{ display: "flex", justifyContent: "space-between" }} /> </> ); };

const renderEndedScreen = () => { return <RtkEndedScreen />; };

// Listen for state updates from RtkUiProvider const handleStatesUpdate = (event: { detail: States }) => { const meetingState = event.detail.meeting; const states = event.detail;