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 React ->

Installation

$ npx shadcn-vue@latest add https://uipkge.dev/r/vue/video.json
Named registry: npx shadcn-vue@latest add @uipkge/video Installs to: app/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 false optional
loop

Loop playback.

boolean false optional
muted

Muted audio.

boolean false optional
nativeControls

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

boolean false optional
playbackRate

Initial playback rate. Default 1.

number 1 optional
aspectRatio

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

string '16/9' optional
class HTMLAttributes['class'] optional

npm dependencies

Files installed (2)

  • app/components/ui/video/Video.vue 8 kB
    <script setup lang="ts">
    import { Maximize, Minimize, Pause, Play, RotateCcw, RotateCw, Volume2, VolumeX } from 'lucide-vue-next'
    import { computed, onBeforeUnmount, onMounted, ref, watch, type HTMLAttributes } from 'vue'
    import { cn } from '@/lib/utils'
    
    interface Props {
      /** 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
      class?: HTMLAttributes['class']
    }
    
    const props = withDefaults(defineProps<Props>(), {
      autoplay: false,
      loop: false,
      muted: false,
      nativeControls: false,
      playbackRate: 1,
      aspectRatio: '16/9',
    })
    
    const videoRef = ref<HTMLVideoElement | null>(null)
    const containerRef = ref<HTMLElement | null>(null)
    
    const playing = ref(false)
    const current = ref(0)
    const duration = ref(0)
    const volume = ref(props.muted ? 0 : 1)
    const isMuted = ref(props.muted)
    const isFullscreen = ref(false)
    const showControls = ref(true)
    let hideTimer: ReturnType<typeof setTimeout> | null = null
    
    function togglePlay() {
      const v = videoRef.value
      if (!v) return
      if (v.paused) v.play()
      else v.pause()
    }
    
    function onPlay() {
      playing.value = true
    }
    function onPause() {
      playing.value = false
    }
    function onTimeUpdate() {
      const v = videoRef.value
      if (!v) return
      current.value = v.currentTime
    }
    function onLoadedMetadata() {
      const v = videoRef.value
      if (!v) return
      duration.value = v.duration || 0
      v.playbackRate = props.playbackRate
      v.volume = volume.value
    }
    function onVolumeChange() {
      const v = videoRef.value
      if (!v) return
      volume.value = v.volume
      isMuted.value = v.muted
    }
    
    function seek(e: Event) {
      const v = videoRef.value
      if (!v) return
      const target = e.target as HTMLInputElement
      v.currentTime = Number(target.value)
    }
    
    function setVolume(e: Event) {
      const v = videoRef.value
      if (!v) return
      const target = e.target as HTMLInputElement
      v.volume = Number(target.value)
      v.muted = Number(target.value) === 0
    }
    
    function toggleMute() {
      const v = videoRef.value
      if (!v) return
      v.muted = !v.muted
    }
    
    function skip(seconds: number) {
      const v = videoRef.value
      if (!v) return
      v.currentTime = Math.min(Math.max(v.currentTime + seconds, 0), v.duration || 0)
    }
    
    function toggleFullscreen() {
      const el = containerRef.value
      if (!el) return
      if (document.fullscreenElement) {
        document.exitFullscreen()
      } else {
        el.requestFullscreen()
      }
    }
    
    function onFullscreenChange() {
      isFullscreen.value = !!document.fullscreenElement
    }
    
    function onMouseMove() {
      showControls.value = true
      if (hideTimer) clearTimeout(hideTimer)
      hideTimer = setTimeout(() => {
        if (playing.value) showControls.value = false
      }, 2500)
    }
    
    function onMouseLeave() {
      if (hideTimer) clearTimeout(hideTimer)
      if (playing.value) showControls.value = false
    }
    
    function 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 = computed(() => (duration.value > 0 ? (current.value / duration.value) * 100 : 0))
    
    watch(
      () => props.playbackRate,
      (rate) => {
        if (videoRef.value) videoRef.value.playbackRate = rate
      },
    )
    
    watch(
      () => props.muted,
      (m) => {
        if (videoRef.value) videoRef.value.muted = m
      },
    )
    
    onMounted(() => {
      document.addEventListener('fullscreenchange', onFullscreenChange)
    })
    
    onBeforeUnmount(() => {
      document.removeEventListener('fullscreenchange', onFullscreenChange)
      if (hideTimer) clearTimeout(hideTimer)
    })
    </script>
    
    <template>
      <div
        ref="containerRef"
        data-uipkge
        data-slot="video"
        :class="cn('group relative overflow-hidden rounded-lg bg-black', props.class)"
        :style="{ aspectRatio: aspectRatio }"
        @mousemove="onMouseMove"
        @mouseleave="onMouseLeave"
      >
        <video
          ref="videoRef"
          data-slot="video-element"
          class="size-full object-contain"
          :src="src"
          :poster="poster"
          :autoplay="autoplay"
          :loop="loop"
          :muted="muted"
          :controls="nativeControls"
          playsinline
          @play="onPlay"
          @pause="onPause"
          @timeupdate="onTimeUpdate"
          @loadedmetadata="onLoadedMetadata"
          @volumechange="onVolumeChange"
          @click="togglePlay"
        />
    
        <!-- Custom controls overlay -->
        <div
          v-if="!nativeControls"
          data-slot="video-controls"
          :class="
            cn(
              'absolute inset-0 flex flex-col justify-between transition-opacity duration-200',
              showControls || !playing ? 'opacity-100' : 'opacity-0',
            )
          "
        >
          <!-- Top spacer / click target -->
          <div class="flex-1" @click="togglePlay" />
    
          <!-- Bottom controls bar -->
          <div class="bg-gradient-to-t from-black/80 to-transparent px-3 pt-6 pb-2">
            <!-- Progress bar -->
            <div class="mb-2 flex items-center gap-2">
              <span class="text-xs text-white/80 tabular-nums">{{ formatTime(current) }}</span>
              <input
                type="range"
                min="0"
                :max="duration || 0"
                step="0.1"
                :value="current"
                class="accent-primary [&::-webkit-slider-thumb]:bg-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"
                @input="seek"
              />
              <span class="text-xs text-white/80 tabular-nums">{{ formatTime(duration) }}</span>
            </div>
    
            <!-- Buttons row -->
            <div class="flex items-center gap-2">
              <button
                type="button"
                class="text-white/90 transition-colors hover:text-white"
                :aria-label="playing ? 'Pause' : 'Play'"
                @click="togglePlay"
              >
                <component :is="playing ? Pause : Play" class="size-5" />
              </button>
              <button
                type="button"
                class="text-white/90 transition-colors hover:text-white"
                aria-label="Rewind 10s"
                @click="skip(-10)"
              >
                <RotateCcw class="size-4" />
              </button>
              <button
                type="button"
                class="text-white/90 transition-colors hover:text-white"
                aria-label="Forward 10s"
                @click="skip(10)"
              >
                <RotateCw class="size-4" />
              </button>
    
              <!-- Volume -->
              <div class="group/volume flex items-center gap-1.5">
                <button
                  type="button"
                  class="text-white/90 transition-colors hover:text-white"
                  :aria-label="isMuted ? 'Unmute' : 'Mute'"
                  @click="toggleMute"
                >
                  <component :is="isMuted || volume === 0 ? VolumeX : Volume2" class="size-4" />
                </button>
                <input
                  type="range"
                  min="0"
                  max="1"
                  step="0.05"
                  :value="isMuted ? 0 : volume"
                  class="accent-primary [&::-webkit-slider-thumb]:bg-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"
                  @input="setVolume"
                />
              </div>
    
              <div class="flex-1" />
    
              <button
                type="button"
                class="text-white/90 transition-colors hover:text-white"
                :aria-label="isFullscreen ? 'Exit fullscreen' : 'Fullscreen'"
                @click="toggleFullscreen"
              >
                <component :is="isFullscreen ? Minimize : Maximize" class="size-4" />
              </button>
            </div>
          </div>
        </div>
      </div>
    </template>
  • app/components/ui/video/index.ts 0 kB
    export { default as Video } from './Video.vue'

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