UIPackage
Menu

Video

video ui
Edit on GitHub

Video player wrapper with a custom controls overlay on a native video element. Supports src, poster, autoplay, loop, muted, custom or native controls, playback rate, fullscreen, time display, a progress bar, play/pause, skip, and volume control.

Also available for Vue ->

Installation

$ npx shadcn@latest add https://uipkge.dev/r/react/video.json
Named registry: npx shadcn@latest add @uipkge-react/video Installs to: components/ui/video/

Examples

Props

Name Type / Values Default Required
src

Video source URL.

string required
poster

Poster image shown before playback.

string optional
autoplay

Autoplay on mount. Note: browsers may block autoplay with sound.

boolean optional
loop

Loop playback.

boolean optional
muted

Muted audio.

boolean optional
nativeControls

Use native browser controls instead of the custom overlay. Default false.

boolean optional
playbackRate

Initial playback rate. Default 1.

number optional
aspectRatio

Aspect ratio class or inline style value. Default '16/9'.

string optional

npm dependencies

Files installed (2)

  • components/ui/video/Video.tsx 9.8 kB
    import * as React from 'react'
    import { Maximize, Minimize, Pause, Play, RotateCcw, RotateCw, Volume2, VolumeX } from 'lucide-react'
    import { cn } from '@/lib/utils'
    
    export interface VideoProps extends React.HTMLAttributes<HTMLDivElement> {
      /** Video source URL. */
      src: string
      /** Poster image shown before playback. */
      poster?: string
      /** Autoplay on mount. Note: browsers may block autoplay with sound. */
      autoplay?: boolean
      /** Loop playback. */
      loop?: boolean
      /** Muted audio. */
      muted?: boolean
      /** Use native browser controls instead of the custom overlay. Default false. */
      nativeControls?: boolean
      /** Initial playback rate. Default 1. */
      playbackRate?: number
      /** Aspect ratio class or inline style value. Default '16/9'. */
      aspectRatio?: string
    }
    
    const Video = React.forwardRef<HTMLDivElement, VideoProps>(
      (
        {
          src,
          poster,
          autoplay = false,
          loop = false,
          muted = false,
          nativeControls = false,
          playbackRate = 1,
          aspectRatio = '16/9',
          className,
          ...props
        },
        ref,
      ) => {
        const videoRef = React.useRef<HTMLVideoElement | null>(null)
        const containerRef = React.useRef<HTMLDivElement | null>(null)
    
        const [playing, setPlaying] = React.useState(false)
        const [current, setCurrent] = React.useState(0)
        const [duration, setDuration] = React.useState(0)
        const [volume, setVolume] = React.useState(muted ? 0 : 1)
        const [isMuted, setIsMuted] = React.useState(muted)
        const [isFullscreen, setIsFullscreen] = React.useState(false)
        const [showControls, setShowControls] = React.useState(true)
        const hideTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
    
        React.useImperativeHandle(ref, () => containerRef.current as HTMLDivElement)
    
        const togglePlay = React.useCallback(() => {
          const v = videoRef.current
          if (!v) return
          if (v.paused) v.play()
          else v.pause()
        }, [])
    
        const onPlay = React.useCallback(() => setPlaying(true), [])
        const onPause = React.useCallback(() => setPlaying(false), [])
        const onTimeUpdate = React.useCallback(() => {
          const v = videoRef.current
          if (!v) return
          setCurrent(v.currentTime)
        }, [])
        const onLoadedMetadata = React.useCallback(() => {
          const v = videoRef.current
          if (!v) return
          setDuration(v.duration || 0)
          v.playbackRate = playbackRate
          v.volume = volume
        }, [playbackRate, volume])
        const onVolumeChange = React.useCallback(() => {
          const v = videoRef.current
          if (!v) return
          setVolume(v.volume)
          setIsMuted(v.muted)
        }, [])
    
        const seek = (e: React.ChangeEvent<HTMLInputElement>) => {
          const v = videoRef.current
          if (!v) return
          v.currentTime = Number(e.target.value)
        }
    
        const setVolumeInput = (e: React.ChangeEvent<HTMLInputElement>) => {
          const v = videoRef.current
          if (!v) return
          v.volume = Number(e.target.value)
          v.muted = Number(e.target.value) === 0
        }
    
        const toggleMute = () => {
          const v = videoRef.current
          if (!v) return
          v.muted = !v.muted
        }
    
        const skip = (seconds: number) => {
          const v = videoRef.current
          if (!v) return
          v.currentTime = Math.min(Math.max(v.currentTime + seconds, 0), v.duration || 0)
        }
    
        const toggleFullscreen = () => {
          const el = containerRef.current
          if (!el) return
          if (document.fullscreenElement) {
            document.exitFullscreen()
          } else {
            el.requestFullscreen()
          }
        }
    
        const onFullscreenChange = React.useCallback(() => {
          setIsFullscreen(!!document.fullscreenElement)
        }, [])
    
        const onMouseMove = () => {
          setShowControls(true)
          if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
          hideTimerRef.current = setTimeout(() => {
            setPlaying((p) => {
              if (p) setShowControls(false)
              return p
            })
          }, 2500)
        }
    
        const onMouseLeave = () => {
          if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
          if (videoRef.current && !videoRef.current.paused) setShowControls(false)
        }
    
        const formatTime = (s: number): string => {
          if (!s || !isFinite(s)) return '0:00'
          const m = Math.floor(s / 60)
          const sec = Math.floor(s % 60)
          return `${m}:${sec.toString().padStart(2, '0')}`
        }
    
        const progress = duration > 0 ? (current / duration) * 100 : 0
    
        React.useEffect(() => {
          document.addEventListener('fullscreenchange', onFullscreenChange)
          return () => {
            document.removeEventListener('fullscreenchange', onFullscreenChange)
            if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
          }
        }, [onFullscreenChange])
    
        React.useEffect(() => {
          const v = videoRef.current
          if (v) v.playbackRate = playbackRate
        }, [playbackRate])
    
        React.useEffect(() => {
          const v = videoRef.current
          if (v) v.muted = muted
        }, [muted])
    
        return (
          <div
            ref={containerRef}
            data-uipkge=""
            data-slot="video"
            className={cn('group relative overflow-hidden rounded-lg bg-black', className)}
            style={{ aspectRatio }}
            onMouseMove={onMouseMove}
            onMouseLeave={onMouseLeave}
            {...props}
          >
            <video
              ref={videoRef}
              data-slot="video-element"
              className="size-full object-contain"
              src={src}
              poster={poster}
              autoPlay={autoplay}
              loop={loop}
              muted={muted}
              controls={nativeControls}
              playsInline
              onPlay={onPlay}
              onPause={onPause}
              onTimeUpdate={onTimeUpdate}
              onLoadedMetadata={onLoadedMetadata}
              onVolumeChange={onVolumeChange}
              onClick={togglePlay}
            />
    
            {!nativeControls && (
              <div
                data-slot="video-controls"
                className={cn(
                  'absolute inset-0 flex flex-col justify-between transition-opacity duration-200',
                  showControls || !playing ? 'opacity-100' : 'opacity-0',
                )}
              >
                {/* Top spacer / click target */}
                <div className="flex-1" onClick={togglePlay} />
    
                {/* Bottom controls bar */}
                <div className="bg-gradient-to-t from-black/80 to-transparent px-3 pt-6 pb-2">
                  {/* Progress bar */}
                  <div className="mb-2 flex items-center gap-2">
                    <span className="text-xs text-white/80 tabular-nums">{formatTime(current)}</span>
                    <input
                      type="range"
                      min="0"
                      max={duration || 0}
                      step="0.1"
                      value={current}
                      className="accent-primary h-1 flex-1 cursor-pointer appearance-none rounded-full bg-white/30 [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary"
                      onChange={seek}
                    />
                    <span className="text-xs text-white/80 tabular-nums">{formatTime(duration)}</span>
                  </div>
    
                  {/* Buttons row */}
                  <div className="flex items-center gap-2">
                    <button
                      type="button"
                      className="text-white/90 transition-colors hover:text-white"
                      aria-label={playing ? 'Pause' : 'Play'}
                      onClick={togglePlay}
                    >
                      {playing ? <Pause className="size-5" /> : <Play className="size-5" />}
                    </button>
                    <button
                      type="button"
                      className="text-white/90 transition-colors hover:text-white"
                      aria-label="Rewind 10s"
                      onClick={() => skip(-10)}
                    >
                      <RotateCcw className="size-4" />
                    </button>
                    <button
                      type="button"
                      className="text-white/90 transition-colors hover:text-white"
                      aria-label="Forward 10s"
                      onClick={() => skip(10)}
                    >
                      <RotateCw className="size-4" />
                    </button>
    
                    {/* Volume */}
                    <div className="group/volume flex items-center gap-1.5">
                      <button
                        type="button"
                        className="text-white/90 transition-colors hover:text-white"
                        aria-label={isMuted ? 'Unmute' : 'Mute'}
                        onClick={toggleMute}
                      >
                        {isMuted || volume === 0 ? <VolumeX className="size-4" /> : <Volume2 className="size-4" />}
                      </button>
                      <input
                        type="range"
                        min="0"
                        max="1"
                        step="0.05"
                        value={isMuted ? 0 : volume}
                        className="accent-primary h-1 w-0 cursor-pointer appearance-none rounded-full bg-white/30 transition-all duration-200 group-hover/volume:w-16 [&::-webkit-slider-thumb]:size-3 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary"
                        onChange={setVolumeInput}
                      />
                    </div>
    
                    <div className="flex-1" />
    
                    <button
                      type="button"
                      className="text-white/90 transition-colors hover:text-white"
                      aria-label={isFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
                      onClick={toggleFullscreen}
                    >
                      {isFullscreen ? <Minimize className="size-4" /> : <Maximize className="size-4" />}
                    </button>
                  </div>
                </div>
              </div>
            )}
          </div>
        )
      },
    )
    Video.displayName = 'Video'
    
    export { Video }
  • components/ui/video/index.ts 0 kB
    export { Video, type VideoProps } from './Video'

Raw manifest: https://uipkge.dev/r/react/video.json