# Video Player

> Video element wrapper — autoplay-friendly defaults, slot-based controls, carousel-coordinated pause, and double-tap gestures.

A shadcn/ui block from the [@ilinxa](https://ui.ilinxa.com) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/ilinxa/video-player).

- Page, with a live preview: https://shadcn.exchange/r/ilinxa/video-player
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/ilinxa/video-player-light-e051ee112f504b07.png
- Registry item: https://ui.ilinxa.com/r/video-player.json
- Categories: media
- Listed: 2026-10-13, source last changed 2026-10-13

## Install

```bash
npx shadcn@latest add https://ui.ilinxa.com/r/video-player.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `lucide-react`
- Registry items: `button`

## Files

### `src/registry/components/media/video-player/video-player.tsx`

```tsx
"use client";

import { memo, useMemo } from "react";
import { cn } from "@/lib/utils";
import {
  DEFAULT_VIDEO_PLAYER_LABELS,
  type VideoPlayerLabels,
  type VideoPlayerProps,
  type VideoState,
} from "./types";
import { useVideoState } from "./hooks/use-video-state";
import { useDoubleTap } from "./hooks/use-double-tap";
import { VideoElement } from "./parts/video-element";
import { DefaultControls } from "./parts/default-controls";

function VideoPlayerInner({
  src,
  poster,
  tracks = [],
  isActive = true,
  loop = true,
  muted = true,
  playsInline = true,
  preload = "metadata",
  autoPlay = false,
  objectFit = "cover",
  controls = true,
  renderControls,
  controlsAutoHideMs = 2000,
  onDoubleTap,
  onPlay,
  onPause,
  onEnded,
  onTimeUpdate,
  onLoadedMetadata,
  onError,
  labels: labelsProp,
  className,
  videoClassName,
  controlsClassName,
}: VideoPlayerProps) {
  const labels = useMemo<Required<VideoPlayerLabels>>(
    () => ({ ...DEFAULT_VIDEO_PLAYER_LABELS, ...labelsProp }),
    [labelsProp],
  );

  const { state, videoRef, togglePlay, toggleMute, videoEventHandlers } =
    useVideoState({
      initialMuted: muted,
      isActive,
      onPlay,
      onPause,
      onEnded,
      onTimeUpdate,
      onLoadedMetadata,
      onError,
    });

  // (isActive auto-pause now lives inside useVideoState — hook owns video DOM.)
  const handleDoubleTap = useDoubleTap(onDoubleTap);

  const stateForRender: VideoState = {
    ...state,
    isActive,
    togglePlay,
    toggleMute,
  };

  return (
    <div
      className={cn(
        "relative h-full w-full overflow-hidden bg-black",
        className,
      )}
      onClick={handleDoubleTap}
    >
      <VideoElement
        videoRef={videoRef}
        eventHandlers={videoEventHandlers}
        src={src}
        poster={poster}
        tracks={tracks}
        loop={loop}
        muted={muted}
        playsInline={playsInline}
        preload={preload}
        autoPlay={autoPlay}
        objectFit={objectFit}
        ariaLabel={labels.videoLabel}
        videoClassName={videoClassName}
        onTogglePlay={togglePlay}
        onToggleMute={toggleMute}
      />

      {controls &&
        (renderControls ? (
          renderControls(stateForRender)
        ) : (
          <DefaultControls
            state={stateForRender}
            labels={labels}
            controlsAutoHideMs={controlsAutoHideMs}
            controlsClassName={controlsClassName}
          />
        ))}
    </div>
  );
}

const VideoPlayer = memo(VideoPlayerInner);
VideoPlayer.displayName = "VideoPlayer";

export { VideoPlayer };
export default VideoPlayer;
```

### `src/registry/components/media/video-player/index.ts`

```ts
export { default as VideoPlayer } from "./video-player";

export type {
  VideoPlayerProps,
  VideoPlayerLabels,
  VideoState,
  VideoTrack,
} from "./types";

export { DEFAULT_VIDEO_PLAYER_LABELS } from "./types";

export {
  useDoubleTap,
  type UseDoubleTapOptions,
} from "./hooks/use-double-tap";
```

### `src/registry/components/media/video-player/types.ts`

```ts
import type { ReactNode } from "react";

export interface VideoTrack {
  /** Track type. */
  kind: TextTrackKind;
  /** WebVTT URL. */
  src: string;
  /** BCP-47 language tag (e.g. "en", "tr-TR"). */
  srcLang: string;
  /** Human-readable label shown in the browser's track menu. */
  label: string;
  /** Mark this track as default (only one per kind should be default). */
  default?: boolean;
}

export interface VideoState {
  /** Currently playing — mirrors the underlying `<video>` element. */
  isPlaying: boolean;
  /** Currently muted. */
  isMuted: boolean;
  /** Active state from the `isActive` prop. */
  isActive: boolean;
  /** True after `loadedmetadata` fires. `duration` is reliable from this point. */
  isLoaded: boolean;
  /** Total duration in seconds (NaN until loaded). */
  duration: number;
  /** Current playback position in seconds (rAF-throttled). */
  currentTime: number;
  /** Imperative: toggle play/pause. */
  togglePlay: () => void;
  /** Imperative: toggle mute/unmute. */
  toggleMute: () => void;
}

export interface VideoPlayerLabels {
  /** Default: "Play". aria-label on the play button. */
  play?: string;
  /** Default: "Pause". aria-label on the pause button. */
  pause?: string;
  /** Default: "Mute". aria-label on the mute toggle when audio is on. */
  mute?: string;
  /** Default: "Unmute". aria-label on the mute toggle when audio is muted. */
  unmute?: string;
  /** Default: "Video". aria-label on the <video> element itself. */
  videoLabel?: string;
}

export interface VideoPlayerProps {
  /** Video URL. Required. */
  src: string;
  /** Poster image URL. Browser shows it before playback + on load failure. */
  poster?: string;
  /** Caption / subtitle tracks. Renders as `<track>` children. */
  tracks?: VideoTrack[];

  // ─── Active state (carousel coordination) ────────────────────────
  /** When false, video pauses + state resets. Default: true. */
  isActive?: boolean;

  // ─── <video> attribute pass-through ──────────────────────────────
  /** Default: true. */
  loop?: boolean;
  /** Default: true (autoplay-friendly; browsers block unmuted autoplay). */
  muted?: boolean;
  /** Default: true (iOS Safari requires this for inline playback). */
  playsInline?: boolean;
  /** Default: "metadata". */
  preload?: "none" | "metadata" | "auto";
  /** Default: false. Subject to browser autoplay policy. */
  autoPlay?: boolean;

  // ─── Visual ──────────────────────────────────────────────────────
  /** Object-fit for the video element. Default: "cover". */
  objectFit?: "cover" | "contain";

  // ─── Controls ────────────────────────────────────────────────────
  /** Show control overlay (default OR custom). Default: true. */
  controls?: boolean;
  /** Custom control overlay. Receives full state + dispatchers. */
  renderControls?: (state: VideoState) => ReactNode;
  /** Auto-hide controls after N ms during playback. Default: 2000. Pass 0 to disable. */
  controlsAutoHideMs?: number;

  // ─── Gestures ────────────────────────────────────────────────────
  /** Fires on a 300ms-window double-tap. Caller wires e.g. heart burst. */
  onDoubleTap?: () => void;

  // ─── Lifecycle callbacks ─────────────────────────────────────────
  onPlay?: () => void;
  onPause?: () => void;
  onEnded?: () => void;
  /** Throttled to one call per requestAnimationFrame max. */
  onTimeUpdate?: (currentTime: number, duration: number) => void;
  /** Fires once on `loadedmetadata`. */
  onLoadedMetadata?: (duration: number) => void;
  /** Fires on video element error. */
  onError?: (error: MediaError | null) => void;

  // ─── i18n ────────────────────────────────────────────────────────
  /** Localized labels. Defaults are English. */
  labels?: VideoPlayerLabels;

  // ─── Style overrides ─────────────────────────────────────────────
  /** Override classes for the wrapping <div>. */
  className?: string;
  /** Override classes for the <video> element. */
  videoClassName?: string;
  /** Override classes for the default control overlay. Ignored when `renderControls` is provided. */
  controlsClassName?: string;
}

/** Default English labels — exported for consumer composition. */
export const DEFAULT_VIDEO_PLAYER_LABELS: Required<VideoPlayerLabels> = {
  play: "Play",
  pause: "Pause",
  mute: "Mute",
  unmute: "Unmute",
  videoLabel: "Video",
};
```

### `src/registry/components/media/video-player/parts/video-element.tsx`

```tsx
"use client";

import type { ReactEventHandler, SyntheticEvent } from "react";
import { cn } from "@/lib/utils";
import type { VideoTrack } from "../types";

interface VideoEventHandlers {
  onPlay: ReactEventHandler<HTMLVideoElement>;
  onPause: ReactEventHandler<HTMLVideoElement>;
  onEnded: ReactEventHandler<HTMLVideoElement>;
  onLoadStart: ReactEventHandler<HTMLVideoElement>;
  onLoadedMetadata: ReactEventHandler<HTMLVideoElement>;
  onTimeUpdate: ReactEventHandler<HTMLVideoElement>;
  onError: (e: SyntheticEvent<HTMLVideoElement>) => void;
  onVolumeChange: ReactEventHandler<HTMLVideoElement>;
}

interface VideoElementProps {
  videoRef: (node: HTMLVideoElement | null) => void;
  eventHandlers: VideoEventHandlers;
  src: string;
  poster?: string;
  tracks: VideoTrack[];
  loop: boolean;
  muted: boolean;
  playsInline: boolean;
  preload: "none" | "metadata" | "auto";
  autoPlay: boolean;
  objectFit: "cover" | "contain";
  ariaLabel: string;
  videoClassName?: string;
  /** Called when the user clicks the video element (NOT a control button). */
  onTogglePlay: () => void;
  /** Called for the M keyboard shortcut. */
  onToggleMute: () => void;
}

export function VideoElement({
  videoRef,
  eventHandlers,
  src,
  poster,
  tracks,
  loop,
  muted,
  playsInline,
  preload,
  autoPlay,
  objectFit,
  ariaLabel,
  videoClassName,
  onTogglePlay,
  onToggleMute,
}: VideoElementProps) {
  const handleKeyDown = (e: React.KeyboardEvent<HTMLVideoElement>) => {
    if (e.key === " " || e.code === "Space") {
      e.preventDefault();
      onTogglePlay();
    } else if (e.key === "m" || e.key === "M") {
      e.preventDefault();
      onToggleMute();
    }
  };

  return (
    <video
      ref={videoRef}
      src={src}
      poster={poster}
      loop={loop}
      muted={muted}
      playsInline={playsInline}
      preload={preload}
      autoPlay={autoPlay}
      tabIndex={0}
      aria-label={ariaLabel}
      onClick={onTogglePlay}
      onKeyDown={handleKeyDown}
      {...eventHandlers}
      className={cn(
        "h-full w-full cursor-pointer",
        objectFit === "cover" ? "object-cover" : "object-contain",
        videoClassName,
      )}
    >
      {tracks.map((t, i) => (
        <track
          key={`${t.kind}-${t.srcLang}-${i}`}
          kind={t.kind}
          src={t.src}
          srcLang={t.srcLang}
          label={t.label}
          default={t.default}
        />
      ))}
    </video>
  );
}
```

### `src/registry/components/media/video-player/parts/default-controls.tsx`

```tsx
"use client";

import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
import { Pause, Play, Volume2, VolumeX } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { VideoPlayerLabels, VideoState } from "../types";

interface DefaultControlsProps {
  state: VideoState;
  labels: Required<VideoPlayerLabels>;
  controlsAutoHideMs: number;
  controlsClassName?: string;
}

const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";

const subscribeReducedMotion = (callback: () => void) => {
  if (typeof window === "undefined") return () => {};
  const mql = window.matchMedia(REDUCED_MOTION_QUERY);
  mql.addEventListener("change", callback);
  return () => mql.removeEventListener("change", callback);
};

const getReducedMotionSnapshot = () =>
  typeof window === "undefined"
    ? false
    : window.matchMedia(REDUCED_MOTION_QUERY).matches;

const getReducedMotionServerSnapshot = () => false;

function usePrefersReducedMotion() {
  return useSyncExternalStore(
    subscribeReducedMotion,
    getReducedMotionSnapshot,
    getReducedMotionServerSnapshot,
  );
}

export function DefaultControls({
  state,
  labels,
  controlsAutoHideMs,
  controlsClassName,
}: DefaultControlsProps) {
  const { isPlaying, isMuted, togglePlay, toggleMute } = state;
  const reducedMotion = usePrefersReducedMotion();

  // Controls are always visible when paused / auto-hide disabled / reduced-motion.
  const alwaysVisible =
    !isPlaying || controlsAutoHideMs <= 0 || reducedMotion;

  // Only used in auto-hide mode. Bumped via `wakeKey` to re-arm the timer.
  const [hidden, setHidden] = useState(false);
  const [wakeKey, setWakeKey] = useState(0);

  const showControls = alwaysVisible || !hidden;

  useEffect(() => {
    if (alwaysVisible) return;
    const t = setTimeout(() => setHidden(true), controlsAutoHideMs);
    return () => clearTimeout(t);
  }, [alwaysVisible, controlsAutoHideMs, wakeKey]);

  const wakeControls = useCallback(() => {
    setHidden(false);
    setWakeKey((k) => k + 1);
  }, []);

  return (
    <div
      className={cn(
        "pointer-events-none absolute inset-0",
        controlsClassName,
      )}
      onMouseEnter={wakeControls}
      onMouseMove={wakeControls}
    >
      {/* Big center play button — always visible when paused */}
      {!isPlaying && (
        <div className="absolute inset-0 flex items-center justify-center">
          <Button
            type="button"
            variant="secondary"
            size="icon"
            className="pointer-events-auto h-14 w-14 rounded-full bg-background/80 hover:bg-background/90"
            onClick={togglePlay}
            aria-label={labels.play}
          >
            <Play className="h-6 w-6 fill-current" aria-hidden="true" />
          </Button>
        </div>
      )}

      {/* Bottom-right mute toggle — auto-hides during playback */}
      <div
        className={cn(
          "absolute right-3 bottom-3 flex gap-2 transition-opacity duration-300",
          showControls ? "opacity-100" : "opacity-0",
        )}
      >
        <Button
          type="button"
          variant="secondary"
          size="icon"
          className="pointer-events-auto h-8 w-8 rounded-full bg-background/70 hover:bg-background/90"
          onClick={toggleMute}
          aria-pressed={isMuted}
          aria-label={isMuted ? labels.unmute : labels.mute}
        >
          {isMuted ? (
            <VolumeX className="h-4 w-4" aria-hidden="true" />
          ) : (
            <Volume2 className="h-4 w-4" aria-hidden="true" />
          )}
        </Button>
      </div>

      {/* Bottom-left small pause indicator — only when playing + showing controls */}
      {isPlaying && showControls && (
        <Button
          type="button"
          variant="ghost"
          size="icon"
          className="pointer-events-auto absolute bottom-3 left-3 h-8 w-8 rounded-full bg-background/70 hover:bg-background/90"
          onClick={togglePlay}
          aria-label={labels.pause}
        >
          <Pause className="h-4 w-4" aria-hidden="true" />
        </Button>
      )}
    </div>
  );
}
```

### `src/registry/components/media/video-player/hooks/use-video-state.ts`

```ts
import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
import type { VideoState } from "../types";

type VideoStateValue = Pick<
  VideoState,
  "isPlaying" | "isMuted" | "isLoaded" | "duration" | "currentTime"
>;

type VideoAction =
  | { type: "play" }
  | { type: "pause" }
  | { type: "set-muted"; muted: boolean }
  | { type: "loadstart" }
  | { type: "loaded"; duration: number }
  | { type: "set-current-time"; currentTime: number }
  | { type: "ended" }
  | { type: "reset" };

const initialState: VideoStateValue = {
  isPlaying: false,
  isMuted: true,
  isLoaded: false,
  duration: NaN,
  currentTime: 0,
};

function videoReducer(
  state: VideoStateValue,
  action: VideoAction,
): VideoStateValue {
  switch (action.type) {
    case "play":
      return state.isPlaying ? state : { ...state, isPlaying: true };
    case "pause":
      return state.isPlaying ? { ...state, isPlaying: false } : state;
    case "set-muted":
      return state.isMuted === action.muted
        ? state
        : { ...state, isMuted: action.muted };
    case "loadstart":
      return { ...initialState, isMuted: state.isMuted };
    case "loaded":
      return { ...state, isLoaded: true, duration: action.duration };
    case "set-current-time":
      return state.currentTime === action.currentTime
        ? state
        : { ...state, currentTime: action.currentTime };
    case "ended":
      return { ...state, isPlaying: false, currentTime: 0 };
    case "reset":
      return { ...initialState, isMuted: state.isMuted };
    default:
      return state;
  }
}

export interface UseVideoStateOptions {
  initialMuted: boolean;
  isActive: boolean;
  onPlay?: () => void;
  onPause?: () => void;
  onEnded?: () => void;
  onTimeUpdate?: (currentTime: number, duration: number) => void;
  onLoadedMetadata?: (duration: number) => void;
  onError?: (error: MediaError | null) => void;
}

export function useVideoState({
  initialMuted,
  isActive,
  onPlay,
  onPause,
  onEnded,
  onTimeUpdate,
  onLoadedMetadata,
  onError,
}: UseVideoStateOptions) {
  const [state, dispatch] = useReducer(videoReducer, {
    ...initialState,
    isMuted: initialMuted,
  });

  const videoElRef = useRef<HTMLVideoElement | null>(null);
  const videoRef = useCallback((node: HTMLVideoElement | null) => {
    videoElRef.current = node;
  }, []);

  // Stable refs for callbacks (avoid effect cascade on identity change).
  const onPlayRef = useRef(onPlay);
  const onPauseRef = useRef(onPause);
  const onEndedRef = useRef(onEnded);
  const onTimeUpdateRef = useRef(onTimeUpdate);
  const onLoadedMetadataRef = useRef(onLoadedMetadata);
  const onErrorRef = useRef(onError);
  useEffect(() => {
    onPlayRef.current = onPlay;
    onPauseRef.current = onPause;
    onEndedRef.current = onEnded;
    onTimeUpdateRef.current = onTimeUpdate;
    onLoadedMetadataRef.current = onLoadedMetadata;
    onErrorRef.current = onError;
  });

  // rAF cleanup on unmount.
  const rafRef = useRef<number | null>(null);
  useEffect(() => {
    return () => {
      if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
    };
  }, []);

  // Pause when isActive flips false. Hook owns video DOM, single dep.
  useEffect(() => {
    if (!isActive) videoElRef.current?.pause();
  }, [isActive]);

  // Memoized handlers — empty deps because all state is in stable refs.
  const handleVideoPlay = useCallback(() => {
    dispatch({ type: "play" });
    onPlayRef.current?.();
  }, []);

  const handleVideoPause = useCallback(() => {
    dispatch({ type: "pause" });
    onPauseRef.current?.();
  }, []);

  const handleVideoEnded = useCallback(() => {
    dispatch({ type: "ended" });
    onEndedRef.current?.();
  }, []);

  const handleVideoLoadStart = useCallback(() => {
    dispatch({ type: "loadstart" });
  }, []);

  const handleVideoLoadedMetadata = useCallback(() => {
    const el = videoElRef.current;
    if (!el) return;
    dispatch({ type: "loaded", duration: el.duration });
    onLoadedMetadataRef.current?.(el.duration);
  }, []);

  const handleVideoTimeUpdate = useCallback(() => {
    if (rafRef.current !== null) return;
    rafRef.current = requestAnimationFrame(() => {
      rafRef.current = null;
      const el = videoElRef.current;
      if (!el) return;
      dispatch({ type: "set-current-time", currentTime: el.currentTime });
      onTimeUpdateRef.current?.(el.currentTime, el.duration);
    });
  }, []);

  const handleVideoError = useCallback(
    (e: React.SyntheticEvent<HTMLVideoElement>) => {
      const el = e.currentTarget;
      onErrorRef.current?.(el.error);
    },
    [],
  );

  const handleVideoVolumeChange = useCallback(() => {
    const el = videoElRef.current;
    if (!el) return;
    dispatch({ type: "set-muted", muted: el.muted });
  }, []);

  const videoEventHandlers = useMemo(
    () => ({
      onPlay: handleVideoPlay,
      onPause: handleVideoPause,
      onEnded: handleVideoEnded,
      onLoadStart: handleVideoLoadStart,
      onLoadedMetadata: handleVideoLoadedMetadata,
      onTimeUpdate: handleVideoTimeUpdate,
      onError: handleVideoError,
      onVolumeChange: handleVideoVolumeChange,
    }),
    [
      handleVideoPlay,
      handleVideoPause,
      handleVideoEnded,
      handleVideoLoadStart,
      handleVideoLoadedMetadata,
      handleVideoTimeUpdate,
      handleVideoError,
      handleVideoVolumeChange,
    ],
  );

  const togglePlay = useCallback(() => {
    const el = videoElRef.current;
    if (!el) return;
    if (el.paused) {
      void el.play().catch(() => {
        // Browser autoplay policy may reject; user gesture is required.
      });
    } else {
      el.pause();
    }
  }, []);

  const toggleMute = useCallback(() => {
    const el = videoElRef.current;
    if (!el) return;
    el.muted = !el.muted;
    // onVolumeChange event will fire and dispatch set-muted.
  }, []);

  return { state, videoRef, togglePlay, toggleMute, videoEventHandlers };
}
```

### `src/registry/components/media/video-player/hooks/use-double-tap.ts`

```ts
import { useCallback, useRef } from "react";

export interface UseDoubleTapOptions {
  /** Time window in ms within which a second tap counts as a double-tap. Default: 300. */
  windowMs?: number;
}

/**
 * Returns a click handler that fires `onDoubleTap` only when invoked twice
 * within `windowMs` of each other. Single-tap is silent. After firing, the
 * timestamp resets so a triple-tap doesn't fire again.
 *
 * Reusable outside `<video>` contexts — image double-tap-to-like, card
 * double-tap-to-favorite, etc.
 */
export function useDoubleTap(
  onDoubleTap: (() => void) | undefined,
  options?: UseDoubleTapOptions,
): React.MouseEventHandler<HTMLElement> {
  const lastTapRef = useRef(0);
  const windowMs = options?.windowMs ?? 300;

  return useCallback(() => {
    if (!onDoubleTap) return;
    const now = performance.now();
    if (now - lastTapRef.current < windowMs) {
      onDoubleTap();
      lastTapRef.current = 0;
    } else {
      lastTapRef.current = now;
    }
  }, [onDoubleTap, windowMs]);
}
```
