Video Player
Custom React video player with playback controls, volume slider, fullscreen mode, keyboard shortcuts, and progress bar. Fully accessible and styleable.
Last updated on
import { VideoPlayer } from "@/components/ui/video-player";
export function VideoPlayerDemo() {
return (
<div className="mx-auto w-full max-w-4xl">
<VideoPlayer
src="https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"
poster="https://peach.blender.org/wp-content/uploads/title_anouncement.jpg?x11217"
title="Big Buck Bunny"
description="A large and lovable rabbit deals with three tiny bullies, led by a flying squirrel, who are determined to squelch his happiness."
/>
</div>
);
}Installation
CLI
npx shadcn@latest add "https://jolyui.dev/r/video-player"Manual
Install the following dependencies:
npm install lucide-react motionCopy and paste the following code into your project.
"use client";
import {
ChevronLeft,
ChevronRight,
ChevronsLeft,
ChevronsRight,
Loader2,
Maximize,
Maximize2,
MessageCircle,
Minimize,
MoreVertical,
Pause,
PictureInPicture2,
Play,
Repeat,
RotateCcw,
RotateCw,
Settings,
Volume2,
VolumeX,
} from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import type React from "react";
import {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
interface QualitySource {
quality: string;
src: string;
}
interface CaptionTrack {
src: string;
label: string;
srcLang: string;
default?: boolean;
}
interface Chapter {
title: string;
startTime: number;
endTime: number;
}
export interface VideoPlayerProps {
src: string | QualitySource[];
tracks?: CaptionTrack[];
poster?: string;
title?: string;
description?: string;
compact?: boolean;
chapters?: Chapter[];
onTimeUpdate?: (time: number) => void;
onNextVideo?: () => void;
onPrevVideo?: () => void;
currentVideoIndex?: number;
totalVideos?: number;
}
export interface VideoPlayerRef {
seek: (time: number) => void;
play: () => void;
pause: () => void;
}
const Tooltip = ({
children,
label,
}: {
children: React.ReactNode;
label: string;
}) => {
const [isHovered, setIsHovered] = useState(false);
return (
<div
className="relative inline-flex"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
role="button"
tabIndex={0}
>
{children}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: isHovered ? 1 : 0 }}
transition={{ duration: 0.2 }}
className="-translate-x-1/2 pointer-events-none absolute bottom-full left-1/2 z-50 mb-2 whitespace-nowrap rounded bg-black/90 px-2 py-1 text-white text-xs"
>
{label}
</motion.div>
</div>
);
};
export const VideoPlayer = forwardRef<VideoPlayerRef, VideoPlayerProps>(
(
{
src,
tracks = [],
poster,
title: _title,
description: _description,
compact: _compact = false,
chapters = [],
onTimeUpdate,
onNextVideo,
onPrevVideo,
currentVideoIndex = 0,
totalVideos = 1,
},
ref,
) => {
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [isPlaying, setIsPlaying] = useState(false);
const [isMuted, setIsMuted] = useState(false);
const [isFullscreen, setIsFullscreen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const [showControls, setShowControls] = useState(true);
const [activeDialog, setActiveDialog] = useState<
"settings" | "options" | "captions" | null
>(null);
const [volume, setVolume] = useState(1);
const [showVolumeSlider, setShowVolumeSlider] = useState(false);
const [quality, setQuality] = useState("auto");
const [availableQualities, setAvailableQualities] = useState<string[]>([]);
const [currentSrc, setCurrentSrc] = useState("");
const [speed, setSpeed] = useState(1);
const [isPictureInPicture, setIsPictureInPicture] = useState(false);
const [currentCaption, setCurrentCaption] = useState<string | null>(null);
const [isTheaterMode, setIsTheaterMode] = useState(false);
const [isLooping, setIsLooping] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
const [duration, setDuration] = useState(0);
const [buffered, setBuffered] = useState(0);
// Hover state for timeline
const [hoverTime, setHoverTime] = useState<number | null>(null);
const [hoverPosition, setHoverPosition] = useState<number | null>(null);
// Double tap state
const [doubleTapAction, setDoubleTapAction] = useState<{
side: "left" | "right";
id: number;
} | null>(null);
const lastTapRef = useRef<{ time: number; x: number } | null>(null);
const tapTimeoutRef = useRef<NodeJS.Timeout>(undefined);
const controlsTimeoutRef = useRef<NodeJS.Timeout>(undefined);
const handleTap = (e: React.MouseEvent<HTMLDivElement>) => {
const time = Date.now();
const clientX = e.clientX;
const rect = containerRef.current?.getBoundingClientRect();
if (!rect) return;
const x = clientX - rect.left;
const width = rect.width;
const isLeft = x < width * 0.3;
const isRight = x > width * 0.7;
if (!isLeft && !isRight) {
togglePlay();
return;
}
if (lastTapRef.current && time - lastTapRef.current.time < 300) {
// Double tap detected
if (tapTimeoutRef.current) {
clearTimeout(tapTimeoutRef.current);
}
if (isLeft) {
handleSkip(-10);
setDoubleTapAction({ side: "left", id: time });
} else {
handleSkip(10);
setDoubleTapAction({ side: "right", id: time });
}
lastTapRef.current = null;
} else {
// First tap
lastTapRef.current = { time, x };
tapTimeoutRef.current = setTimeout(() => {
togglePlay();
lastTapRef.current = null;
}, 300);
}
};
// Clear double tap action after animation
useEffect(() => {
if (doubleTapAction) {
const timeout = setTimeout(() => {
setDoubleTapAction(null);
}, 1000);
return () => clearTimeout(timeout);
}
}, [doubleTapAction]);
useImperativeHandle(ref, () => ({
seek: (time: number) => {
if (videoRef.current) {
videoRef.current.currentTime = time;
setCurrentTime(time);
}
},
play: () => {
videoRef.current?.play();
},
pause: () => {
videoRef.current?.pause();
},
}));
// Initialize quality sources
useEffect(() => {
if (Array.isArray(src)) {
const qualities = src.map((s) => s.quality);
setAvailableQualities(["auto", ...qualities]);
// Default to the first quality source if auto
setCurrentSrc(src[0]?.src || "");
} else {
setAvailableQualities(["auto"]);
setCurrentSrc(src);
}
}, [src]);
// Initialize captions
useEffect(() => {
if (tracks.length > 0) {
const defaultTrack = tracks.find((t) => t.default);
if (defaultTrack) {
setCurrentCaption(defaultTrack.srcLang);
}
}
}, [tracks]);
// Handle caption change
const handleCaptionChange = (lang: string | null) => {
setCurrentCaption(lang);
if (videoRef.current) {
const textTracks = videoRef.current.textTracks;
for (let i = 0; i < textTracks.length; i++) {
const track = textTracks[i];
if (track) {
if (lang && track.language === lang) {
track.mode = "showing";
} else {
track.mode = "hidden";
}
}
}
}
};
// Format time display
const formatTime = (time: number) => {
if (!time || Number.isNaN(time)) return "0:00";
const hours = Math.floor(time / 3600);
const minutes = Math.floor((time % 3600) / 60);
const seconds = Math.floor(time % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, "0")}:${seconds.toString().padStart(2, "0")}`;
}
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
};
// Handle play/pause
const togglePlay = () => {
if (videoRef.current) {
if (isPlaying) {
videoRef.current.pause();
} else {
videoRef.current.play();
}
setIsPlaying(!isPlaying);
}
};
// Handle volume change
const handleVolumeChange = (value: number[]) => {
const newVolume = value[0] ?? 1;
setVolume(newVolume);
if (videoRef.current) {
videoRef.current.volume = newVolume;
}
if (newVolume === 0) {
setIsMuted(true);
} else if (isMuted) {
setIsMuted(false);
}
};
// Toggle mute
const toggleMute = () => {
if (videoRef.current) {
if (isMuted) {
videoRef.current.volume = volume || 0.5;
setIsMuted(false);
} else {
videoRef.current.volume = 0;
setIsMuted(true);
}
}
};
// Handle progress bar click
const handleProgressClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (!videoRef.current || !duration) return;
const rect = e.currentTarget.getBoundingClientRect();
const percent = (e.clientX - rect.left) / rect.width;
const newTime = percent * duration;
videoRef.current.currentTime = newTime;
setCurrentTime(newTime);
};
// Handle progress bar hover
const handleProgressHover = (e: React.MouseEvent<HTMLDivElement>) => {
if (!duration) return;
const rect = e.currentTarget.getBoundingClientRect();
const percent = (e.clientX - rect.left) / rect.width;
const time = Math.max(0, Math.min(percent * duration, duration));
setHoverTime(time);
setHoverPosition(percent * 100);
};
const handleProgressLeave = () => {
setHoverTime(null);
setHoverPosition(null);
};
// Toggle fullscreen
const toggleFullscreen = () => {
if (!containerRef.current) return;
if (!isFullscreen) {
if (containerRef.current.requestFullscreen) {
containerRef.current.requestFullscreen();
} else if (
(
containerRef.current as HTMLElement & {
webkitRequestFullscreen?: () => void;
}
).webkitRequestFullscreen
) {
(
containerRef.current as HTMLElement & {
webkitRequestFullscreen?: () => void;
}
).webkitRequestFullscreen?.();
}
setIsFullscreen(true);
} else {
if (document.fullscreenElement) {
document.exitFullscreen();
}
setIsFullscreen(false);
}
};
const toggleTheaterMode = () => {
const newTheaterMode = !isTheaterMode;
setIsTheaterMode(newTheaterMode);
// Lock/unlock body scroll
if (newTheaterMode) {
document.body.style.overflow = "hidden";
} else {
document.body.style.overflow = "";
}
};
// Handle Picture-in-Picture
const togglePictureInPicture = async () => {
try {
if (document.pictureInPictureElement) {
await document.exitPictureInPicture();
setIsPictureInPicture(false);
} else if (videoRef.current && document.pictureInPictureEnabled) {
await videoRef.current.requestPictureInPicture();
setIsPictureInPicture(true);
}
} catch (error) {
console.error("PiP error:", error);
}
};
// Handle quality change
const handleQualityChange = (newQuality: string) => {
if (!videoRef.current) return;
const currentTime = videoRef.current.currentTime;
const wasPlaying = !videoRef.current.paused;
setQuality(newQuality);
if (Array.isArray(src)) {
let newSrc = "";
if (newQuality === "auto") {
newSrc = src[0]?.src || "";
} else {
const source = src.find((s) => s.quality === newQuality);
if (source) newSrc = source.src;
}
if (newSrc && newSrc !== currentSrc) {
setCurrentSrc(newSrc);
// Restore playback position after source change
const handleCanPlay = () => {
if (videoRef.current) {
videoRef.current.currentTime = currentTime;
if (wasPlaying) videoRef.current.play();
videoRef.current.removeEventListener(
"loadedmetadata",
handleCanPlay,
);
}
};
videoRef.current.addEventListener("loadedmetadata", handleCanPlay);
}
}
};
// Handle speed change
const handleSpeedChange = (newSpeed: number) => {
setSpeed(newSpeed);
if (videoRef.current) {
videoRef.current.playbackRate = newSpeed;
}
};
// Handle skip
const handleSkip = (seconds: number) => {
if (videoRef.current) {
videoRef.current.currentTime += seconds;
}
};
const handleToggleLoop = () => {
if (videoRef.current) {
videoRef.current.loop = !videoRef.current.loop;
setIsLooping(!isLooping);
}
};
// Handle video metadata loaded
const handleLoadedMetadata = () => {
setDuration(videoRef.current?.duration || 0);
setIsLoading(false);
};
// Handle time update
const handleTimeUpdate = () => {
setCurrentTime(videoRef.current?.currentTime || 0);
if (onTimeUpdate) {
onTimeUpdate(videoRef.current?.currentTime || 0);
}
// Update buffered amount
if (videoRef.current && videoRef.current.buffered.length > 0) {
const bufferedEnd = videoRef.current.buffered.end(
videoRef.current.buffered.length - 1,
);
setBuffered((bufferedEnd / duration) * 100);
}
};
// Handle fullscreen change
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement);
};
document.addEventListener("fullscreenchange", handleFullscreenChange);
return () =>
document.removeEventListener(
"fullscreenchange",
handleFullscreenChange,
);
}, []);
// Handle mouse movement for controls
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const handleMouseMove = () => {
setShowControls(true);
if (controlsTimeoutRef.current) {
clearTimeout(controlsTimeoutRef.current);
}
if (isPlaying) {
controlsTimeoutRef.current = setTimeout(() => {
setShowControls(false);
}, 3000);
}
};
container.addEventListener("mousemove", handleMouseMove);
return () => container.removeEventListener("mousemove", handleMouseMove);
}, [isPlaying]);
return (
<div
ref={containerRef}
className={`group relative w-full overflow-hidden rounded-lg bg-black ${
isTheaterMode
? "fixed inset-0 z-50 h-screen w-screen rounded-none"
: ""
}`}
>
<div
className={`relative w-full transition-all duration-300 ${isTheaterMode ? "h-screen" : "aspect-video"}`}
>
{/* Video Element */}
{/* biome-ignore lint/a11y/useMediaCaption: Video player component may not have captions */}
<video
ref={videoRef}
src={currentSrc}
poster={poster}
onLoadedMetadata={handleLoadedMetadata}
onTimeUpdate={handleTimeUpdate}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onLoadStart={() => setIsLoading(true)}
onEnded={() => setIsPlaying(false)}
className="absolute inset-0 h-full w-full"
>
{tracks.map((track, index) => (
<track
key={index}
kind="subtitles"
src={track.src}
srcLang={track.srcLang}
label={track.label}
default={track.default}
/>
))}
</video>
{/* Loading Spinner */}
<AnimatePresence>
{isLoading && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute inset-0 flex items-center justify-center bg-black/20 backdrop-blur-sm"
>
<motion.div
animate={{ rotate: 360 }}
transition={{ duration: 1, repeat: Number.POSITIVE_INFINITY }}
>
<Loader2 className="h-12 w-12 text-white" />
</motion.div>
</motion.div>
)}
</AnimatePresence>
{/* Controls Background Gradient */}
<AnimatePresence>
{showControls && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="absolute right-0 bottom-0 left-0 h-32 bg-gradient-to-t from-black via-black/50 to-transparent"
/>
)}
</AnimatePresence>
{/* Click Overlay */}
<div
className="absolute inset-0 z-10"
onClick={handleTap}
role="button"
aria-label="Play/Pause"
tabIndex={0}
/>
{/* Double Tap Animation */}
<AnimatePresence>
{doubleTapAction && (
<div
key={doubleTapAction.id}
className={`absolute inset-y-0 ${
doubleTapAction.side === "left"
? "left-0 justify-start pl-12"
: "right-0 justify-end pr-12"
} pointer-events-none z-20 flex w-1/2 items-center`}
>
<motion.div
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 1.5 }}
transition={{ duration: 0.5 }}
className="flex flex-col items-center justify-center rounded-full shadow-lg"
>
{doubleTapAction.side === "left" ? (
<>
<ChevronsLeft className="h-8 w-8 text-white" />
<span className="mt-1 select-none font-bold text-white text-xs shadow-lg">
10s
</span>
</>
) : (
<>
<ChevronsRight className="h-8 w-8 text-white" />
<span className="mt-1 select-none font-bold text-white text-xs shadow-lg">
10s
</span>
</>
)}
</motion.div>
</div>
)}
</AnimatePresence>
{/* Center Play Button */}
<AnimatePresence>
{showControls && !isPlaying && (
<motion.button
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
className="pointer-events-none absolute inset-0 z-20 flex items-center justify-center"
>
<motion.div
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
className="pointer-events-auto rounded-full bg-white/20 p-4 backdrop-blur-sm transition-colors hover:bg-white/30"
onClick={togglePlay}
>
<Play className="h-12 w-12 fill-white text-white" />
</motion.div>
</motion.button>
)}
</AnimatePresence>
{/* Controls Bar */}
<AnimatePresence>
{showControls && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
className="absolute right-0 bottom-0 left-0 z-40 flex flex-col gap-3 px-4 py-3"
>
{/* Progress Bar */}
<div
onClick={handleProgressClick}
onMouseMove={handleProgressHover}
onMouseLeave={handleProgressLeave}
className="group/progress relative h-1.5 w-full cursor-pointer rounded-full bg-white/20 transition-all hover:h-2"
role="button"
aria-label="Seek"
tabIndex={0}
>
{/* Buffered indicator */}
<div
className="absolute inset-y-0 left-0 rounded-full bg-white/40"
style={{ width: `${buffered}%` }}
/>
{/* Progress indicator */}
<div
className="absolute inset-y-0 left-0 rounded-full bg-gradient-to-r from-blue-500 to-cyan-400 transition-all"
style={{ width: `${(currentTime / duration) * 100}%` }}
/>
{/* Chapter markers */}
{chapters.length > 0 && duration > 0 && (
<div className="pointer-events-none absolute inset-0 h-full w-full">
{chapters.map((chapter, index) => {
if (index === 0) return null;
const left = (chapter.startTime / duration) * 100;
return (
<div
key={index}
className="absolute top-0 bottom-0 z-10 w-0.5 bg-black/50"
style={{ left: `${left}%` }}
/>
);
})}
</div>
)}
{/* Scrubber */}
<motion.div
className="-translate-y-1/2 -translate-x-1/2 absolute top-1/2 z-20 h-4 w-4 rounded-full bg-white opacity-0 shadow-lg transition-opacity group-hover/progress:opacity-100"
style={{ left: `${(currentTime / duration) * 100}%` }}
/>
{/* Hover Time Tooltip */}
<AnimatePresence>
{hoverTime !== null && hoverPosition !== null && (
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.8 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.8 }}
className="-translate-x-1/2 pointer-events-none absolute bottom-full z-50 mb-4 flex flex-col items-center gap-0.5 whitespace-nowrap rounded-lg border border-white/10 bg-black/90 px-2 py-1 text-white text-xs"
style={{ left: `${hoverPosition}%` }}
>
{chapters.length > 0 && (
<span className="font-medium text-white/90">
{
chapters.find((c, i) => {
const nextChapter = chapters[i + 1];
return (
hoverTime >= c.startTime &&
(!nextChapter ||
hoverTime < nextChapter.startTime)
);
})?.title
}
</span>
)}
<span
className={chapters.length > 0 ? "text-white/70" : ""}
>
{formatTime(hoverTime)}
</span>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Time Display and Controls */}
<div className="flex items-center justify-between gap-2">
{/* Left Controls */}
<div className="flex items-center gap-1">
{/* Play/Pause */}
<Tooltip
label={isPlaying ? "Pause (Space)" : "Play (Space)"}
>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
onClick={togglePlay}
className="flex items-center justify-center rounded-lg p-2 transition-colors hover:bg-white/20"
aria-label="Play/Pause"
>
{isPlaying ? (
<Pause className="h-5 w-5 fill-white text-white" />
) : (
<Play className="h-5 w-5 fill-white text-white" />
)}
</motion.button>
</Tooltip>
{/* Skip Back 10s */}
<Tooltip label="Previous 10 seconds (J)">
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
onClick={() => handleSkip(-10)}
className="flex items-center justify-center rounded-lg p-2 transition-colors hover:bg-white/20"
aria-label="Skip back 10 seconds"
>
<RotateCcw className="h-5 w-5 text-white" />
</motion.button>
</Tooltip>
{/* Skip Forward 10s */}
<Tooltip label="Next 10 seconds (L)">
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
onClick={() => handleSkip(10)}
className="flex items-center justify-center rounded-lg p-2 transition-colors hover:bg-white/20"
aria-label="Skip forward 10 seconds"
>
<RotateCw className="h-5 w-5 text-white" />
</motion.button>
</Tooltip>
{/* Previous Video */}
{currentVideoIndex > 0 && (
<Tooltip label="Previous Video">
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
onClick={onPrevVideo}
className="flex items-center justify-center rounded-lg p-2 transition-colors hover:bg-white/20"
aria-label="Previous video"
>
<ChevronLeft className="h-5 w-5 text-white" />
</motion.button>
</Tooltip>
)}
{/* Next Video */}
{currentVideoIndex < totalVideos - 1 && (
<Tooltip label="Next Video">
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
onClick={onNextVideo}
className="flex items-center justify-center rounded-lg p-2 transition-colors hover:bg-white/20"
aria-label="Next video"
>
<ChevronRight className="h-5 w-5 text-white" />
</motion.button>
</Tooltip>
)}
{/* Volume Control */}
<div className="flex items-center gap-1">
<Tooltip label={isMuted ? "Unmute (M)" : "Mute (M)"}>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
onMouseEnter={() => setShowVolumeSlider(true)}
onMouseLeave={() => setShowVolumeSlider(false)}
onClick={toggleMute}
className="flex items-center justify-center rounded-lg p-2 transition-colors hover:bg-white/20"
aria-label="Mute/Unmute"
>
{isMuted ? (
<VolumeX className="h-5 w-5 text-white" />
) : (
<Volume2 className="h-5 w-5 text-white" />
)}
</motion.button>
</Tooltip>
{/* Volume Slider */}
<AnimatePresence>
{showVolumeSlider && (
<motion.div
initial={{ opacity: 0, width: 0 }}
animate={{ opacity: 1, width: 80 }}
exit={{ opacity: 0, width: 0 }}
className="flex items-center overflow-hidden pl-2"
onMouseEnter={() => setShowVolumeSlider(true)}
onMouseLeave={() => setShowVolumeSlider(false)}
>
<input
type="range"
min="0"
max="1"
step="0.01"
value={isMuted ? 0 : volume}
onChange={(e) =>
handleVolumeChange([
Number.parseFloat(e.target.value),
])
}
className="h-1 w-full cursor-pointer appearance-none rounded-full focus:outline-none [&::-moz-range-thumb]:h-3 [&::-moz-range-thumb]:w-3 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-none [&::-moz-range-thumb]:bg-white [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white"
style={{
background: `linear-gradient(to right, white ${isMuted ? 0 : volume * 100}%, rgba(255, 255, 255, 0.2) ${isMuted ? 0 : volume * 100}%)`,
}}
/>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Time Display */}
<span className="ml-2 flex min-w-24 items-center text-sm text-white">
{formatTime(currentTime)} / {formatTime(duration)}
</span>
</div>
{/* Right Controls */}
<div className="flex items-center gap-1">
{/* Captions */}
{tracks.length > 0 && (
<div className="relative flex items-center">
<Tooltip label="Captions (C)">
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
onClick={() => {
if (tracks.length === 1 && tracks[0]) {
handleCaptionChange(
currentCaption ? null : tracks[0].srcLang,
);
} else {
setActiveDialog(
activeDialog === "captions"
? null
: "captions",
);
}
}}
className={`flex items-center justify-center rounded-lg p-2 transition-colors ${
currentCaption
? "bg-cyan-500/30 hover:bg-cyan-500/40"
: "hover:bg-white/20"
}`}
aria-label="Captions"
>
<MessageCircle className="h-5 w-5 text-white" />
</motion.button>
</Tooltip>
<AnimatePresence>
{activeDialog === "captions" && tracks.length > 1 && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
className="absolute right-0 bottom-full z-50 mb-2 min-w-40 rounded-lg border border-white/10 bg-black/95 p-3 backdrop-blur-sm"
>
<p className="mb-2 font-semibold text-white text-xs uppercase opacity-70">
Captions
</p>
<div className="space-y-1">
<button
onClick={() => {
handleCaptionChange(null);
setActiveDialog(null);
}}
className={`w-full rounded px-2 py-1 text-left text-sm transition-colors ${
!currentCaption
? "bg-cyan-500 text-white"
: "text-white/70 hover:bg-white/10"
}`}
>
Off
</button>
{tracks.map((track) => (
<button
key={track.srcLang}
onClick={() => {
handleCaptionChange(track.srcLang);
setActiveDialog(null);
}}
className={`w-full rounded px-2 py-1 text-left text-sm transition-colors ${
currentCaption === track.srcLang
? "bg-cyan-500 text-white"
: "text-white/70 hover:bg-white/10"
}`}
>
{track.label}
</button>
))}
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)}
{/* Picture-in-Picture */}
<Tooltip label="Picture in Picture (P)">
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
onClick={togglePictureInPicture}
className={`flex items-center justify-center rounded-lg p-2 transition-colors ${
isPictureInPicture
? "bg-cyan-500/30 hover:bg-cyan-500/40"
: "hover:bg-white/20"
}`}
aria-label="Picture in Picture"
>
<PictureInPicture2 className="h-5 w-5 text-white" />
</motion.button>
</Tooltip>
{/* Settings */}
<div className="relative flex items-center">
<Tooltip label="Settings">
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
onClick={() =>
setActiveDialog(
activeDialog === "settings" ? null : "settings",
)
}
className={`flex items-center justify-center rounded-lg p-2 transition-colors ${
activeDialog === "settings"
? "bg-cyan-500/30"
: "hover:bg-white/20"
}`}
aria-label="Settings"
>
<Settings className="h-5 w-5 text-white" />
</motion.button>
</Tooltip>
<AnimatePresence>
{activeDialog === "settings" && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
className="absolute right-0 bottom-full z-50 mb-2 min-w-40 rounded-lg border border-white/10 bg-black/95 p-3 backdrop-blur-sm"
>
{/* Quality Selection */}
{availableQualities.length > 1 && (
<div className="mb-3">
<p className="mb-2 font-semibold text-white text-xs uppercase opacity-70">
Quality
</p>
<div className="space-y-1">
{availableQualities.map((q) => (
<button
key={q}
onClick={() => handleQualityChange(q)}
className={`w-full rounded px-2 py-1 text-left text-sm transition-colors ${
quality === q
? "bg-cyan-500 text-white"
: "text-white/70 hover:bg-white/10"
}`}
>
{q}
</button>
))}
</div>
</div>
)}
{/* Speed Selection */}
<div>
<p className="mb-2 font-semibold text-white text-xs uppercase opacity-70">
Speed
</p>
<div className="space-y-1">
{[0.5, 0.75, 1, 1.25, 1.5, 2].map((s) => (
<button
key={s}
onClick={() => handleSpeedChange(s)}
className={`w-full rounded px-2 py-1 text-left text-sm transition-colors ${
speed === s
? "bg-cyan-500 text-white"
: "text-white/70 hover:bg-white/10"
}`}
>
{s}x
</button>
))}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
{/* More Options */}
<div className="relative flex items-center">
<Tooltip label="More Options">
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
onClick={() =>
setActiveDialog(
activeDialog === "options" ? null : "options",
)
}
className={`flex items-center justify-center rounded-lg p-2 transition-colors ${
activeDialog === "options"
? "bg-cyan-500/30"
: "hover:bg-white/20"
}`}
aria-label="More options"
>
<MoreVertical className="h-5 w-5 text-white" />
</motion.button>
</Tooltip>
<AnimatePresence>
{activeDialog === "options" && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
className="absolute right-0 bottom-full z-50 mb-2 min-w-48 rounded-lg border border-white/10 bg-black/95 p-2 backdrop-blur-sm"
>
{/* Theater Mode */}
<button
onClick={() => {
toggleTheaterMode();
setActiveDialog(null);
}}
className="flex w-full items-center gap-2 rounded px-3 py-2 text-left text-sm text-white transition-colors hover:bg-white/10"
>
<Maximize2 className="h-4 w-4" />
{isTheaterMode
? "Exit Theater Mode"
: "Theater Mode"}
</button>
{/* Loop */}
<button
onClick={() => handleToggleLoop()}
className={`flex w-full items-center gap-2 rounded px-3 py-2 text-left text-sm transition-colors ${
isLooping
? "bg-cyan-500/30 text-white"
: "text-white hover:bg-white/10"
}`}
>
<Repeat className="h-4 w-4" />
Loop
</button>
</motion.div>
)}
</AnimatePresence>
</div>
{/* Fullscreen */}
<Tooltip
label={
isFullscreen ? "Exit Fullscreen (F)" : "Fullscreen (F)"
}
>
<motion.button
whileHover={{ scale: 1.1 }}
whileTap={{ scale: 0.95 }}
onClick={toggleFullscreen}
className="flex items-center justify-center rounded-lg p-2 transition-colors hover:bg-white/20"
aria-label="Fullscreen"
>
{isFullscreen ? (
<Minimize className="h-5 w-5 text-white" />
) : (
<Maximize className="h-5 w-5 text-white" />
)}
</motion.button>
</Tooltip>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
);
},
);
VideoPlayer.displayName = "VideoPlayer";Layout
import { VideoPlayer } from "@/components/ui/video-player";
<VideoPlayer
src="https://example.com/video.mp4"
poster="https://example.com/poster.jpg"
title="Video Title"
description="Video description"
/>Features
The Video Player component includes:
- Advanced Controls: Play/pause, volume, progress bar, fullscreen, picture-in-picture
- Keyboard Shortcuts: Space (play/pause), M (mute), F (fullscreen), J/L (skip), P (PiP), C (captions)
- Quality & Speed Control: Adjustable playback quality and speed settings
- Theater Mode: Immersive viewing experience
- Playlist Support: Navigate between multiple videos
- Custom Callbacks: Track playback progress and events
- Smooth Animations: Powered by Framer Motion
- Responsive Design: Works on all screen sizes
Examples
Compact Mode
Use the compact prop for a minimal player with essential controls only.
import { VideoPlayer } from "@/components/ui/video-player";
export function VideoPlayerCompactDemo() {
return (
<div className="mx-auto w-full max-w-2xl">
<VideoPlayer
src="https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4"
poster="https://download.blender.org/ED/cover_art/elephants_dream_1024.jpg"
title="Elephants Dream"
description="A short film about two strange characters exploring a cavernous labyrinth."
compact={true}
/>
</div>
);
}Playlist
Create a playlist by managing video state and using navigation callbacks.
"use client";
import { useState } from "react";
import { VideoPlayer } from "@/components/ui/video-player";
const playlist = [
{
src: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4",
poster:
"https://peach.blender.org/wp-content/uploads/title_anouncement.jpg?x11217",
title: "Big Buck Bunny",
description: "A large and lovable rabbit deals with three tiny bullies.",
},
{
src: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ElephantsDream.mp4",
poster:
"https://download.blender.org/ED/cover_art/elephants_dream_1024.jpg",
title: "Elephants Dream",
description: "Two strange characters exploring a cavernous labyrinth.",
},
{
src: "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/ForBiggerBlazes.mp4",
title: "For Bigger Blazes",
description: "A short promotional video.",
},
];
export function VideoPlayerPlaylistDemo() {
const [currentIndex, setCurrentIndex] = useState(0);
const handleNext = () => {
if (currentIndex < playlist.length - 1) {
setCurrentIndex(currentIndex + 1);
}
};
const handlePrev = () => {
if (currentIndex > 0) {
setCurrentIndex(currentIndex - 1);
}
};
const currentVideo = playlist[currentIndex];
if (!currentVideo) return null;
return (
<div className="mx-auto w-full max-w-4xl space-y-4">
<VideoPlayer
src={currentVideo.src}
poster={currentVideo.poster}
title={currentVideo.title}
description={currentVideo.description}
currentVideoIndex={currentIndex}
totalVideos={playlist.length}
onNextVideo={handleNext}
onPrevVideo={handlePrev}
/>
<div className="flex gap-2 overflow-x-auto pb-2">
{playlist.map((video, index) => (
<button
key={index}
onClick={() => setCurrentIndex(index)}
className={`flex-shrink-0 rounded-lg border-2 transition-all ${
index === currentIndex
? "border-cyan-500 ring-2 ring-cyan-500/20"
: "border-transparent hover:border-gray-300"
}`}
>
<div className="relative h-24 w-40 overflow-hidden rounded-md bg-muted">
{video.poster && (
// biome-ignore lint/performance/noImgElement: next/image causes ESM issues with fumadocs-mdx
<img
src={video.poster}
alt={video.title}
className="absolute inset-0 h-full w-full object-cover"
/>
)}
<div className="absolute inset-0 flex items-center justify-center bg-black/40">
<p className="px-2 text-center font-medium text-white text-xs">
{video.title}
</p>
</div>
</div>
</button>
))}
</div>
</div>
);
}With Callbacks
Track playback progress and implement custom behaviors using the onTimeUpdate callback.
"use client";
import { useState } from "react";
import { VideoPlayer } from "@/components/ui/video-player";
export function VideoPlayerCallbacksDemo() {
const [currentTime, setCurrentTime] = useState(0);
const [watchedPercentage, setWatchedPercentage] = useState(0);
const handleTimeUpdate = (time: number) => {
setCurrentTime(time);
// Assuming video duration is around 596 seconds for Big Buck Bunny
const percentage = Math.min((time / 596) * 100, 100);
setWatchedPercentage(percentage);
};
return (
<div className="mx-auto w-full max-w-4xl space-y-4">
<VideoPlayer
src="https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"
poster="https://peach.blender.org/wp-content/uploads/title_anouncement.jpg?x11217"
title="Big Buck Bunny"
onTimeUpdate={handleTimeUpdate}
/>
<div className="space-y-3 rounded-lg border bg-card p-4">
<h3 className="font-semibold text-sm">Playback Information</h3>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Current Time:</span>
<span className="font-mono">{currentTime.toFixed(2)}s</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Progress:</span>
<span className="font-mono">{watchedPercentage.toFixed(1)}%</span>
</div>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full bg-gradient-to-r from-blue-500 to-cyan-400 transition-all duration-300"
style={{ width: `${watchedPercentage}%` }}
/>
</div>
</div>
</div>
);
}Timestamp Control
Control playback position programmatically using the seek method exposed via ref. This example also demonstrates the built-in hover preview feature.
"use client";
import { useRef } from "react";
import {
VideoPlayer,
type VideoPlayerRef,
} from "@/components/ui/video-player";
export function VideoPlayerTimestampDemo() {
const playerRef = useRef<VideoPlayerRef>(null);
const timestamps = [
{ label: "Intro", time: 0 },
{ label: "Bunny Wakes Up", time: 35 },
{ label: "Butterfly", time: 60 },
{ label: "Attack", time: 180 },
{ label: "Revenge", time: 300 },
{ label: "Credits", time: 580 },
];
return (
<div className="mx-auto w-full max-w-4xl space-y-4">
<VideoPlayer
ref={playerRef}
src="https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"
poster="https://peach.blender.org/wp-content/uploads/title_anouncement.jpg?x11217"
title="Big Buck Bunny"
chapters={timestamps.map((t) => ({
title: t.label,
startTime: t.time,
endTime: 0,
}))}
/>
<div className="grid gap-4 rounded-lg border bg-card p-4">
<div className="space-y-1">
<h3 className="font-semibold">Timestamps</h3>
<p className="text-muted-foreground text-sm">
Click on a timestamp to jump to that part of the video.
</p>
</div>
<div className="flex flex-wrap gap-2">
{timestamps.map((ts) => (
<button
key={ts.label}
onClick={() => playerRef.current?.seek(ts.time)}
className="flex items-center gap-2 rounded-md bg-secondary px-3 py-1.5 font-medium text-sm transition-colors hover:bg-secondary/80"
>
<span className="text-primary">{formatTime(ts.time)}</span>
<span>{ts.label}</span>
</button>
))}
</div>
</div>
</div>
);
}
function formatTime(time: number) {
const minutes = Math.floor(time / 60);
const seconds = Math.floor(time % 60);
return `${minutes}:${seconds.toString().padStart(2, "0")}`;
}Keyboard Shortcuts
| Key | Action |
|---|---|
Space | Play/Pause |
M | Mute/Unmute |
F | Toggle Fullscreen |
J | Skip backward 10 seconds |
L | Skip forward 10 seconds |
P | Toggle Picture-in-Picture |
C | Toggle Captions |
API Reference
VideoPlayer
A comprehensive video player with advanced features and controls.
Prop
Type
How is this guide?
Magnetic
Magnetic hover effect component for React. Elements smoothly follow cursor with spring physics. Perfect for buttons, cards, and interactive UI.
Animated Toast
Animated toast notifications for React. Success, error, warning, and info variants with stacking, promises, and custom styling. Sonner alternative.