All components

Video Player

Video Player is a React and Tailwind CSS media component from HextaUI, licensed MIT. Copy it and paste it into your project.

HextaUI MIT media

What it is

Video Player is a React and Tailwind CSS media component from HextaUI, licensed MIT. Copy it and paste it into your project.

How to use it

Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.

Open in the catalogue

Five free copies a month. No card.

Needs

npm i @radix-ui/react-slot

Licence

This component comes from HextaUI and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.

Video Player

registry/new-york/ui/video-player.tsx
"use client";

import { cva, type VariantProps } from "class-variance-authority";
import {
  Maximize,
  Minimize,
  Pause,
  Play,
  SkipBack,
  SkipForward,
  Volume2,
  VolumeX,
} from "lucide-react";
import * as React from "react";
import { cn } from "@/lib/utils";

const videoPlayerVariants = cva(
  "group relative w-full touch-manipulation overflow-hidden rounded-card bg-black",
  {
    variants: {
      size: {
        sm: "max-w-md",
        default: "max-w-2xl",
        lg: "max-w-4xl",
        full: "w-full",
      },
    },
    defaultVariants: {
      size: "default",
    },
  }
);

export interface VideoPlayerProps
  extends Omit<React.VideoHTMLAttributes<HTMLVideoElement>, "controls">,
    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<number | null>(null);
    const liveRef = React.useRef<HTMLDivElement>(null);

    React.useImperativeHandle(ref, () => videoRef.current as HTMLVideoElement);

    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 announce = React.useCallback((msg: string) => {
      if (!liveRef.current) return;
      liveRef.current.textContent = msg;
    }, []);

    const resetHideControlsTimeout = React.useCallback(() => {
      if (hideControlsTimeoutRef.current)
        window.clearTimeout(hideControlsTimeoutRef.current);
      if (autoHide && isPlaying) {
        hideControlsTimeoutRef.current = window.setTimeout(() => {
          setShowControlsState(false);
        }, 3000);
      }
    }, [autoHide, isPlaying]);

    const togglePlay = React.useCallback(() => {
      const el = videoRef.current;
      if (!el) return;
      if (el.paused) {
        el.play();
        announce("Playing");
      } else {
        el.pause();
        announce("Paused");
      }
    }, [announce]);

    const toggleMute = React.useCallback(() => {
      const el = videoRef.current;
      if (!el) return;
      el.muted = !el.muted;
      setIsMuted(el.muted);
      announce(el.muted ? "Muted" : "Unmuted");
    }, [announce]);

    const handleVolumeChange = React.useCallback((newVolume: number) => {
      const el = videoRef.current;
      setVolume(newVolume);
      if (el) {
        el.volume = newVolume;
        setIsMuted(newVolume === 0);
      }
    }, []);

    const handleSeek = React.useCallback((newTime: number) => {
      setCurrentTime(newTime);
      const el = videoRef.current;
      if (el) el.currentTime = newTime;
    }, []);

    const toggleFullscreen = React.useCallback(() => {
      if (document.fullscreenElement) {
        document.exitFullscreen();
        setIsFullscreen(false);
      } else {
        containerRef.current?.requestFullscreen();
        setIsFullscreen(true);
      }
    }, []);

    const skip = React.useCallback(
      (seconds: number) => {
        const el = videoRef.current;
        if (!el) return;
        const next = Math.max(
          0,
          Math.min(el.duration || 0, (el.currentTime || 0) + seconds)
        );
        el.currentTime = next;
        setCurrentTime(next);
        announce(
          `${seconds > 0 ? "Forward" : "Back"} ${Math.abs(seconds)} seconds`
        );
      },
      [announce]
    );

    const handleMouseMove = () => {
      setShowControlsState(true);
      resetHideControlsTimeout();
    };

    React.useEffect(() => {
      const video = videoRef.current;
      if (!video) return;
      const onLoadedMetadata = () => setDuration(video.duration || 0);
      const onTimeUpdate = () => setCurrentTime(video.currentTime || 0);
      const onPlay = () => {
        setIsPlaying(true);
        resetHideControlsTimeout();
      };
      const onPause = () => {
        setIsPlaying(false);
        setShowControlsState(true);
        if (hideControlsTimeoutRef.current)
          window.clearTimeout(hideControlsTimeoutRef.current);
      };
      const onVol = () => {
        setVolume(video.volume);
        setIsMuted(video.muted);
      };
      video.addEventListener("loadedmetadata", onLoadedMetadata);
      video.addEventListener("timeupdate", onTimeUpdate);
      video.addEventListener("play", onPlay);
      video.addEventListener("pause", onPause);
      video.addEventListener("volumechange", onVol);
      return () => {
        video.removeEventListener("loadedmetadata", onLoadedMetadata);
        video.removeEventListener("timeupdate", onTimeUpdate);
        video.removeEventListener("play", onPlay);
        video.removeEventListener("pause", onPause);
        video.removeEventListener("volumechange", onVol);
        if (hideControlsTimeoutRef.current)
          window.clearTimeout(hideControlsTimeoutRef.current);
      };
    }, [autoHide, isPlaying, resetHideControlsTimeout]);

    React.useEffect(() => {
      const onFs = () => setIsFullscreen(!!document.fullscreenElement);
      document.addEventListener("fullscreenchange", onFs);
      return () => document.removeEventListener("fullscreenchange", onFs);
    }, []);

    React.useEffect(() => {
      const handleKeyDown = (e: KeyboardEvent) => {
        if (
          !(
            containerRef.current &&
            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();
            handleVolumeChange(
              Math.min(1, Math.round((volume + 0.1) * 100) / 100)
            );
            break;
          case "ArrowDown":
            e.preventDefault();
            handleVolumeChange(
              Math.max(0, Math.round((volume - 0.1) * 100) / 100)
            );
            break;
          default:
            break;
        }
      };

      document.addEventListener("keydown", handleKeyDown);

      return () => {
        document.removeEventListener("keydown", handleKeyDown);
      };
    }, [
      togglePlay,
      toggleMute,
      toggleFullscreen,
      skip,
      volume,
      handleVolumeChange,
    ]);

    const progressPct = duration ? (currentTime / duration) * 100 : 0;
    const volumePct = (isMuted ? 0 : volume) * 100;

    return (
      <div
        aria-label="Video player"
        className={cn(
          videoPlayerVariants({ size }),
          "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-safe:duration-200 motion-reduce:animate-none",
          className
        )}
        onMouseLeave={() =>
          autoHide && isPlaying && setShowControlsState(false)
        }
        onMouseMove={handleMouseMove}
        ref={containerRef}
        role="region"
        tabIndex={0}
      >
        <div
          aria-atomic="true"
          aria-live="polite"
          className="sr-only"
          ref={liveRef}
        />
        <video
          aria-label="Video"
          className="h-full w-full object-cover"
          onClick={togglePlay}
          poster={poster}
          ref={videoRef}
          src={src}
          {...props}
        />
        {showControls && (
          <>
            <div
              className={cn(
                "pointer-events-none absolute inset-0 flex items-center justify-center transition-opacity motion-safe:duration-200",
                !isPlaying || showControlsState ? "opacity-100" : "opacity-0"
              )}
            >
              <button
                aria-label={isPlaying ? "Pause" : "Play"}
                className="pointer-events-auto flex size-16 items-center justify-center rounded-full border border-white/30 bg-white/20 text-white backdrop-blur-sm transition-colors hover:bg-white/30 motion-safe:duration-200"
                onClick={(e) => {
                  e.stopPropagation();
                  togglePlay();
                }}
                type="button"
              >
                {isPlaying ? (
                  <Pause aria-hidden="true" className="size-6" />
                ) : (
                  <Play aria-hidden="true" className="size-6" />
                )}
              </button>
            </div>

            <div
              className={cn(
                "pointer-events-none absolute inset-x-0 bottom-0 bg-linear-to-t from-black/80 via-black/40 to-transparent transition-opacity motion-safe:duration-200",
                showControlsState ? "opacity-100" : "opacity-0"
              )}
            >
              <div className="pointer-events-auto flex flex-col gap-2 p-4">
                <div className="flex items-center gap-2 text-sm text-white">
                  <span aria-live="off" className="min-w-0 font-mono text-xs">
                    {formatTime(currentTime)}
                  </span>
                  <div className="group/progress relative flex-1">
                    <label className="sr-only" htmlFor="video-progress">
                      Seek
                    </label>
                    <input
                      aria-label="Seek"
                      aria-valuemax={Math.max(0, Math.floor(duration))}
                      aria-valuemin={0}
                      aria-valuenow={Math.floor(currentTime)}
                      className="h-1 w-full cursor-pointer appearance-none rounded-full bg-white/30 focus-visible:outline-none motion-safe:duration-200 [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white group-hover/progress:[&::-webkit-slider-thumb]:scale-125"
                      id="video-progress"
                      max={duration || 0}
                      min={0}
                      onChange={(e) => {
                        e.stopPropagation();
                        handleSeek(Number.parseFloat(e.target.value));
                      }}
                      role="slider"
                      style={{
                        background: `linear-gradient(to right, #ffffff 0%, #ffffff ${progressPct}%, rgba(255,255,255,0.3) ${progressPct}%, rgba(255,255,255,0.3) 100%)`,
                      }}
                      type="range"
                      value={currentTime}
                    />
                  </div>
                  <span className="min-w-0 font-mono text-xs">
                    {formatTime(duration)}
                  </span>
                </div>

                <div className="flex items-center justify-between">
                  <div className="flex items-center gap-2">
                    <button
                      aria-label="Skip back 10 seconds"
                      className="rounded-md p-2 text-white transition-colors hover:bg-white/20"
                      onClick={(e) => {
                        e.stopPropagation();
                        skip(-10);
                      }}
                      type="button"
                    >
                      <SkipBack aria-hidden="true" className="size-4" />
                    </button>
                    <button
                      aria-label={isPlaying ? "Pause" : "Play"}
                      className="rounded-md p-2 text-white transition-colors hover:bg-white/20"
                      onClick={(e) => {
                        e.stopPropagation();
                        togglePlay();
                      }}
                      type="button"
                    >
                      {isPlaying ? (
                        <Pause aria-hidden="true" className="size-4" />
                      ) : (
                        <Play aria-hidden="true" className="size-4" />
                      )}
                    </button>
                    <button
                      aria-label="Skip forward 10 seconds"
                      className="rounded-md p-2 text-white transition-colors hover:bg-white/20"
                      onClick={(e) => {
                        e.stopPropagation();
                        skip(10);
                      }}
                      type="button"
                    >
                      <SkipForward aria-hidden="true" className="size-4" />
                    </button>
                    <div className="group/volume flex items-center gap-2">
                      <button
                        aria-label={isMuted || volume === 0 ? "Unmute" : "Mute"}
                        className="rounded-md p-2 text-white transition-colors hover:bg-white/20"
                        onClick={(e) => {
                          e.stopPropagation();
                          toggleMute();
                        }}
                        type="button"
                      >
                        {isMuted || volume === 0 ? (
                          <VolumeX aria-hidden="true" className="size-4" />
                        ) : (
                          <Volume2 aria-hidden="true" className="size-4" />
                        )}
                      </button>
                      <div className="w-0 overflow-hidden transition-all group-hover/volume:w-20 motion-safe:duration-200">
                        <label className="sr-only" htmlFor="video-volume">
                          Volume
                        </label>
                        <input
                          aria-label="Volume"
                          aria-valuemax={100}
                          aria-valuemin={0}
                          aria-valuenow={Math.round(volumePct)}
                          className="h-1 w-full cursor-pointer appearance-none rounded-full bg-white/30 focus-visible:outline-none [&::-webkit-slider-thumb]:size-2 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white"
                          id="video-volume"
                          max={1}
                          min={0}
                          onChange={(e) => {
                            e.stopPropagation();
                            handleVolumeChange(
                              Number.parseFloat(e.target.value)
                            );
                          }}
                          role="slider"
                          step={0.1}
                          style={{
                            background: `linear-gradient(to right, #ffffff 0%, #ffffff ${volumePct}%, rgba(255,255,255,0.3) ${volumePct}%, rgba(255,255,255,0.3) 100%)`,
                          }}
                          type="range"
                          value={isMuted ? 0 : volume}
                        />
                      </div>
                    </div>
                  </div>

                  <div className="flex items-center gap-2">
                    <button
                      aria-label={
                        isFullscreen ? "Exit fullscreen" : "Enter fullscreen"
                      }
                      className="rounded-md p-2 text-white transition-colors hover:bg-white/20"
                      onClick={(e) => {
                        e.stopPropagation();
                        toggleFullscreen();
                      }}
                      type="button"
                    >
                      {isFullscreen ? (
                        <Minimize aria-hidden="true" className="size-4" />
                      ) : (
                        <Maximize aria-hidden="true" className="size-4" />
                      )}
                    </button>
                  </div>
                </div>
              </div>
            </div>
          </>
        )}
      </div>
    );
  }
);

VideoPlayer.displayName = "VideoPlayer";

export { VideoPlayer };

Similar components