Claude
Skills
Sign in
Back

video-ui

Included with Lifetime
$97 forever

LiveKit video UI components — room provider, participant grid, speaker view, controls bar, prejoin screen, and participant tile using @livekit/components-react and shadcn/ui. Use this skill when the user says "add video UI", "video components", "setup video-ui", "video call UI", or "participant grid".

Design

What this skill does


# Video UI

A complete set of video conferencing UI components built on `@livekit/components-react` and styled with shadcn/ui. Provides a room provider, participant grid, active speaker spotlight, media controls, device selection prejoin screen, and individual participant tiles.

## Prerequisites

- Next.js app with `src/` directory and App Router
- `video-room` skill installed (LiveKit server SDK, token API, types)
- `add-shadcn` skill installed (Button, Card components available)
- shadcn/ui components: `button`, `card`, `select`, `avatar`

## Installation

```bash
bun add @livekit/components-react @livekit/components-styles
bunx shadcn@latest add button card select avatar
```

## What Gets Created

```
src/
└── components/
    └── video/
        ├── video-room-provider.tsx     # LiveKitRoom wrapper, fetches token from /api/video/token
        ├── participant-grid.tsx        # Grid layout using useParticipants + useTracks
        ├── speaker-view.tsx            # Active speaker spotlight using useSpeakingParticipants
        ├── controls-bar.tsx            # Mute / camera / screen share / leave buttons
        ├── prejoin-screen.tsx          # Device selection + camera preview before joining
        └── participant-tile.tsx        # Single participant video track + name overlay + speaking indicator
```

## Setup Steps

### Step 1: Create `src/components/video/video-room-provider.tsx`

```tsx
"use client";

import { useState, useEffect, useCallback, type ReactNode } from "react";
import { LiveKitRoom } from "@livekit/components-react";
import "@livekit/components-styles/prefabs.css";
import type { TokenResponse } from "@/lib/video/types";

type VideoRoomProviderProps = {
  roomName: string;
  participantName?: string;
  children: ReactNode;
  onConnected?: () => void;
  onDisconnected?: () => void;
  onError?: (error: Error) => void;
};

export function VideoRoomProvider({
  roomName,
  participantName,
  children,
  onConnected,
  onDisconnected,
  onError,
}: VideoRoomProviderProps) {
  const [token, setToken] = useState<string | null>(null);
  const [url, setUrl] = useState<string | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  const fetchToken = useCallback(async () => {
    setIsLoading(true);
    setError(null);

    try {
      const res = await fetch("/api/video/token", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ roomName, participantName }),
      });

      if (!res.ok) {
        const body = await res.json().catch(() => ({ error: "Failed to get token" }));
        throw new Error((body as { error: string }).error);
      }

      const data: TokenResponse = await res.json();
      setToken(data.token);
      setUrl(data.url);
    } catch (err) {
      const message = err instanceof Error ? err.message : "Failed to connect";
      setError(message);
      onError?.(err instanceof Error ? err : new Error(message));
    } finally {
      setIsLoading(false);
    }
  }, [roomName, participantName, onError]);

  useEffect(() => {
    fetchToken();
  }, [fetchToken]);

  if (isLoading) {
    return (
      <div className="flex h-full items-center justify-center">
        <div className="flex items-center gap-2 text-muted-foreground">
          <div className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
          <span>Connecting to room...</span>
        </div>
      </div>
    );
  }

  if (error || !token || !url) {
    return (
      <div className="flex h-full items-center justify-center">
        <div className="text-center">
          <p className="text-destructive font-medium">Failed to join room</p>
          <p className="text-muted-foreground text-sm mt-1">{error ?? "Unknown error"}</p>
          <button
            type="button"
            onClick={fetchToken}
            className="mt-4 rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90"
          >
            Retry
          </button>
        </div>
      </div>
    );
  }

  return (
    <LiveKitRoom
      token={token}
      serverUrl={url}
      connect={true}
      onConnected={onConnected}
      onDisconnected={onDisconnected}
      onError={(err) => onError?.(err)}
      data-lk-theme="default"
      className="h-full w-full"
    >
      {children}
    </LiveKitRoom>
  );
}
```

### Step 2: Create `src/components/video/participant-tile.tsx`

```tsx
"use client";

import { useRef, useEffect } from "react";
import type { TrackReferenceOrPlaceholder } from "@livekit/components-react";
import { Card } from "@/components/ui/card";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";

type ParticipantTileProps = {
  trackRef: TrackReferenceOrPlaceholder;
  className?: string;
};

function getInitials(name: string): string {
  return name
    .split(" ")
    .map((part) => part.charAt(0))
    .join("")
    .toUpperCase()
    .slice(0, 2);
}

export function ParticipantTile({ trackRef, className }: ParticipantTileProps) {
  const videoRef = useRef<HTMLVideoElement>(null);
  const participant = trackRef.participant;
  const publication = trackRef.publication;
  const track = publication?.track;
  const isSpeaking = participant.isSpeaking;
  const displayName = participant.name ?? participant.identity;

  useEffect(() => {
    if (!videoRef.current || !track) return;
    track.attach(videoRef.current);
    return () => {
      track.detach(videoRef.current!);
    };
  }, [track]);

  const hasVideo = track && !publication?.isMuted;

  return (
    <Card
      className={`relative overflow-hidden bg-muted ${
        isSpeaking ? "ring-2 ring-primary" : ""
      } ${className ?? ""}`}
    >
      {hasVideo ? (
        <video
          ref={videoRef}
          autoPlay
          playsInline
          muted
          className="h-full w-full object-cover"
        />
      ) : (
        <div className="flex h-full w-full items-center justify-center bg-muted">
          <Avatar className="h-16 w-16">
            <AvatarFallback className="text-lg">
              {getInitials(displayName)}
            </AvatarFallback>
          </Avatar>
        </div>
      )}

      {/* Name overlay */}
      <div className="absolute bottom-0 left-0 right-0 bg-black/50 px-3 py-1.5">
        <div className="flex items-center gap-2">
          {isSpeaking && (
            <span className="h-2 w-2 shrink-0 rounded-full bg-green-400 animate-pulse" />
          )}
          <span className="truncate text-sm font-medium text-white">
            {displayName}
          </span>
        </div>
      </div>
    </Card>
  );
}
```

### Step 3: Create `src/components/video/participant-grid.tsx`

```tsx
"use client";

import { useId } from "react";
import { useTracks } from "@livekit/components-react";
import { Track } from "livekit-client";
import { ParticipantTile } from "@/components/video/participant-tile";

type ParticipantGridProps = {
  className?: string;
};

function getGridCols(count: number): string {
  if (count <= 1) return "grid-cols-1";
  if (count <= 4) return "grid-cols-2";
  if (count <= 9) return "grid-cols-3";
  return "grid-cols-4";
}

export function ParticipantGrid({ className }: ParticipantGridProps) {
  const gridId = useId();

  const trackRefs = useTracks(
    [
      { source: Track.Source.Camera, withPlaceholder: true },
      { source: Track.Source.ScreenShare, withPlaceholder: false },
    ],
    { onlySubscribed: false }
  );

  if (trackRefs.length === 0) {
    return (
      <div className="flex h-full items-center justify-center text-muted-foreground">
        <p>Waiting for participants...</p>
      </div>
    );
  }

  const gridCols = getGridCols(trackRefs.length);

  return (
    <div
      className={`grid gap-2 p-2 ${gridCols} auto-rows-fr ${className ?? ""}`}
    >
      {trackRefs.map((trackRef) => (
        <ParticipantTile
          key={`
Files: 1
Size: 29.8 KB
Complexity: 40/100
Category: Design

Related in Design