INTEGRITY Cloudflare Docs

Build Your Own UI

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.

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:

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>

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, microphone, etc.
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 the all supported dialogs, such as settings, breakout rooms, etc.

You can split all of these components further. To see more such components, please refer to our components library.

We have our UI Kit open source on GitHub, you can find it here.

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;

		// Store states to update your custom UI
		if (meetingState === "idle" && currentState !== "idle") {
			setCurrentState("idle");
		} else if (meetingState === "setup" && currentState !== "setup") {
			setCurrentState("setup");
		} else if (meetingState === "waiting" && currentState !== "waiting") {
			setCurrentState("waiting");
		} else if (meetingState === "joined" && currentState !== "joined") {
			setCurrentState("joined");
		} else if (meetingState === "ended" && currentState !== "ended") {
			setCurrentState("ended");
		}

		// Update sidebar visibility based on state
		if (states.activeSidebar !== undefined) {
			setShowSidebar(states.activeSidebar);
		}
	};

	return (
		<RealtimeKitProvider value={meeting}>
			<RtkUiProvider
				meeting={meeting}
				showSetupScreen={true}
				onRtkStatesUpdate={handleStatesUpdate}
				style={{
					display: "flex",
					flexDirection: "column",
					height: "100vh",
					margin: 0,
				}}
			>
				<div
					id="meeting-container"
					style={{
						display: "flex",
						flexDirection: "column",
						flex: 1,
						flexGrow: 1,
						flexShrink: 1,
					}}
				>
					{currentState === "idle" && <div>Meeting is loading...</div>}
					{currentState === "setup" && renderSetupScreen()}
					{currentState === "waiting" && renderWaitingScreen()}
					{currentState === "joined" && renderJoinedScreen()}
					{currentState === "ended" && renderEndedScreen()}
				</div>
				<RtkParticipantsAudio />
				<RtkDialogManager />
				<RtkNotifications />
			</RtkUiProvider>
		</RealtimeKitProvider>
	);
}

function App() {
	return <MeetingContainer />;
}

First level split of RtkMeeting using RtkUiProvider has the following components:

RtkHeader is the header component that shows the session name and the session controls.
RtkStage is the container component that contains the grid and sidebar components.
RtkGrid is the grid component that shows the participants in the session.
RtkSidebar is the sidebar component that shows the sidebar, in which chat, polls content shows up.
RtkControlbar is the controlbar component that shows the controls, such as camera, microphone, etc.
RtkNotifications is the notifications component that shows the notifications for the session.
RtkParticipantsAudio is the audio component that helps you listen other participants in the session.
RtkDialogManager is the dialog-manager component that shows the dialogs for the session.

You can split all of these components further. To see more such components, please refer to our components library.

We have our UI Kit open source on GitHub, you can find it here.

In your app.module.ts, import the RealtimeKitComponentsModule along with all the custom modules you have built using the component library (example shown below).

app.module.ts
import { NgModule } from "@angular/core";
import { BrowserModule } from "@angular/platform-browser";
import { FormsModule } from "@angular/forms";

import { AppComponent } from "./app.component";
import { CustomRtkMeetingComponent } from "./components/custom-rtk-meeting.component";
import { SetupScreenComponent } from "./components/setup-screen.component";
import { InMeetingComponent } from "./components/in-meeting.component";
import { MeetingHeaderComponent } from "./components/meeting-header.component";
import { MeetingControlBarComponent } from "./components/meeting-control-bar.component";
import { MeetingSidebarComponent } from "./components/meeting-sidebar.component";
import { MediaPreviewModalComponent } from "./components/media-preview-modal.component";
import { AudioPreviewComponent } from "./components/audio-preview.component";
import { VideoPreviewComponent } from "./components/video-preview.component";

import { RealtimeKitComponentsModule } from "@cloudflare/realtimekit-angular-ui";

@NgModule({
	declarations: [
		AppComponent,
		CustomRtkMeetingComponent,
		SetupScreenComponent,
		InMeetingComponent,
		MeetingHeaderComponent,
		MeetingControlBarComponent,
		MeetingSidebarComponent,
		MediaPreviewModalComponent,
		AudioPreviewComponent,
		VideoPreviewComponent,
	],
	imports: [BrowserModule, FormsModule, RealtimeKitComponentsModule],
	providers: [],
	bootstrap: [AppComponent],
})
export class AppModule {}

Initialize the meeting in your app.component.ts

app.component.ts
import { Component, OnInit, Inject } from "@angular/core";
import { DOCUMENT } from "@angular/common";
import { StatesService } from "./services/states.service";
import RealtimeKitClient from "@cloudflare/realtimekit";

@Component({
	selector: "app-root",
	templateUrl: "./app.component.html",
	styleUrls: ["./app.component.css"],
})
export class AppComponent implements OnInit {
	meeting: any = null;

	constructor(
		private statesService: StatesService,
		@Inject(DOCUMENT) private document: Document,
	) {}

	async ngOnInit() {
		await this.initializeMeeting();
	}

	private async initializeMeeting() {
		const searchParams = new URLSearchParams(
			this.document.defaultView?.location.search,
		);
		const authToken = searchParams.get("authToken");

		if (!authToken) {
			alert(
				"An authToken wasn't passed, please pass an authToken in the URL query to join a meeting.",
			);
			return;
		}

		if (!this.meeting) {
			try {
				// Initialize RealtimeKit client
				const meeting = await RealtimeKitClient.init({
					authToken,
				});

				this.meeting = meeting;

				// Expose meeting object to window for debugging
				Object.assign(this.document.defaultView as any, {
					meeting: this.meeting,
				});
			} catch (error) {
				console.error("Failed to initialize meeting:", error);
			}
		}
	}

	onRtkStatesUpdate(event: any) {
		this.statesService.setStates(event.detail);
	}
}
app.component.html
<rtk-ui-provider
	[meeting]="meeting"
	(rtkStatesUpdate)="onRtkStatesUpdate($event)"
	[showSetupScreen]="true"
	style="height: 100%; width: 100%; display: block;"
>
	<app-custom-rtk-meeting></app-custom-rtk-meeting>
	<rtk-dialog-manager></rtk-dialog-manager>
</rtk-ui-provider>

This is an example of what a custom element built using UI Kit components looks like:

components/custom-rtk-component.ts
import { Component, OnInit, OnDestroy } from "@angular/core";
import { Subject, takeUntil } from "rxjs";
import { StatesService, CustomStatesService } from "../services/states.service";
import { States } from "@cloudflare/realtimekit-ui";
import { CustomStates } from "../types";

@Component({
	selector: "app-custom-rtk-meeting",
	template: `
		<rtk-idle-screen *ngIf="states.meeting === 'idle'"></rtk-idle-screen>
		<app-setup-screen *ngIf="states.meeting === 'setup'"></app-setup-screen>
		<rtk-waiting-screen
			*ngIf="states.meeting === 'waiting'"
		></rtk-waiting-screen>
		<rtk-ended-screen *ngIf="states.meeting === 'ended'"></rtk-ended-screen>
		<app-in-meeting
			*ngIf="states.meeting === 'joined' || !states.meeting"
		></app-in-meeting>
	`,
})
export class CustomRtkMeetingComponent implements OnInit, OnDestroy {
	states: States = { meeting: "idle" } as States;
	customStates: CustomStates = {};
	private destroy$ = new Subject<void>();

	constructor(
		private statesService: StatesService,
		private customStatesService: CustomStatesService,
	) {}

	ngOnInit() {
		this.statesService.states$
			.pipe(takeUntil(this.destroy$))
			.subscribe((states) => {
				this.states = states;
				console.log(states, this.customStates);
			});

		this.customStatesService.customStates$
			.pipe(takeUntil(this.destroy$))
			.subscribe((customStates) => {
				this.customStates = customStates;
				console.log(this.states, customStates);
			});
	}

	ngOnDestroy() {
		this.destroy$.next();
		this.destroy$.complete();
	}
}

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, microphone, etc.
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 the all supported dialogs, such as settings, breakout rooms, etc.

You can split all of these components further. To see more such components, please refer to our components library.

We have our UI Kit open source on GitHub, you can find it here.

The following example replaces the group call meeting screen with a custom view controller that uses individual UI Kit components:

import UIKit
import RealtimeKit
import RealtimeKitUI

class CustomFlowDelegate: RtkUIFlowCoordinatorDelegate {
    // Return nil to use the default setup screen
    func showSetUpScreen(completion: () -> Void) -> SetupViewControllerDataSource? {
        return nil
    }

    // Return a custom view controller for group calls
    func showGroupCallMeetingScreen(
        meeting: RealtimeKitClient,
        completion: @escaping () -> Void
    ) -> UIViewController? {
        return CustomMeetingViewController(meeting: meeting, completion: completion)
    }

    // Return nil to use the default webinar screen
    func showWebinarMeetingScreen(
        meeting: RealtimeKitClient,
        completion: @escaping () -> Void
    ) -> UIViewController? {
        return nil
    }
}

class CustomMeetingViewController: UIViewController {
    private let meeting: RealtimeKitClient
    private let completion: () -> Void
    private var selfListener: RtkEventSelfListener?

    init(meeting: RealtimeKitClient, completion: @escaping () -> Void) {
        self.meeting = meeting
        self.completion = completion
        super.init(nibName: nil, bundle: nil)
    }

    required init?(coder: NSCoder) { fatalError("init(coder:) not supported") }

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .black

        // Header
        let header = RtkMeetingHeaderView(meeting: meeting)
        header.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(header)

        // Control bar
        let controlBar = RtkMeetingControlBar(
            meeting: meeting,
            delegate: nil,
            presentingViewController: self
        )
        controlBar.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(controlBar)

        NSLayoutConstraint.activate([
            header.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
            header.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            header.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            header.heightAnchor.constraint(equalToConstant: 50),
            controlBar.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
            controlBar.leadingAnchor.constraint(equalTo: view.leadingAnchor),
            controlBar.trailingAnchor.constraint(equalTo: view.trailingAnchor),
            controlBar.heightAnchor.constraint(equalToConstant: 80),
        ])

        // Listen for meeting events
        selfListener = RtkEventSelfListener(rtkClient: meeting)
        selfListener?.observeSelfRemoved { [weak self] _ in
            self?.dismiss(animated: true, completion: self?.completion)
        }
        selfListener?.observeSelfMeetingEndForAll { [weak self] _ in
            self?.dismiss(animated: true, completion: self?.completion)
        }
    }
}

Present the meeting with your custom flow delegate:

let delegate = CustomFlowDelegate()
let rtkUI = RealtimeKitUI(
    meetingInfo: RtkMeetingInfo(
        authToken: "<auth-token>",
        enableAudio: true,
        enableVideo: true
    ),
    flowDelegate: delegate
)

let controller = rtkUI.startMeeting {
    self.dismiss(animated: true)
}
controller.modalPresentationStyle = .fullScreen
present(controller, animated: true)

Individual components available for custom meeting screens:

Component Description
RtkMeetingHeaderView Header with meeting title, participant count, clock, and recording indicator
RtkMeetingControlBar Control bar with microphone, camera, and end meeting buttons
RtkParticipantTileView Single participant video tile with avatar fallback and name tag
RtkVideoView Raw video rendering view
RtkAvatarView Participant avatar with initials
RtkNameTag Participant name with microphone status icon
RtkAudioButtonControlBar Standalone microphone toggle button
RtkVideoButtonControlBar Standalone camera toggle button
RtkEndMeetingControlBarButton End/leave meeting button
RtkEventSelfListener Observer for audio/video state, removal, reconnection, and permissions

The following example creates a custom meeting screen using individual UI Kit views in an XML layout with the activate(meeting) pattern:

activity_custom_meeting.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:background="#000000">

    <com.cloudflare.realtimekit.ui.view.headers.RtkMeetingHeaderView
        android:id="@+id/headerView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

    <com.cloudflare.realtimekit.ui.view.grid.RtkGridView
        android:id="@+id/gridView"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1" />

    <com.cloudflare.realtimekit.ui.view.controlbars.RtkMeetingControlBarView
        android:id="@+id/controlBarView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />

</LinearLayout>
CustomMeetingActivity.kt
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import com.cloudflare.realtimekit.RealtimeKitClient
import com.cloudflare.realtimekit.RtkMeetingRoomEventListener
import com.cloudflare.realtimekit.models.RtkMeetingInfo
import com.cloudflare.realtimekit.ui.RealtimeKitUIBuilder
import com.cloudflare.realtimekit.ui.RealtimeKitUIInfo
import com.cloudflare.realtimekit.ui.view.controlbars.RtkMeetingControlBarView
import com.cloudflare.realtimekit.ui.view.grid.RtkGridView
import com.cloudflare.realtimekit.ui.view.headers.RtkMeetingHeaderView

class CustomMeetingActivity : AppCompatActivity() {
    private lateinit var headerView: RtkMeetingHeaderView
    private lateinit var gridView: RtkGridView
    private lateinit var controlBarView: RtkMeetingControlBarView

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_custom_meeting)

        headerView = findViewById(R.id.headerView)
        gridView = findViewById(R.id.gridView)
        controlBarView = findViewById(R.id.controlBarView)

        val meetingInfo = RtkMeetingInfo(authToken = "<auth-token>")
        val uiKitInfo = RealtimeKitUIInfo(
            activity = this,
            rtkMeetingInfo = meetingInfo,
        )
        val rtkUI = RealtimeKitUIBuilder.build(uiKitInfo)
        val meeting: RealtimeKitClient = rtkUI.meeting

        // Listen for meeting lifecycle events
        meeting.addMeetingRoomEventListener(object : RtkMeetingRoomEventListener {
            override fun onMeetingRoomJoinCompleted(meeting: RealtimeKitClient) {
                runOnUiThread {
                    // Activate all components once the meeting is joined
                    headerView.activate(meeting)
                    gridView.activate(meeting)
                    controlBarView.activate(meeting)
                }
            }

            override fun onMeetingEnded() {
                finish()
            }

            override fun onMeetingRoomLeaveCompleted() {
                finish()
            }
        })
    }
}

Individual components available for custom layouts:

Component Activate with Description
RtkGridView RealtimeKitClient Paginated participant video grid
RtkMeetingHeaderView RealtimeKitClient Header with title, participant count, clock, and recording indicator
RtkMeetingControlBarView RealtimeKitClient Control bar with mic, camera, more, and leave buttons
RtkParticipantTileView RtkMeetingParticipant Single participant video tile with avatar and name tag
RtkMicToggleButton RealtimeKitClient Standalone microphone toggle
RtkCameraToggleButton RealtimeKitClient Standalone camera toggle
RtkLeaveButton RealtimeKitClient Leave/end meeting button
RtkAvatarView RtkMeetingParticipant Participant avatar
RtkNameTagView RtkMeetingParticipant Name tag with audio indicator
RtkJoinButton RealtimeKitClient Join meeting button for setup screens

The following example uses RtkUIProvider and individual components with state-based screen rendering, similar to the web React pattern:

CustomMeeting.tsx
import React, { useContext, useEffect } from "react";
import { View, Text } from "react-native";
import {
	useRealtimeKitClient,
	RealtimeKitProvider,
} from "@cloudflare/realtimekit-react-native";
import {
	RtkUIProvider,
	RtkUIContext,
	RtkHeader,
	RtkGrid,
	RtkControlbar,
	RtkSidebar,
	RtkDialogManager,
	RtkSetupScreen,
	RtkWaitingScreen,
	RtkEndedScreen,
} from "@cloudflare/realtimekit-react-native-ui";

function MeetingScreens({ meeting }) {
	const { storeStates, setStates } = useContext(RtkUIContext);
	const currentState = storeStates.meeting;

	useEffect(() => {
		// Listen for meeting events and drive state transitions
		const onRoomJoined = () => {
			setStates({ ...storeStates, meeting: "joined" });
		};
		const onRoomLeft = ({ state }) => {
			if (state === "ended" || state === "left") {
				setStates({ ...storeStates, meeting: "ended" });
			}
		};
		meeting.self.addListener("roomJoined", onRoomJoined);
		meeting.self.addListener("roomLeft", onRoomLeft);
		return () => {
			meeting.self.removeListener("roomJoined", onRoomJoined);
			meeting.self.removeListener("roomLeft", onRoomLeft);
		};
	}, [meeting]);

	if (currentState === "setup") {
		return <RtkSetupScreen meeting={meeting} />;
	}

	if (currentState === "waiting") {
		return <RtkWaitingScreen />;
	}

	if (currentState === "joined") {
		return (
			<View style={{ flex: 1 }}>
				<RtkHeader meeting={meeting} />
				<View style={{ flex: 1 }}>
					<RtkGrid meeting={meeting} />
					{storeStates.activeSidebar && <RtkSidebar meeting={meeting} />}
				</View>
				<RtkControlbar meeting={meeting} />
				<RtkDialogManager meeting={meeting} />
			</View>
		);
	}

	if (currentState === "ended") {
		return <RtkEndedScreen meeting={meeting} />;
	}

	return <Text>Loading...</Text>;
}

function CustomMeeting({ authToken }) {
	const [meet, initMeeting] = useRealtimeKitClient();
	const { setStates, storeStates } = useContext(RtkUIContext);

	useEffect(() => {
		initMeeting({
			authToken,
			defaults: { audio: true, video: true },
		});
	}, [authToken]);

	useEffect(() => {
		if (meet) {
			setStates({ ...storeStates, meeting: "setup" });
		}
	}, [meet]);

	if (!meet) return <Text>Initializing...</Text>;

	return (
		<RealtimeKitProvider value={meet}>
			<MeetingScreens meeting={meet} />
		</RealtimeKitProvider>
	);
}

export default function App() {
	return (
		<RtkUIProvider>
			<CustomMeeting authToken="<auth-token>" />
		</RtkUIProvider>
	);
}

First level split of RtkMeeting has the following components:

Component Required prop Description
RtkHeader meeting Header with meeting title, participant count, clock, and recording indicator
RtkGrid meeting Paginated participant video grid
RtkControlbar meeting Control bar with mic, camera, screenshare, and more toggles
RtkSidebar meeting Sidebar with chat, polls, participants, and plugins panels
RtkDialogManager meeting Manages all dialogs (settings, leave confirmation, join stage)
RtkSetupScreen meeting Pre-join screen with video preview and device selection
RtkWaitingScreen Waiting room screen
RtkEndedScreen meeting Meeting ended screen
RtkMicToggle meeting Standalone microphone toggle button
RtkCameraToggle meeting Standalone camera toggle button
RtkLeaveButton Leave/end meeting button
RtkParticipantTile meeting, participant Single participant video tile
RtkNotifications meeting Toast notifications for join/leave/chat events