Video Player
MUX Player React components with shadcn/ui styling. Includes a responsive player wrapper, chapter navigation sidebar, caption controls, thumbnail hover previews, and video list cards. All components are designed for the App Router with "use client" directives where hooks are used.
Prerequisites
- Next.js app with
src/directory and App Router stream-muxskill applied (MUX backend infrastructure)add-shadcnskill applied (design system with Tailwind v4)- shadcn/ui components:
card,button,badge,scroll-area,dropdown-menu,toggle
Installation
bun add @mux/mux-player-react
bunx shadcn@latest add card button badge scroll-area dropdown-menu toggle
What Gets Created
src/
└── components/
└── video/
├── mux-player.tsx # MUX Player React wrapper
├── video-chapters.tsx # Chapter navigation sidebar
├── video-captions-toggle.tsx # Caption on/off + language selector
├── video-thumbnail-hover.tsx # Thumbnail preview strip on hover
└── video-card.tsx # Video card for lists
Setup Steps
Step 1: Create src/components/video/mux-player.tsx
"use client";
import { useRef, useCallback } from "react";
import MuxPlayerElement from "@mux/mux-player-react";
import type { MuxPlayerRefAttributes } from "@mux/mux-player-react";
import { cn } from "@/lib/utils";
type MuxPlayerProps = {
playbackId: string;
title?: string;
autoPlay?: boolean;
muted?: boolean;
accentColor?: string;
thumbnailTime?: number;
signedToken?: string;
startTime?: number;
className?: string;
onTimeUpdate?: (currentTime: number) => void;
onEnded?: () => void;
onError?: (error: Error) => void;
};
type MuxPlayerRef = {
seekTo: (time: number) => void;
play: () => void;
pause: () => void;
getCurrentTime: () => number;
};
function MuxPlayer({
playbackId,
title,
autoPlay = false,
muted = false,
accentColor,
thumbnailTime,
signedToken,
startTime,
className,
onTimeUpdate,
onEnded,
onError,
}: MuxPlayerProps) {
const playerRef = useRef<MuxPlayerRefAttributes>(null);
const handleTimeUpdate = useCallback(() => {
if (onTimeUpdate && playerRef.current) {
onTimeUpdate(playerRef.current.currentTime);
}
}, [onTimeUpdate]);
const handleError = useCallback(
(event: Event) => {
if (onError) {
const message =
event instanceof ErrorEvent
? event.message
: "Video playback error";
onError(new Error(message));
}
},
[onError]
);
return (
<div
className={cn(
"relative w-full overflow-hidden rounded-lg bg-black",
"aspect-video",
className
)}
>
<MuxPlayerElement
ref={playerRef}
playbackId={playbackId}
metadata={{
video_title: title ?? "",
}}
autoPlay={autoPlay ? "muted" : undefined}
muted={muted}
accentColor={accentColor}
thumbnailTime={thumbnailTime}
tokens={signedToken ? { playback: signedToken } : undefined}
startTime={startTime}
streamType="on-demand"
style={{
width: "100%",
height: "100%",
"--media-object-fit": "contain",
}}
/>
</div>
);
}
export { MuxPlayer };
export type { MuxPlayerProps, MuxPlayerRef };
Step 2: Create src/components/video/video-chapters.tsx
"use client";
import { useState, useId } from "react";
import { cn } from "@/lib/utils";
import { ScrollArea } from "@/components/ui/scroll-area";
type Chapter = {
title: string;
startTime: number;
};
type VideoChaptersProps = {
chapters: Chapter[];
currentTime?: number;
onChapterClick: (startTime: number) => void;
className?: string;
};
function formatTimestamp(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
function getCurrentChapterIndex(
chapters: Chapter[],
currentTime: number
): number {
let activeIndex = 0;
for (let i = 0; i < chapters.length; i++) {
if (currentTime >= chapters[i].startTime) {
activeIndex = i;
}
}
return activeIndex;
}
function VideoChapters({
chapters,
currentTime = 0,
onChapterClick,
className,
}: VideoChaptersProps) {
const listId = useId();
const activeIndex = getCurrentChapterIndex(chapters, currentTime);
return (
<ScrollArea
className={cn(
"w-full rounded-lg border bg-card",
className
)}
>
<div className="p-2">
<h3 className="mb-2 px-2 text-sm font-semibold text-muted-foreground">
Chapters
</h3>
<ul className="space-y-0.5">
{chapters.map((chapter, index) => (
<li key={`${listId}-${chapter.startTime}`}>
<button
type="button"
=> onChapterClick(chapter.startTime)}
className={cn(
"flex w-full items-center gap-3 rounded-md px-2 py-2 text-left text-sm transition-colors",
"hover:bg-accent hover:text-accent-foreground",
index === activeIndex &&
"bg-accent text-accent-foreground font-medium"
)}
>
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{formatTimestamp(chapter.startTime)}
</span>
<span className="truncate">{chapter.title}</span>
</button>
</li>
))}
</ul>
</div>
</ScrollArea>
);
}
export { VideoChapters };
export type { VideoChaptersProps, Chapter };
Step 3: Create src/components/video/video-captions-toggle.tsx
"use client";
import { useCallback, useId } from "react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { cn } from "@/lib/utils";
type CaptionTrack = {
language: string;
label: string;
};
type VideoCaptionsToggleProps = {
tracks: CaptionTrack[];
activeTrack: string | null;
onToggle: (enabled: boolean) => void;
onSelectTrack: (language: string) => void;
className?: string;
};
function CaptionsIcon({ enabled }: { enabled: boolean }) {
return (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={cn(enabled ? "text-foreground" : "text-muted-foreground")}
>
<rect x="2" y="4" width="20" height="16" rx="2" />
<path d="M7 12h4" />
<path d="M13 12h4" />
<path d="M7 16h10" />
</svg>
);
}
function VideoCaptionsToggle({
tracks,
activeTrack,
onToggle,
onSelectTrack,
className,
}: VideoCaptionsToggleProps) {
const menuId = useId();
const isEnabled = activeTrack !== null;
const handleToggle = useCallback(() => {
onToggle(!isEnabled);
}, [isEnabled, onToggle]);
if (tracks.length === 0) {
return null;
}
if (tracks.length === 1) {
return (
<Button
variant={isEnabled ? "default" : "outline"}
size="sm"
className={cn("gap-2", className)}
aria-label={isEnabled ? "Disable captions" : "Enable captions"}
>
<CaptionsIcon enabled={isEnabled} />
<span className="text-xs">CC</span>
</Button>
);
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant={isEnabled ? "default" : "outline"}
size="sm"
className={cn("gap-2", className)}
aria-label="Caption settings"
>
<CaptionsIcon enabled={isEnabled} />
<span className="text-xs">CC</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
=> onToggle(false)}
className={cn(!isEnabled && "font-medium")}
>
Off
</DropdownMenuItem>
{tracks.map((track) => (
<DropdownMenuItem
key={`${menuId}-${track.language}`}
=> onSelectTrack(track.language)}
className={cn(
activeTrack === track.language && "font-medium"
)}
>
{track.label}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
export { VideoCaptionsToggle };
export type { VideoCaptionsToggleProps, CaptionTrack };
Step 4: Create src/components/video/video-thumbnail-hover.tsx
"use client";
import { useState, useCallback, useRef, useMemo } from "react";
import { cn } from "@/lib/utils";
type VideoThumbnailHoverProps = {
playbackId: string;
duration: number;
thumbnailCount?: number;
onSeek?: (time: number) => void;
className?: string;
};
function formatTimestamp(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
function VideoThumbnailHover({
playbackId,
duration,
thumbnailCount = 10,
onSeek,
className,
}: VideoThumbnailHoverProps) {
const containerRef = useRef<HTMLDivElement>(null);
const [hoverPosition, setHoverPosition] = useState<number | null>(null);
const [hoverTime, setHoverTime] = useState(0);
const thumbnailUrl = useMemo(() => {
if (hoverPosition === null) return null;
const time = Math.floor(hoverPosition * duration);
return `https://image.mux.com/${playbackId}/thumbnail.jpg?time=${time}&width=160&height=90`;
}, [playbackId, duration, hoverPosition]);
const handleMouseMove = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (!containerRef.current) return;
const rect = containerRef.current.getBoundingClientRect();
const position = (e.clientX - rect.left) / rect.width;
const clampedPosition = Math.max(0, Math.min(1, position));
setHoverPosition(clampedPosition);
setHoverTime(clampedPosition * duration);
},
[duration]
);
const handleMouseLeave = useCallback(() => {
setHoverPosition(null);
}, []);
const handleClick = useCallback(() => {
if (onSeek && hoverPosition !== null) {
onSeek(hoverPosition * duration);
}
}, [onSeek, hoverPosition, duration]);
return (
<div
ref={containerRef}
className={cn(
"group relative h-8 w-full cursor-pointer rounded bg-muted",
className
)}
role="slider"
aria-label="Video timeline"
aria-valuemin={0}
aria-valuemax={duration}
aria-valuenow={hoverTime}
tabIndex={0}
>
{/* Progress track */}
<div className="absolute inset-0 rounded bg-muted" />
{/* Hover indicator */}
{hoverPosition !== null && (
<>
{/* Vertical line */}
<div
className="absolute top-0 h-full w-0.5 bg-foreground/50"
style={{ left: `${hoverPosition * 100}%` }}
/>
{/* Thumbnail preview */}
<div
className="absolute bottom-full mb-2 -translate-x-1/2"
style={{
left: `${Math.max(10, Math.min(90, hoverPosition * 100))}%`,
}}
>
<div className="overflow-hidden rounded-md border bg-background shadow-lg">
{thumbnailUrl && (
<img
src={thumbnailUrl}
alt={`Preview at ${formatTimestamp(hoverTime)}`}
width={160}
height={90}
className="block"
loading="eager"
/>
)}
<div className="px-2 py-1 text-center text-xs font-mono text-muted-foreground">
{formatTimestamp(hoverTime)}
</div>
</div>
</div>
</>
)}
</div>
);
}
export { VideoThumbnailHover };
export type { VideoThumbnailHoverProps };
Step 5: Create src/components/video/video-card.tsx
"use client";
import { useState, useCallback, useId } from "react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
type VideoCardProps = {
playbackId: string;
title: string;
duration: number | null;
thumbnailTime?: number;
status?: "preparing" | "ready" | "errored" | "deleted";
onClick?: () => void;
onPlay?: () => void;
className?: string;
};
function formatDuration(seconds: number): string {
const hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`;
}
return `${mins}:${secs.toString().padStart(2, "0")}`;
}
function PlayIcon() {
return (
<svg
width="48"
height="48"
viewBox="0 0 48 48"
fill="currentColor"
className="drop-shadow-lg"
>
<circle cx="24" cy="24" r="24" fillOpacity="0.7" />
<polygon points="18,14 36,24 18,34" fill="white" />
</svg>
);
}
function VideoCard({
playbackId,
title,
duration,
thumbnailTime = 0,
status = "ready",
onClick,
onPlay,
className,
}: VideoCardProps) {
const cardId = useId();
const [imageError, setImageError] = useState(false);
const thumbnailUrl = playbackId
? `https://image.mux.com/${playbackId}/thumbnail.jpg?width=640&height=360&time=${thumbnailTime}`
: null;
const handleClick = useCallback(() => {
if (onClick) {
onClick();
}
}, [onClick]);
const handlePlayClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
if (onPlay) {
onPlay();
} else if (onClick) {
onClick();
}
},
[onPlay, onClick]
);
const handleImageError = useCallback(() => {
setImageError(true);
}, []);
const isPlayable = status === "ready" && playbackId;
return (
<Card
className={cn(
"group cursor-pointer overflow-hidden transition-shadow hover:shadow-md",
!isPlayable && "opacity-60",
className
)}
role="button"
tabIndex={0}
aria-label={`${title}${duration ? ` - ${formatDuration(duration)}` : ""}`}
>
{/* Thumbnail */}
<div className="relative aspect-video w-full overflow-hidden bg-muted">
{thumbnailUrl && !imageError ? (
<img
src={thumbnailUrl}
alt={title}
className="h-full w-full object-cover transition-transform group-hover:scale-105"
loading="lazy"
/>
) : (
<div className="flex h-full w-full items-center justify-center bg-muted">
<svg
width="48"
height="48"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1"
className="text-muted-foreground"
>
<rect x="2" y="4" width="20" height="16" rx="2" />
<polygon points="10,8 16,12 10,16" fill="currentColor" />
</svg>
</div>
)}
{/* Play button overlay */}
{isPlayable && (
<button
type="button"
className="absolute inset-0 flex items-center justify-center text-white opacity-0 transition-opacity group-hover:opacity-100"
aria-label={`Play ${title}`}
>
<PlayIcon />
</button>
)}
{/* Duration badge */}
{duration !== null && duration > 0 && (
<Badge
variant="secondary"
className="absolute bottom-2 right-2 bg-black/70 text-white font-mono text-xs"
>
{formatDuration(duration)}
</Badge>
)}
{/* Status badge for non-ready assets */}
{status !== "ready" && (
<Badge
variant={status === "errored" ? "destructive" : "secondary"}
className="absolute top-2 left-2"
>
{status === "preparing" ? "Processing..." : status}
</Badge>
)}
</div>
{/* Title */}
<CardContent className="p-3">
<h3 className="text-sm font-medium leading-tight line-clamp-2">
{title}
</h3>
</CardContent>
</Card>
);
}
export { VideoCard };
export type { VideoCardProps };
Usage
Basic Player
"use client";
import { MuxPlayer } from "@/components/video/mux-player";
function VideoPage() {
return (
<MuxPlayer
playbackId="DS00Spx1CV902MCtPj5WknGlR102V5HFkDe"
title="My Video"
accentColor="#10b981"
/>
);
}
Player with Chapters
"use client";
import { useState, useCallback, useRef } from "react";
import { MuxPlayer } from "@/components/video/mux-player";
import { VideoChapters } from "@/components/video/video-chapters";
import type { Chapter } from "@/components/video/video-chapters";
const chapters: Chapter[] = [
{ title: "Introduction", startTime: 0 },
{ title: "Getting Started", startTime: 60 },
{ title: "Advanced Topics", startTime: 300 },
{ title: "Conclusion", startTime: 600 },
];
function VideoWithChapters() {
const [currentTime, setCurrentTime] = useState(0);
const handleChapterClick = useCallback((startTime: number) => {
// In production, wire this to the player's seekTo method
console.log("Seek to:", startTime);
}, []);
return (
<div className="flex gap-4">
<div className="flex-1">
<MuxPlayer
playbackId="DS00Spx1CV902MCtPj5WknGlR102V5HFkDe"
title="Tutorial"
/>
</div>
<VideoChapters
chapters={chapters}
currentTime={currentTime}
className="w-64 h-[400px]"
/>
</div>
);
}
Video Card Grid
"use client";
import { useId } from "react";
import { VideoCard } from "@/components/video/video-card";
type Video = {
id: string;
playbackId: string;
title: string;
duration: number | null;
status: "preparing" | "ready" | "errored" | "deleted";
};
function VideoGrid({ videos }: { videos: Video[] }) {
const gridId = useId();
return (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{videos.map((video) => (
<VideoCard
key={`${gridId}-${video.id}`}
playbackId={video.playbackId}
title={video.title}
duration={video.duration}
status={video.status}
=> console.log("Navigate to:", video.id)}
=> console.log("Play:", video.id)}
/>
))}
</div>
);
}
Captions Toggle
"use client";
import { useState, useCallback } from "react";
import { MuxPlayer } from "@/components/video/mux-player";
import { VideoCaptionsToggle } from "@/components/video/video-captions-toggle";
import type { CaptionTrack } from "@/components/video/video-captions-toggle";
const captionTracks: CaptionTrack[] = [
{ language: "en", label: "English" },
{ language: "es", label: "Spanish" },
];
function VideoWithCaptions() {
const [activeTrack, setActiveTrack] = useState<string | null>(null);
const handleToggle = useCallback((enabled: boolean) => {
setActiveTrack(enabled ? "en" : null);
}, []);
const handleSelectTrack = useCallback((language: string) => {
setActiveTrack(language);
}, []);
return (
<div className="space-y-2">
<MuxPlayer
playbackId="DS00Spx1CV902MCtPj5WknGlR102V5HFkDe"
title="Captioned Video"
/>
<div className="flex justify-end">
<VideoCaptionsToggle
tracks={captionTracks}
activeTrack={activeTrack}
/>
</div>
</div>
);
}
Thumbnail Hover Preview
"use client";
import { useCallback } from "react";
import { VideoThumbnailHover } from "@/components/video/video-thumbnail-hover";
function TimelinePreview() {
const handleSeek = useCallback((time: number) => {
console.log("Seek to:", time);
}, []);
return (
<VideoThumbnailHover
playbackId="DS00Spx1CV902MCtPj5WknGlR102V5HFkDe"
duration={600}
/>
);
}
Acceptance Criteria
-
bun add @mux/mux-player-reactinstalls without errors -
MuxPlayerrenders a responsive video player with the correct playback ID -
MuxPlayersupportsautoPlay,muted,accentColor,thumbnailTimeprops -
MuxPlayersupports signed playback URLs viasignedToken -
VideoChaptersrenders a scrollable chapter list -
VideoChaptershighlights the currently active chapter based oncurrentTime - Clicking a chapter fires
onChapterClickwith the correctstartTime -
VideoCaptionsTogglerenders a single toggle for one caption track -
VideoCaptionsTogglerenders a dropdown menu for multiple caption tracks -
VideoThumbnailHovershows a MUX thumbnail preview on hover -
VideoCarddisplays thumbnail, title, duration badge -
VideoCardshows a play button overlay on hover -
VideoCardshows status badge for non-ready assets - All components use
useIdfor React list keys - All components use
"use client"directive - All components use shadcn/ui primitives (Card, Button, Badge, etc.)
- Styling uses Tailwind v4 utility classes
- No usage of
anytype anywhere in the code -
tscpasses with no errors -
bun run buildsucceeds
Troubleshooting
MUX Player not rendering
Symptoms: The player area is blank or shows a black rectangle.
Cause: Invalid playbackId or the asset is not yet in "ready" status.
Fix: Verify the playback ID is correct and the asset status is "ready" in MUX. Use the stream-mux skill's /api/mux/assets/[id] endpoint to check status.
Thumbnails not loading
Symptoms: Video cards show a placeholder icon instead of thumbnails.
Cause: The MUX thumbnail URL requires a valid playback ID. Assets in "preparing" status do not have thumbnails yet.
Fix: Only show thumbnails for assets with status === "ready" and a non-empty playbackId. The VideoCard component handles this gracefully with a fallback.
Player accent color not matching theme
Symptoms: Player controls use a different color than your shadcn theme.
Cause: MUX Player uses its own theming system separate from Tailwind/shadcn.
Fix: Pass the accentColor prop to MuxPlayer with your theme's primary color hex value. You can extract it from your CSS custom properties:
<MuxPlayer
playbackId="..."
accentColor="hsl(var(--primary))"
/>
Chapter sidebar not scrolling
Symptoms: Chapter list overflows its container.
Cause: The VideoChapters component needs an explicit height to enable scrolling via ScrollArea.
Fix: Set a height on the component: <VideoChapters className="h-[400px]" ... />
"use client" missing errors
Symptoms: Build error about hooks being used in a Server Component.
Cause: All video player components use hooks and must be rendered client-side.
Fix: All components in this skill include "use client" at the top. If you wrap them in a parent component, that parent must also have "use client".