Skip to main content
Back to Blog
Tutorials
5 min read
December 12, 2024

How to Build a Video Player Component in React

Create a custom video player component in React with play/pause, progress bar, volume control, fullscreen, and keyboard shortcuts.

Ryel Banfield

Founder & Lead Developer

A custom video player lets you match your brand and control the user experience. Here is a fully featured player with modern controls.

Video Player Hook

"use client";

import { useCallback, useEffect, useRef, useState } from "react";

interface UseVideoPlayerOptions {
  autoplay?: boolean;
  startTime?: number;
}

export function useVideoPlayer(options: UseVideoPlayerOptions = {}) {
  const videoRef = useRef<HTMLVideoElement>(null);
  const [isPlaying, setIsPlaying] = useState(false);
  const [currentTime, setCurrentTime] = useState(0);
  const [duration, setDuration] = useState(0);
  const [volume, setVolumeState] = useState(1);
  const [isMuted, setIsMuted] = useState(false);
  const [isFullscreen, setIsFullscreen] = useState(false);
  const [playbackRate, setPlaybackRateState] = useState(1);
  const [buffered, setBuffered] = useState(0);
  const [isLoading, setIsLoading] = useState(true);

  const video = videoRef.current;

  const togglePlay = useCallback(() => {
    if (!video) return;
    if (video.paused) {
      video.play();
    } else {
      video.pause();
    }
  }, [video]);

  const seek = useCallback(
    (time: number) => {
      if (!video) return;
      video.currentTime = Math.max(0, Math.min(time, duration));
    },
    [video, duration]
  );

  const setVolume = useCallback(
    (val: number) => {
      if (!video) return;
      video.volume = Math.max(0, Math.min(val, 1));
      video.muted = val === 0;
    },
    [video]
  );

  const toggleMute = useCallback(() => {
    if (!video) return;
    video.muted = !video.muted;
  }, [video]);

  const setPlaybackRate = useCallback(
    (rate: number) => {
      if (!video) return;
      video.playbackRate = rate;
    },
    [video]
  );

  const toggleFullscreen = useCallback(() => {
    const container = video?.parentElement;
    if (!container) return;

    if (document.fullscreenElement) {
      document.exitFullscreen();
    } else {
      container.requestFullscreen();
    }
  }, [video]);

  const skipForward = useCallback(() => seek(currentTime + 10), [seek, currentTime]);
  const skipBackward = useCallback(() => seek(currentTime - 10), [seek, currentTime]);

  useEffect(() => {
    if (!video) return;

    const handlers = {
      play: () => setIsPlaying(true),
      pause: () => setIsPlaying(false),
      timeupdate: () => setCurrentTime(video.currentTime),
      durationchange: () => setDuration(video.duration),
      volumechange: () => {
        setVolumeState(video.volume);
        setIsMuted(video.muted);
      },
      ratechange: () => setPlaybackRateState(video.playbackRate),
      waiting: () => setIsLoading(true),
      canplay: () => setIsLoading(false),
      loadedmetadata: () => {
        setDuration(video.duration);
        setIsLoading(false);
        if (options.startTime) video.currentTime = options.startTime;
      },
      progress: () => {
        if (video.buffered.length > 0) {
          setBuffered(video.buffered.end(video.buffered.length - 1));
        }
      },
    };

    for (const [event, handler] of Object.entries(handlers)) {
      video.addEventListener(event, handler);
    }

    const onFullscreenChange = () => {
      setIsFullscreen(!!document.fullscreenElement);
    };
    document.addEventListener("fullscreenchange", onFullscreenChange);

    return () => {
      for (const [event, handler] of Object.entries(handlers)) {
        video.removeEventListener(event, handler);
      }
      document.removeEventListener("fullscreenchange", onFullscreenChange);
    };
  }, [video, options.startTime]);

  return {
    videoRef,
    isPlaying,
    currentTime,
    duration,
    volume,
    isMuted,
    isFullscreen,
    playbackRate,
    buffered,
    isLoading,
    togglePlay,
    seek,
    setVolume,
    toggleMute,
    setPlaybackRate,
    toggleFullscreen,
    skipForward,
    skipBackward,
  };
}

Video Player Component

"use client";

import { useVideoPlayer } from "./useVideoPlayer";
import { useEffect, useRef, useState } from "react";

interface VideoPlayerProps {
  src: string;
  poster?: string;
  title?: string;
}

export function VideoPlayer({ src, poster, title }: VideoPlayerProps) {
  const player = useVideoPlayer();
  const containerRef = useRef<HTMLDivElement>(null);
  const [showControls, setShowControls] = useState(true);
  const hideTimeout = useRef<ReturnType<typeof setTimeout>>(null);

  // Auto-hide controls
  useEffect(() => {
    if (!player.isPlaying) {
      setShowControls(true);
      return;
    }

    const resetTimer = () => {
      setShowControls(true);
      if (hideTimeout.current) clearTimeout(hideTimeout.current);
      hideTimeout.current = setTimeout(() => setShowControls(false), 3000);
    };

    const container = containerRef.current;
    container?.addEventListener("mousemove", resetTimer);
    container?.addEventListener("touchstart", resetTimer);
    resetTimer();

    return () => {
      container?.removeEventListener("mousemove", resetTimer);
      container?.removeEventListener("touchstart", resetTimer);
      if (hideTimeout.current) clearTimeout(hideTimeout.current);
    };
  }, [player.isPlaying]);

  // Keyboard shortcuts
  useEffect(() => {
    const handleKey = (e: KeyboardEvent) => {
      if (e.target instanceof HTMLInputElement) return;

      switch (e.key) {
        case " ":
        case "k":
          e.preventDefault();
          player.togglePlay();
          break;
        case "ArrowLeft":
          e.preventDefault();
          player.skipBackward();
          break;
        case "ArrowRight":
          e.preventDefault();
          player.skipForward();
          break;
        case "ArrowUp":
          e.preventDefault();
          player.setVolume(player.volume + 0.1);
          break;
        case "ArrowDown":
          e.preventDefault();
          player.setVolume(player.volume - 0.1);
          break;
        case "m":
          player.toggleMute();
          break;
        case "f":
          player.toggleFullscreen();
          break;
      }
    };

    window.addEventListener("keydown", handleKey);
    return () => window.removeEventListener("keydown", handleKey);
  }, [player]);

  return (
    <div
      ref={containerRef}
      className="relative group bg-black rounded-lg overflow-hidden"
      style={{ cursor: showControls ? "default" : "none" }}
    >
      <video
        ref={player.videoRef}
        src={src}
        poster={poster}
        className="w-full aspect-video"
        onClick={player.togglePlay}
        playsInline
      />

      {/* Loading Spinner */}
      {player.isLoading && (
        <div className="absolute inset-0 flex items-center justify-center">
          <div className="w-10 h-10 border-3 border-white/30 border-t-white rounded-full animate-spin" />
        </div>
      )}

      {/* Play Button Overlay */}
      {!player.isPlaying && !player.isLoading && (
        <button
          onClick={player.togglePlay}
          className="absolute inset-0 flex items-center justify-center bg-black/20"
          aria-label="Play video"
        >
          <div className="w-16 h-16 bg-white/90 rounded-full flex items-center justify-center">
            <svg className="w-6 h-6 ml-1 text-black" fill="currentColor" viewBox="0 0 24 24">
              <path d="M8 5v14l11-7z" />
            </svg>
          </div>
        </button>
      )}

      {/* Controls */}
      <div
        className={`absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4 transition-opacity duration-300 ${
          showControls ? "opacity-100" : "opacity-0"
        }`}
      >
        {/* Progress Bar */}
        <ProgressBar
          currentTime={player.currentTime}
          duration={player.duration}
          buffered={player.buffered}
          onSeek={player.seek}
        />

        <div className="flex items-center justify-between mt-2">
          <div className="flex items-center gap-3">
            <button onClick={player.togglePlay} className="text-white" aria-label={player.isPlaying ? "Pause" : "Play"}>
              {player.isPlaying ? (
                <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M6 19h4V5H6v14zm8-14v14h4V5h-4z" /></svg>
              ) : (
                <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24"><path d="M8 5v14l11-7z" /></svg>
              )}
            </button>

            <VolumeControl
              volume={player.volume}
              isMuted={player.isMuted}
              onVolumeChange={player.setVolume}
              onToggleMute={player.toggleMute}
            />

            <span className="text-white text-xs font-mono">
              {formatTime(player.currentTime)} / {formatTime(player.duration)}
            </span>
          </div>

          <div className="flex items-center gap-3">
            <SpeedSelector
              current={player.playbackRate}
              onChange={player.setPlaybackRate}
            />

            <button onClick={player.toggleFullscreen} className="text-white" aria-label="Toggle fullscreen">
              <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
                <path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z" />
              </svg>
            </button>
          </div>
        </div>
      </div>

      {title && showControls && (
        <div className="absolute top-4 left-4 text-white text-sm font-medium">
          {title}
        </div>
      )}
    </div>
  );
}

function ProgressBar({
  currentTime,
  duration,
  buffered,
  onSeek,
}: {
  currentTime: number;
  duration: number;
  buffered: number;
  onSeek: (time: number) => void;
}) {
  const barRef = useRef<HTMLDivElement>(null);

  const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
    const rect = barRef.current?.getBoundingClientRect();
    if (!rect) return;
    const percent = (e.clientX - rect.left) / rect.width;
    onSeek(percent * duration);
  };

  const progress = duration ? (currentTime / duration) * 100 : 0;
  const bufferedPercent = duration ? (buffered / duration) * 100 : 0;

  return (
    <div
      ref={barRef}
      onClick={handleClick}
      className="h-1 bg-white/20 rounded cursor-pointer group/progress hover:h-1.5 transition-all"
    >
      <div
        className="h-full bg-white/30 rounded absolute"
        style={{ width: `${bufferedPercent}%` }}
      />
      <div
        className="h-full bg-white rounded relative"
        style={{ width: `${progress}%` }}
      >
        <div className="absolute right-0 top-1/2 -translate-y-1/2 w-3 h-3 bg-white rounded-full opacity-0 group-hover/progress:opacity-100 transition-opacity" />
      </div>
    </div>
  );
}

function VolumeControl({
  volume,
  isMuted,
  onVolumeChange,
  onToggleMute,
}: {
  volume: number;
  isMuted: boolean;
  onVolumeChange: (v: number) => void;
  onToggleMute: () => void;
}) {
  const effectiveVolume = isMuted ? 0 : volume;

  return (
    <div className="flex items-center gap-1.5 group/vol">
      <button onClick={onToggleMute} className="text-white" aria-label="Toggle mute">
        <svg className="w-5 h-5" fill="currentColor" viewBox="0 0 24 24">
          {effectiveVolume === 0 ? (
            <path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z" />
          ) : effectiveVolume < 0.5 ? (
            <path d="M18.5 12c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM5 9v6h4l5 5V4L9 9H5z" />
          ) : (
            <path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z" />
          )}
        </svg>
      </button>
      <input
        type="range"
        min={0}
        max={1}
        step={0.05}
        value={effectiveVolume}
        onChange={(e) => onVolumeChange(Number(e.target.value))}
        className="w-0 group-hover/vol:w-20 transition-all accent-white"
        aria-label="Volume"
      />
    </div>
  );
}

function SpeedSelector({ current, onChange }: { current: number; onChange: (r: number) => void }) {
  const speeds = [0.5, 0.75, 1, 1.25, 1.5, 2];
  const [open, setOpen] = useState(false);

  return (
    <div className="relative">
      <button onClick={() => setOpen(!open)} className="text-white text-xs font-mono">
        {current}x
      </button>
      {open && (
        <div className="absolute bottom-full right-0 mb-2 bg-black/90 rounded p-1 flex flex-col">
          {speeds.map((s) => (
            <button
              key={s}
              onClick={() => { onChange(s); setOpen(false); }}
              className={`px-3 py-1 text-xs rounded ${
                s === current ? "text-white bg-white/20" : "text-white/70 hover:text-white"
              }`}
            >
              {s}x
            </button>
          ))}
        </div>
      )}
    </div>
  );
}

function formatTime(seconds: number): string {
  if (!isFinite(seconds)) return "0:00";
  const m = Math.floor(seconds / 60);
  const s = Math.floor(seconds % 60);
  return `${m}:${s.toString().padStart(2, "0")}`;
}

Need Custom Media Components?

We build rich interactive components for content-driven websites. Contact us to discuss your media needs.

videomediaReactcomponenttutorial

Ready to Start Your Project?

RCB Software builds world-class websites and applications for businesses worldwide.

Get in Touch

Related Articles