Custom Video Player in React: Controls, Progress, Fullscreen
Build a fully custom video player in React with play/pause, a scrubable progress bar, volume, and fullscreen — no third-party player library needed.
Why Build Your Own Video Player at All?
Honestly, the native <video> element is ugly by default and you can't style it consistently across browsers. Chrome, Safari, and Firefox each ship their own control chrome, and none of them match your design system. If you've ever tried to skin appearance: none on a video element you already know the pain.
Third-party solutions like Video.js or Plyr are solid but they come with trade-offs: extra bundle weight, opinionated CSS you'll fight, and another dependency to audit every time there's a CVE. For most product use cases — a hero video, a course player, a demo loop — a small bespoke component is genuinely the right call.
What we're building here: a VideoPlayer React component backed by the HTML5 <video> API, styled with Tailwind v4.0.2, with play/pause, a click-and-drag progress bar, volume control, a time display, and fullscreen toggle. No library. Around 120 lines of TSX.
Setting Up the Ref and Core State
Everything in the HTML5 video API lives on the DOM node, so we need a useRef. We also track three pieces of state: playing (boolean), progress (0–1 float), and volume (0–1 float). That's it for state — everything else we can derive or read directly from the ref.
One thing that trips people up: the timeupdate event fires frequently but not on every frame. Don't try to sync to requestAnimationFrame unless you genuinely need sub-second accuracy. timeupdate at roughly 4 Hz is fine for a progress bar. Keep the event handler cheap.
import { useRef, useState, useCallback, useEffect } from 'react';
interface VideoPlayerProps {
src: string;
poster?: string;
className?: string;
}
export function VideoPlayer({ src, poster, className = '' }: VideoPlayerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [playing, setPlaying] = useState(false);
const [progress, setProgress] = useState(0); // 0 to 1
const [volume, setVolume] = useState(1);
const [duration, setDuration] = useState(0);
const [currentTime, setCurrentTime] = useState(0);
const handleTimeUpdate = useCallback(() => {
const v = videoRef.current;
if (!v || !v.duration) return;
setProgress(v.currentTime / v.duration);
setCurrentTime(v.currentTime);
}, []);
const handleLoadedMetadata = useCallback(() => {
if (videoRef.current) setDuration(videoRef.current.duration);
}, []);
useEffect(() => {
const v = videoRef.current;
if (!v) return;
v.addEventListener('timeupdate', handleTimeUpdate);
v.addEventListener('loadedmetadata', handleLoadedMetadata);
v.addEventListener('ended', () => setPlaying(false));
return () => {
v.removeEventListener('timeupdate', handleTimeUpdate);
v.removeEventListener('loadedmetadata', handleLoadedMetadata);
};
}, [handleTimeUpdate, handleLoadedMetadata]);Play, Pause, and Volume Controls
Play/pause is two lines. Toggle the boolean, call the matching DOM method. The tricky part is keeping your React state in sync when the video ends or gets paused by the browser (e.g., when the tab loses focus on mobile). That's why we listen to ended in the effect above and reset playing to false.
Volume is just setting videoRef.current.volume. Range is 0.0 to 1.0. We wire it to a <input type="range"> with Tailwind's accent-white to override the browser thumb color. If you also want a mute toggle, read videoRef.current.muted — don't duplicate the volume value into another state variable, or they'll drift.
const togglePlay = () => {
const v = videoRef.current;
if (!v) return;
if (playing) {
v.pause();
} else {
v.play();
}
setPlaying(!playing);
};
const handleVolume = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = Number(e.target.value);
setVolume(val);
if (videoRef.current) videoRef.current.volume = val;
};Building a Scrubable Progress Bar
The progress bar is where most tutorials cut corners and just use <input type="range">. That works, but you lose control over the track height, thumb shape, and the hover-fill color. A fully custom bar uses a <div> with a mousedown listener, then tracks mousemove on the window until mouseup. This feels exactly like the progress bar in YouTube or Vimeo.
We calculate the click position as a fraction of the element's width using getBoundingClientRect(). Then we set videoRef.current.currentTime = fraction * duration. The timeupdate handler takes care of re-rendering the fill width — no additional state needed.
const progressRef = useRef<HTMLDivElement>(null);
const scrubbing = useRef(false);
const scrubTo = useCallback((clientX: number) => {
const bar = progressRef.current;
const v = videoRef.current;
if (!bar || !v) return;
const { left, width } = bar.getBoundingClientRect();
const fraction = Math.min(Math.max((clientX - left) / width, 0), 1);
v.currentTime = fraction * v.duration;
setProgress(fraction);
}, []);
const handleProgressMouseDown = (e: React.MouseEvent) => {
scrubbing.current = true;
scrubTo(e.clientX);
const onMove = (ev: MouseEvent) => scrubbing.current && scrubTo(ev.clientX);
const onUp = () => {
scrubbing.current = false;
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', onUp);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', onUp);
};
// JSX snippet for the bar:
// <div ref={progressRef} onMouseDown={handleProgressMouseDown}
// className="relative h-1.5 bg-white/20 rounded-full cursor-pointer group">
// <div className="absolute inset-y-0 left-0 bg-white rounded-full"
// style={{ width: `${progress * 100}%` }} />
// <div className="absolute top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full
// opacity-0 group-hover:opacity-100 transition-opacity"
// style={{ left: `calc(${progress * 100}% - 6px)` }} />
// </div>That group-hover:opacity-100 trick on the thumb dot is borrowed from the same approach we use in animated button components — show the interactive affordance only when the user is already hovering. It keeps the UI clean at a glance.
Fullscreen API Integration
Fullscreen is one API call: containerRef.current.requestFullscreen(). To exit, document.exitFullscreen(). The trick is detecting the current state — use document.fullscreenElement and compare it to your container ref. Sync a fullscreen boolean to state by listening to the fullscreenchange event on document.
Safari still uses the prefixed webkitRequestFullscreen in some older versions, but as of Safari 16.4 the unprefixed version ships. If you need to support iOS Safari on older iPhones, add a feature-detect: typeof containerRef.current.requestFullscreen === 'function'. Don't fall back silently — show the user a disabled button so they know it's not supported.
const [fullscreen, setFullscreen] = useState(false);
useEffect(() => {
const onChange = () => {
setFullscreen(document.fullscreenElement === containerRef.current);
};
document.addEventListener('fullscreenchange', onChange);
return () => document.removeEventListener('fullscreenchange', onChange);
}, []);
const toggleFullscreen = () => {
if (!containerRef.current) return;
if (!document.fullscreenElement) {
containerRef.current.requestFullscreen();
} else {
document.exitFullscreen();
}
};Styling the Controls Bar with Tailwind
The controls overlay sits at the bottom of the player container. We use absolute bottom-0 left-0 right-0 with a gradient from rgba(0,0,0,0) to rgba(0,0,0,0.75) — that's a Tailwind bg-gradient-to-t from-black/75 to-transparent. It fades in on hover using opacity-0 group-hover:opacity-100 transition-opacity duration-200.
Gap between control elements is 8px (gap-2 in Tailwind). The progress bar sits above the button row, spanning full width, with 4px of vertical padding so the hit target is usable on touch screens. On mobile you'll want to increase that to at least 12px of padding — thin scrub bars are frustrating on small screens.
For the time display we format seconds with a small helper: Math.floor(t / 60).toString().padStart(2, '0') + ':' + Math.floor(t % 60).toString().padStart(2, '0'). That gives you 0:00 / 1:23 without pulling in a date library. This same minimal-utility approach is what makes components like animated tabs stay lean — no dependencies for things you can write in two lines.
The icons can be any SVG icon set. Heroicons or Lucide both work. Keep them at w-5 h-5 (20px) inside buttons with p-1.5 padding so the click area is comfortably 28px. That clears the WCAG 2.5.5 target size guideline without making the bar feel bulky.
Keyboard Shortcuts and Accessibility
Did you know most users who watch video on desktop expect space bar to toggle play? If your player eats focus events and doesn't handle keyboard input, you're going to get bug reports. Wire onKeyDown to the outer container div with tabIndex={0} — space for play/pause, left/right arrows for ±5s seek, m for mute, f for fullscreen.
For screen readers, every button needs an aria-label. <button aria-label={playing ? 'Pause' : 'Play'}>. The progress bar div should have role="slider", aria-valuemin={0}, aria-valuemax={100}, and aria-valuenow={Math.round(progress * 100)}. Without these, the player is completely opaque to assistive technology.
The <video> element itself should have a title attribute and, where possible, a <track> element pointing to a WebVTT captions file. This is often skipped in custom players because people assume the custom UI replaces the need — it doesn't. Captions are a separate concern from visual controls.
Putting It Together and Next Steps
Once you assemble all the pieces — ref, state, event listeners, progress scrub, fullscreen, and the styled overlay — the full component sits comfortably under 160 lines. It's tree-shakable, has zero runtime dependencies beyond React, and you own 100% of the styling. You can drop it straight into a Next.js App Router page or a Vite + React app without any configuration.
From here there are a few obvious extensions. A currentTime-based chapter markers overlay (array of { time, label } objects rendered as thin markers on the progress bar). A quality selector if your backend serves HLS with multiple bitrates — that's where you'd bring in hls.js as a single targeted dependency. Picture-in-picture via videoRef.current.requestPictureInPicture(). All of these graft on cleanly because the core state is already structured.
If you're building a broader UI and want to see how custom interactive components integrate with design-system layouts, check out the bento grid component — the same principle of composing atomic state-driven components applies at a layout level too. Keep your components focused, keep state local unless it genuinely needs to lift, and the whole thing stays maintainable.
FAQ
video.play() returns a Promise and browsers block autoplay if the user hasn't interacted with the page yet. Wrap it in video.play().catch(() => {}) or — better — only call play() from inside a click handler, which counts as a user gesture and satisfies the autoplay policy.
Track a scrubbing ref (not state — you don't want a re-render). Set it to true on mousedown and false on mouseup. In your auto-hide timer logic, check if (scrubbing.current) return before starting the hide countdown.
Yes. For HLS, attach hls.js to your videoRef inside a useEffect after the component mounts: const hls = new Hls(); hls.loadSource(src); hls.attachMedia(videoRef.current). Everything else — state, controls, fullscreen — stays exactly the same. DASH works the same way with dash.js.
Wrap the video in a div with aspect-ratio: 16/9 (Tailwind: aspect-video) and set the video to width: 100%; height: 100%; object-fit: cover. The container div handles sizing; the video fills it without letterboxing or stretching.
Not in the traditional sense — iOS Safari runs fullscreen differently and requestFullscreen() on a container div is not supported. The <video> element itself has a webkitEnterFullscreen() method that iOS does support, though it hands control back to the native player. For iOS you're generally better off showing a prominent native fullscreen button via playsinline on the video element.
On timeupdate (debounced to every 5 seconds to reduce writes), save videoRef.current.currentTime to sessionStorage keyed by src. On mount, read that value back and set videoRef.current.currentTime inside loadedmetadata. Two event listeners and two storage calls — no library needed.