← Blog9 min read#video player#react#media

Video Player in React: Custom Controls, Fullscreen, Captions

Build a fully custom React video player with play/pause, seek, fullscreen, volume, and WebVTT captions - no third-party libraries required.

custom React video player with dark UI controls overlay

Why Build a Custom Video Player at All?

The browser's native <video> element is genuinely decent - and badly designed. Each browser renders it differently. Chrome gives you one look, Firefox another, Safari a third, and on iOS you get whatever Apple felt like shipping that year. If you're building anything that needs to feel like a product rather than a 1998 web page, the default controls need to go.

That said, rolling your own from scratch used to mean fighting with HTMLVideoElement APIs, pointer events, keyboard handlers, and a mountain of edge cases around buffering state. In 2026, with React 18's concurrent features and solid TypeScript support, it's actually manageable. You end up with a component you fully own - no licensing fees, no bloated dependency pulling in half of node_modules, no breaking changes from a library you don't control.

Honestly, the native <video> API is more capable than most devs realize. Everything you need - seeking, volume, mute, playback rate, buffering state, text tracks for captions - it's all there. The work is wiring it to a UI that doesn't look like it shipped with Windows XP.

Look, if you need DRM, adaptive bitrate streaming (HLS, DASH), or picture-in-picture in a production product, consider video.js or Plyr on top of what's here. But for the 90% case - a product demo, a course platform, a portfolio piece - you can ship something tight and beautiful yourself. Let's do that.

Setting Up the VideoPlayer Component

Start with a useRef pointing to the <video> element and a state object tracking the five things you actually care about: playing, current time, duration, volume, and muted. Everything else derives from those.

// VideoPlayer.tsx
import { useRef, useState, useEffect, useCallback } from 'react';

interface VideoPlayerProps {
  src: string;
  poster?: string;
  captions?: { src: string; label: string; srcLang: string }[];
}

export function VideoPlayer({ src, poster, captions = [] }: VideoPlayerProps) {
  const videoRef = useRef<HTMLVideoElement>(null);
  const containerRef = useRef<HTMLDivElement>(null);
  const [playing, setPlaying] = useState(false);
  const [currentTime, setCurrentTime] = useState(0);
  const [duration, setDuration] = useState(0);
  const [volume, setVolume] = useState(1);
  const [muted, setMuted] = useState(false);
  const [showControls, setShowControls] = useState(true);
  const [buffered, setBuffered] = useState(0);

  useEffect(() => {
    const v = videoRef.current;
    if (!v) return;
    const onTime = () => setCurrentTime(v.currentTime);
    const onDuration = () => setDuration(v.duration);
    const onProgress = () => {
      if (v.buffered.length > 0) {
        setBuffered(v.buffered.end(v.buffered.length - 1));
      }
    };
    v.addEventListener('timeupdate', onTime);
    v.addEventListener('loadedmetadata', onDuration);
    v.addEventListener('progress', onProgress);
    return () => {
      v.removeEventListener('timeupdate', onTime);
      v.removeEventListener('loadedmetadata', onDuration);
      v.removeEventListener('progress', onProgress);
    };
  }, []);

  return (
    <div ref={containerRef} className="relative bg-black rounded-xl overflow-hidden group">
      <video
        ref={videoRef}
        src={src}
        poster={poster}
        className="w-full aspect-video"
        onClick={togglePlay}
      />
      {/* Controls overlay goes here */}
    </div>
  );
}

Three things worth calling out. First, we track buffered separately from currentTime - that lets you draw a two-tone progress bar (played vs buffered vs unloaded), which makes the player feel snappy even on slow connections. Second, cleanup in the useEffect return is non-negotiable; skip it and you'll leak event listeners every time the component remounts. Third, aspect-video (Tailwind's aspect-ratio: 16/9) handles the sizing so you don't need hardcoded pixel heights.

The showControls state drives an auto-hide behavior. You'll want controls to fade out after ~2.5 seconds of inactivity during playback - a 32px overlay blocking the video gets annoying fast. We'll wire that up with a useCallback and a setTimeout ref in the next section.

Quick aside: don't destructure the video ref inside the effect. videoRef.current can be null on the first render and TypeScript will yell at you if you're not careful. The if (!v) return; guard at the top of the effect is your friend.

Play, Seek, Volume, and the Progress Bar

The controls themselves are pure React - no magic. The <video> element is just a DOM node, and you call methods on it imperatively through the ref.

const togglePlay = useCallback(() => {
  const v = videoRef.current;
  if (!v) return;
  if (v.paused) {
    v.play();
    setPlaying(true);
  } else {
    v.pause();
    setPlaying(false);
  }
}, []);

const seek = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
  const v = videoRef.current;
  if (!v) return;
  const time = Number(e.target.value);
  v.currentTime = time;
  setCurrentTime(time);
}, []);

const changeVolume = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
  const v = videoRef.current;
  if (!v) return;
  const vol = Number(e.target.value);
  v.volume = vol;
  setVolume(vol);
  setMuted(vol === 0);
}, []);

const toggleMute = useCallback(() => {
  const v = videoRef.current;
  if (!v) return;
  v.muted = !v.muted;
  setMuted(!muted);
}, [muted]);

For the progress bar, use a <input type="range"> with min={0}, max={duration}, step={0.1}, and value={currentTime}. Style it with CSS custom properties to draw the buffered region as a lighter fill and the played region in your brand color. Tailwind doesn't give you a clean API for range styling, so drop into globals.css for that one:

/* In your globals.css */
input[type='range'].video-seek {
  --played: 60%;    /* updated via JS */
  --buffered: 75%;  /* updated via JS */
  background: linear-gradient(
    to right,
    #6366f1 0%,
    #6366f1 var(--played),
    #4b5563 var(--played),
    #4b5563 var(--buffered),
    #1f2937 var(--buffered),
    #1f2937 100%
  );
  height: 4px;
  border-radius: 2px;
  appearance: none;
  cursor: pointer;
}

Update those CSS custom properties in your onTimeUpdate handler by reaching into the DOM directly: seekRef.current?.style.setProperty('--played', ...). In practice, doing this inside a requestAnimationFrame callback keeps things smooth without hammering React's reconciler 60 times a second.

Worth noting: time formatting for the 00:12 / 04:32 display is a one-liner - const fmt = (s: number) => new Date(s * 1000).toISOString().slice(14, 19); - handles everything up to 60 minutes cleanly. Past that you'd want a proper duration formatter, but most videos are shorter.

Fullscreen API and Keyboard Shortcuts

Fullscreen in 2026 is mercifully consistent across browsers, though Safari still needs the -webkit- prefix for a couple of things. You're working with requestFullscreen on the container div (not the video element - that loses your custom controls overlay).

const [isFullscreen, setIsFullscreen] = useState(false);

const toggleFullscreen = useCallback(async () => {
  const el = containerRef.current;
  if (!el) return;
  try {
    if (!document.fullscreenElement) {
      await el.requestFullscreen();
      setIsFullscreen(true);
    } else {
      await document.exitFullscreen();
      setIsFullscreen(false);
    }
  } catch (err) {
    console.warn('Fullscreen failed:', err);
  }
}, []);

// Sync state when user presses Escape
useEffect(() => {
  const onFsChange = () => setIsFullscreen(!!document.fullscreenElement);
  document.addEventListener('fullscreenchange', onFsChange);
  return () => document.removeEventListener('fullscreenchange', onFsChange);
}, []);

Keyboard shortcuts are expected by users even when they never read documentation. Space = play/pause, M = mute, F = fullscreen, left/right arrows = Β±5 seconds, up/down = volume Β±10%. Wire them on the container div with onKeyDown and tabIndex={0} so the element can receive focus:

const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
  const v = videoRef.current;
  if (!v) return;
  switch (e.key) {
    case ' ': e.preventDefault(); togglePlay(); break;
    case 'm': case 'M': toggleMute(); break;
    case 'f': case 'F': toggleFullscreen(); break;
    case 'ArrowRight': v.currentTime = Math.min(v.currentTime + 5, duration); break;
    case 'ArrowLeft':  v.currentTime = Math.max(v.currentTime - 5, 0); break;
    case 'ArrowUp':    v.volume = Math.min(v.volume + 0.1, 1); break;
    case 'ArrowDown':  v.volume = Math.max(v.volume - 0.1, 0); break;
  }
}, [togglePlay, toggleMute, toggleFullscreen, duration]);

One more thing - on iOS, requestFullscreen on a div is not supported. The workaround is calling videoRef.current.webkitEnterFullscreen() instead, which triggers the native iOS player. Detect it with 'webkitEnterFullscreen' in videoRef.current before branching. It's ugly but it works, and it's been that way since iOS 10.

WebVTT Captions and Track Switching

Captions are the most-skipped feature in custom video players and the first thing accessibility audits flag. The HTML5 <track> element handles WebVTT files natively - you don't need a parsing library. The tricky part is that you need to manage TextTrack mode yourself when letting users toggle captions on and off.

// Inside your <video> element:
{captions.map((cap) => (
  <track
    key={cap.srcLang}
    kind="subtitles"
    src={cap.src}
    label={cap.label}
    srcLang={cap.srcLang}
    default={cap.srcLang === 'en'}
  />
))}

The default attribute tells the browser which track to show first - but the browser might still ignore it, especially if the user has previously dismissed captions on a different video. To take explicit control, grab the tracks from the video element and set mode manually:

const [captionsOn, setCaptionsOn] = useState(false);
const [activeLang, setActiveLang] = useState('en');

const toggleCaptions = useCallback(() => {
  const v = videoRef.current;
  if (!v) return;
  const tracks = Array.from(v.textTracks);
  tracks.forEach((track) => {
    if (track.language === activeLang) {
      track.mode = captionsOn ? 'disabled' : 'showing';
    } else {
      track.mode = 'disabled';
    }
  });
  setCaptionsOn(!captionsOn);
}, [captionsOn, activeLang]);

const switchLang = useCallback((lang: string) => {
  const v = videoRef.current;
  if (!v) return;
  Array.from(v.textTracks).forEach((track) => {
    track.mode = track.language === lang && captionsOn ? 'showing' : 'disabled';
  });
  setActiveLang(lang);
}, [captionsOn]);

For a multi-language caption menu, render a small dropdown from your captions prop - language code as the key, label as the display text. Keep the UI minimal: a CC icon button that toggles captions, and a small popover for language switching if you have more than one track. Anything more complex than that and you're building a media player product, not a component.

Styling the default browser caption rendering is painful - it varies wildly per browser and the ::cue pseudo-element support is spotty. In practice, if you need full control over caption appearance (font, background, position), you'll want to read cue data from the TextTrack API and render captions yourself in an absolutely-positioned overlay div. That adds ~60 lines of code but gives you pixel-perfect control. For most use cases, the browser's native rendering is fine.

Polishing the UI: Controls Overlay and Auto-Hide

A controls overlay that never hides is annoying. One that hides too fast is worse - users can't click things in time. The sweet spot is a 2500ms idle timeout that resets on any mouse movement or touch event, stays visible while paused, and never hides in fullscreen when the mouse is near the bottom.

const hideTimer = useRef<ReturnType<typeof setTimeout> | null>(null);

const showControlsTemporarily = useCallback(() => {
  setShowControls(true);
  if (hideTimer.current) clearTimeout(hideTimer.current);
  if (playing) {
    hideTimer.current = setTimeout(() => setShowControls(false), 2500);
  }
}, [playing]);

useEffect(() => {
  // Always show when paused
  if (!playing) {
    setShowControls(true);
    if (hideTimer.current) clearTimeout(hideTimer.current);
  }
}, [playing]);

For the visual transition, use opacity-0 group-hover:opacity-100 transition-opacity duration-300 on the controls container. But since group-hover won't work for the auto-hide timer approach, you'll end up conditionally toggling a class: ${showControls ? 'opacity-100' : 'opacity-0'}. Combine both - Tailwind's group-hover as a fallback and your state-driven class as the primary signal.

The controls bar itself should sit absolutely at the bottom of the container with a gradient scrim behind it: bg-gradient-to-t from-black/80 via-black/30 to-transparent. This keeps button labels readable over any video content without a hard-edged box. A 64px padding at the bottom gives you enough room for a progress bar, play button, time display, volume control, caption toggle, and fullscreen button in a row. If you're drawing inspiration for the visual style - dark surfaces, glowing icons, layered depth - check out the cyberpunk components on Empire UI for ideas that translate well to dark video UIs.

One more thing - the loading/buffering state. Show a spinner centered over the video when videoRef.current.readyState < 3 (HAVE_FUTURE_DATA). Listen for waiting and canplay events to toggle it. Users expect instant feedback that something is happening, and a well-designed spinner with a 200ms delay (to avoid flashing on fast connections) covers that nicely. The animated button components on the blog show a good pattern for delay-triggered animations you can adapt here.

Accessibility, Performance, and Finishing Touches

An inaccessible video player is a liability, not a feature. At minimum: every control button needs an aria-label, the play/pause button should announce its current state with aria-pressed, the seek slider needs aria-label="Seek" and aria-valuetext formatted as human-readable time, and the whole player needs role="region" with aria-label="Video player".

<button
  onClick={togglePlay}
  aria-label={playing ? 'Pause' : 'Play'}
  aria-pressed={playing}
  className="p-2 rounded-full hover:bg-white/10 transition-colors"
>
  {playing ? <PauseIcon /> : <PlayIcon />}
</button>

<input
  type="range"
  className="video-seek flex-1"
  aria-label="Seek"
  aria-valuetext={`${fmt(currentTime)} of ${fmt(duration)}`}
  min={0}
  max={duration}
  step={0.1}
  value={currentTime}
  onChange={seek}
/>

For performance: don't put the video player in a context that re-renders frequently. useCallback on all your handlers keeps the controls bar stable, but if the parent component is polling some API or has animated state, wrap the whole player in React.memo. The <video> element itself doesn't re-render in the React sense once mounted - the DOM node persists - but your overlay UI does, and you don't want 60fps state updates causing layout thrashing in the controls.

Worth noting: preload="metadata" on the video element loads just enough to show duration and poster without fetching the whole file. preload="none" is even more conservative - good for pages with multiple video players (think a course catalog). Don't use preload="auto" unless you're certain the user will watch the video; it burns bandwidth and hurts Core Web Vitals.

If you're building a design-system-quality component, look at how Empire UI handles component API design - the pattern of keeping internal state private while exposing an imperative handle via useImperativeHandle is exactly right for a video player. That lets parent components call playerRef.current.play() or playerRef.current.seek(30) without coupling to the internal state shape. Ship the player as a black box and your future self will thank you.

FAQ

Can I build a React video player without a library like video.js?

Yes - the native HTMLVideoElement API covers play, pause, seek, volume, captions, fullscreen, and buffering state. You only need a library if you're adding DRM, HLS/DASH adaptive streaming, or need deep cross-browser polyfills.

How do I add WebVTT captions to a React video player?

Use the HTML5 <track> element inside your <video> with kind="subtitles" and a .vtt file URL. Control which track is active by setting textTrack.mode to 'showing' or 'disabled' via the videoRef.current.textTracks API.

Why call requestFullscreen on the container div instead of the video element?

Fullscreening the <video> element directly hands control back to the browser's native player and hides your custom controls overlay. Fullscreening the wrapper div keeps your entire UI visible in fullscreen mode.

How do I prevent controls from re-rendering at 60fps during playback?

Update CSS custom properties on the seek input directly via the DOM (seekRef.current.style.setProperty) inside a requestAnimationFrame callback instead of calling setState on every timeupdate event. Reserve React state for coarse updates like play/pause and volume changes.

Free components in 41 styles
React & Tailwind, copy-paste ready.
Browse β†’

Read next

React UI Components Complete Reference: 60+ Patterns with Code β†’Audio Player in React: Play, Seek, Volume and Waveform Display β†’Icon System in React: lucide-react, Heroicons and Custom SVGs β†’Feedback Widget in React: Thumbs, Star Rating, Free Text β†’