UI/UI
Video Player
A modern, customizable video player component with full controls and keyboard shortcuts.
0:00 0:00
<VideoPlayer
src="https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"
poster="https://peach.blender.org/wp-content/uploads/bbb-splash.png"
size="default"
/>
Installation
Install following dependencies:
npm install class-variance-authority lucide-react
pnpm add class-variance-authority lucide-react
yarn add class-variance-authority lucide-react
bun add class-variance-authority lucide-react
Copy and paste the following code into your project.
"use client";
import * as React from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import {
Play,
Pause,
Volume2,
VolumeX,
Maximize,
Minimize,
SkipBack,
SkipForward,
Settings,
} from "lucide-react";
const videoPlayerVariants = cva(
"relative w-full bg-black rounded-lg overflow-hidden group",
{
variants: {
size: {
sm: "max-w-md",
default: "max-w-2xl",
lg: "max-w-4xl",
full: "w-full",
},
},
defaultVariants: {
size: "default",
},
},
);
export interface VideoPlayerProps
extends React.VideoHTMLAttributes<HTMLVideoElement>,
VariantProps<typeof videoPlayerVariants> {
src: string;
poster?: string;
showControls?: boolean;
autoHide?: boolean;
className?: string;
}
const VideoPlayer = React.forwardRef<HTMLVideoElement, VideoPlayerProps>(
(
{
className,
size,
src,
poster,
showControls = true,
autoHide = true,
...props
},
ref,
) => {
const [isPlaying, setIsPlaying] = React.useState(false);
const [currentTime, setCurrentTime] = React.useState(0);
const [duration, setDuration] = React.useState(0);
const [volume, setVolume] = React.useState(1);
const [isMuted, setIsMuted] = React.useState(false);
const [isFullscreen, setIsFullscreen] = React.useState(false);
const [showControlsState, setShowControlsState] = React.useState(true);
const videoRef = React.useRef<HTMLVideoElement>(null);
const containerRef = React.useRef<HTMLDivElement>(null);
const hideControlsTimeoutRef = React.useRef<NodeJS.Timeout | null>(null);
React.useImperativeHandle(ref, () => videoRef.current!, []);
const formatTime = (time: number) => {
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")}`;
};
const togglePlay = () => {
if (videoRef.current) {
if (isPlaying) {
videoRef.current.pause();
} else {
videoRef.current.play();
}
}
};
const toggleMute = () => {
if (videoRef.current) {
videoRef.current.muted = !isMuted;
setIsMuted(!isMuted);
}
};
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newVolume = parseFloat(e.target.value);
setVolume(newVolume);
if (videoRef.current) {
videoRef.current.volume = newVolume;
setIsMuted(newVolume === 0);
}
};
const handleSeek = (e: React.ChangeEvent<HTMLInputElement>) => {
const newTime = parseFloat(e.target.value);
setCurrentTime(newTime);
if (videoRef.current) {
videoRef.current.currentTime = newTime;
}
};
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
containerRef.current?.requestFullscreen();
setIsFullscreen(true);
} else {
document.exitFullscreen();
setIsFullscreen(false);
}
};
const skip = (seconds: number) => {
if (videoRef.current) {
videoRef.current.currentTime = Math.max(
0,
Math.min(duration, currentTime + seconds),
);
}
};
const resetHideControlsTimeout = () => {
if (hideControlsTimeoutRef.current) {
clearTimeout(hideControlsTimeoutRef.current);
}
if (autoHide && isPlaying) {
hideControlsTimeoutRef.current = setTimeout(() => {
setShowControlsState(false);
}, 3000);
}
};
const handleMouseMove = () => {
setShowControlsState(true);
resetHideControlsTimeout();
};
React.useEffect(() => {
const video = videoRef.current;
if (!video) return;
const handleLoadedMetadata = () => {
setDuration(video.duration);
};
const handleTimeUpdate = () => {
setCurrentTime(video.currentTime);
};
const handlePlay = () => {
setIsPlaying(true);
resetHideControlsTimeout();
};
const handlePause = () => {
setIsPlaying(false);
setShowControlsState(true);
if (hideControlsTimeoutRef.current) {
clearTimeout(hideControlsTimeoutRef.current);
}
};
const handleVolumeChange = () => {
setVolume(video.volume);
setIsMuted(video.muted);
};
video.addEventListener("loadedmetadata", handleLoadedMetadata);
video.addEventListener("timeupdate", handleTimeUpdate);
video.addEventListener("play", handlePlay);
video.addEventListener("pause", handlePause);
video.addEventListener("volumechange", handleVolumeChange);
return () => {
video.removeEventListener("loadedmetadata", handleLoadedMetadata);
video.removeEventListener("timeupdate", handleTimeUpdate);
video.removeEventListener("play", handlePlay);
video.removeEventListener("pause", handlePause);
video.removeEventListener("volumechange", handleVolumeChange);
if (hideControlsTimeoutRef.current) {
clearTimeout(hideControlsTimeoutRef.current);
}
};
}, [autoHide, isPlaying]);
React.useEffect(() => {
const handleFullscreenChange = () => {
setIsFullscreen(!!document.fullscreenElement);
};
document.addEventListener("fullscreenchange", handleFullscreenChange);
return () => {
document.removeEventListener(
"fullscreenchange",
handleFullscreenChange,
);
};
}, []);
React.useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (!containerRef.current?.contains(document.activeElement)) return;
switch (e.key) {
case " ":
case "k":
e.preventDefault();
togglePlay();
break;
case "m":
e.preventDefault();
toggleMute();
break;
case "f":
e.preventDefault();
toggleFullscreen();
break;
case "ArrowLeft":
e.preventDefault();
skip(-10);
break;
case "ArrowRight":
e.preventDefault();
skip(10);
break;
case "ArrowUp":
e.preventDefault();
setVolume((prev) => Math.min(1, prev + 0.1));
break;
case "ArrowDown":
e.preventDefault();
setVolume((prev) => Math.max(0, prev - 0.1));
break;
}
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [currentTime, duration]);
return (
<div
ref={containerRef}
className={cn(videoPlayerVariants({ size }), className)}
onMouseMove={handleMouseMove}
onMouseLeave={() =>
autoHide && isPlaying && setShowControlsState(false)
}
tabIndex={0}
>
{" "}
<video
ref={videoRef}
src={src}
poster={poster}
className="w-full h-full object-cover"
onClick={togglePlay}
{...props}
/>
{showControls && (
<>
{/* Play/Pause Overlay - Only visible when not playing or on hover */}
<div
className={cn(
"absolute inset-0 flex items-center justify-center pointer-events-none transition-opacity duration-300",
!isPlaying || showControlsState ? "opacity-100" : "opacity-0",
)}
>
{" "}
<button
onClick={(e) => {
e.stopPropagation();
togglePlay();
}}
className="w-16 h-16 rounded-full bg-white/20 backdrop-blur-sm border border-white/30 flex items-center justify-center text-white hover:bg-white/30 transition-all duration-200 pointer-events-auto"
>
{isPlaying ? (
<Pause className="w-6 h-6 ml-0.5" />
) : (
<Play className="w-6 h-6 ml-1" />
)}
</button>
</div>
{/* Controls Bar */}
<div
className={cn(
"absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent",
"transition-opacity duration-300 pointer-events-none",
showControlsState ? "opacity-100" : "opacity-0",
)}
>
<div className="p-4 space-y-3 pointer-events-auto">
{/* Progress Bar */}
<div className="flex items-center gap-2 text-white text-sm">
<span className="min-w-0 text-xs font-mono">
{formatTime(currentTime)}
</span>
<div className="flex-1 relative group/progress">
{" "}
<input
type="range"
min={0}
max={duration || 0}
value={currentTime}
onChange={(e) => {
e.stopPropagation();
handleSeek(e);
}}
className="w-full h-1 bg-white/30 rounded-full appearance-none cursor-pointer
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white
[&::-webkit-slider-thumb]:shadow-sm [&::-webkit-slider-thumb]:cursor-pointer
[&::-webkit-slider-thumb]:transition-all [&::-webkit-slider-thumb]:duration-200
group-hover/progress:[&::-webkit-slider-thumb]:scale-125"
style={{
background: `linear-gradient(to right, #ffffff 0%, #ffffff ${
(currentTime / duration) * 100
}%, rgba(255,255,255,0.3) ${
(currentTime / duration) * 100
}%, rgba(255,255,255,0.3) 100%)`,
}}
/>
</div>
<span className="min-w-0 text-xs font-mono">
{formatTime(duration)}
</span>
</div>
{/* Control Buttons */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{" "}
<button
onClick={(e) => {
e.stopPropagation();
skip(-10);
}}
className="p-2 text-white hover:bg-white/20 rounded-md transition-colors"
>
<SkipBack className="w-4 h-4" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
togglePlay();
}}
className="p-2 text-white hover:bg-white/20 rounded-md transition-colors"
>
{isPlaying ? (
<Pause className="w-4 h-4" />
) : (
<Play className="w-4 h-4 ml-0.5" />
)}
</button>{" "}
<button
onClick={(e) => {
e.stopPropagation();
skip(10);
}}
className="p-2 text-white hover:bg-white/20 rounded-md transition-colors"
>
<SkipForward className="w-4 h-4" />
</button>
<div className="flex items-center gap-2 group/volume">
{" "}
<button
onClick={(e) => {
e.stopPropagation();
toggleMute();
}}
className="p-2 text-white hover:bg-white/20 rounded-md transition-colors"
>
{isMuted || volume === 0 ? (
<VolumeX className="w-4 h-4" />
) : (
<Volume2 className="w-4 h-4" />
)}
</button>
<div className="w-0 group-hover/volume:w-20 transition-all duration-200 overflow-hidden">
{" "}
<input
type="range"
min={0}
max={1}
step={0.1}
value={isMuted ? 0 : volume}
onChange={(e) => {
e.stopPropagation();
handleVolumeChange(e);
}}
className="w-full h-1 bg-white/30 rounded-full appearance-none cursor-pointer
[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-2 [&::-webkit-slider-thumb]:h-2
[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white
[&::-webkit-slider-thumb]:cursor-pointer"
style={{
background: `linear-gradient(to right, #ffffff 0%, #ffffff ${
(isMuted ? 0 : volume) * 100
}%, rgba(255,255,255,0.3) ${
(isMuted ? 0 : volume) * 100
}%, rgba(255,255,255,0.3) 100%)`,
}}
/>
</div>
</div>
</div>
<div className="flex items-center gap-2">
{" "}
<button
onClick={(e) => {
e.stopPropagation();
toggleFullscreen();
}}
className="p-2 text-white hover:bg-white/20 rounded-md transition-colors"
>
{isFullscreen ? (
<Minimize className="w-4 h-4" />
) : (
<Maximize className="w-4 h-4" />
)}
</button>
</div>
</div>
</div>
</div>
</>
)}
</div>
);
},
);
VideoPlayer.displayName = "VideoPlayer";
export { VideoPlayer, videoPlayerVariants };
npx hextaui@latest add video-player
pnpm dlx hextaui@latest add video-player
yarn dlx hextaui@latest add video-player
bun x hextaui@latest add video-player
Usage
import { VideoPlayer } from "@/components/ui/VideoPlayer";
<VideoPlayer src="/path/to/video.mp4" poster="/path/to/poster.jpg" />
Examples
Sizes
Small
0:00 0:00
Default
0:00 0:00
Large
0:00 0:00
<div className="space-y-6">
<VideoPlayer src="/video.mp4" size="sm" />
<VideoPlayer src="/video.mp4" size="default" />
<VideoPlayer src="/video.mp4" size="lg" />
</div>
With Poster
0:00 0:00
<VideoPlayer src="/video.mp4" poster="/poster.jpg" size="default" />
Auto-hide Controls
0:00 0:00
<VideoPlayer src="/video.mp4" autoHide={true} />
No Controls
<VideoPlayer src="/video.mp4" showControls={false} />
Full Width
0:00 0:00
<VideoPlayer src="/video.mp4" size="full" className="aspect-video" />
Keyboard Shortcuts
The video player supports the following keyboard shortcuts when focused:
- Space/K - Play/Pause
- M - Toggle mute
- F - Toggle fullscreen
- ←/→ - Skip backward/forward (10 seconds)
- ↑/↓ - Volume up/down
Features
- Modern Design - Clean, minimal interface with smooth animations
- Full Controls - Play/pause, seek, volume, fullscreen, skip controls
- Keyboard Shortcuts - Complete keyboard navigation support
- Auto-hide Controls - Controls fade away during playback for immersive viewing
- Responsive - Works perfectly on all screen sizes
- Accessible - Screen reader friendly with proper ARIA labels
- Customizable - Multiple size variants and styling options
Props
Prop | Type | Default |
---|---|---|
className? | string | undefined |
autoHide? | boolean | true |
showControls? | boolean | true |
size? | "sm" | "default" | "lg" | "full" | "default" |
poster? | string | undefined |
src? | string | required |
Edit on GitHub
Last updated on