How to Add Video Playback to Vue Apps
Add video playback to Vue apps with native HTML5 video, reusable components, or Video.js for HLS, captions, autoplay, and cleanup.
There are three ways to add video playback to a Vue app, and the right one depends on how much control you need: use the native HTML5 <video> element for a simple embedded clip, reach for Video.js when you need adaptive streaming, captions, or broad format support, and drop in a ready-made component like @videojs-player/vue when you want Video.js features without wiring the player yourself.
The clip usually plays fine on the machine you built it on. Then a tester opens the page on an iPhone and it jumps to fullscreen, or autoplay quietly does nothing in Chrome and nobody can tell why, so this guide walks all three approaches with copy-pasteable Vue 3 <script setup> code and covers the failures that only show up on someone else’s device.
Key Takeaways
- For a simple embedded clip with default controls, the native HTML5
<video>element bound with:src,controls, andposterneeds no library at all. - In Vue 3 with
<script setup>, grab the element with a templateref()and call the native media API onvideoRef.valueinsideonMounted, never viadocument.querySelector. - Video.js renders unstyled unless you import
video.js/dist/video-js.css, the single most common reason a Video.js-in-Vue player looks broken. - Whenever you initialize Video.js, call
player.dispose()inonBeforeUnmount(Vue 3). Skipping it leaks the player and its DOM on every unmount. - Browsers block autoplay with sound: to autoplay reliably you must set both
mutedandautoplay, and addplaysinlineso iOS Safari plays inline instead of forcing fullscreen.
How do you choose a Vue video player approach?
Pick native <video> when you don’t need custom UI or adaptive streaming; pick a custom wrapper when you want bespoke controls but full ownership; pick Video.js or its component wrapper when you need HLS, captions, or many formats. These are the three criteria that decide: do you need custom UI, do you need adaptive streaming (HLS/DASH), and how many dependencies can you tolerate.
| Approach | Extra deps | Custom UI effort | HLS / captions | Pick this when |
|---|---|---|---|---|
Native <video> | None | You build every control | Native HLS only (Safari) | Simple clip, minimal bundle |
| Custom wrapper component | None | Full control via slots | Native HLS only | Bespoke UI, reused across the app |
Video.js / @videojs-player/vue | video.js (+ wrapper) | Skin or override | Yes, built in | Format breadth, streaming, tracks |
Discover how at OpenReplay.com.
Native HTML5 <video> in Vue (start here)
The fastest way to add video playback in Vue is a native <video> element with props bound via :src, poster, and preload, plus a template ref() to call the HTMLMediaElement API: play(), pause(), and .muted. In Vue 3 <script setup>, bind native media events like timeupdate, loadedmetadata, and ended directly in the template so Vue attaches and removes the listeners for you.
<script setup>
import { ref } from 'vue'
const videoRef = ref(null)
const playing = ref(false)
const muted = ref(false)
const current = ref(0)
const duration = ref(0)
function togglePlay() {
const el = videoRef.value
el.paused ? el.play() : el.pause()
}
function toggleMute() {
const el = videoRef.value
el.muted = !el.muted
muted.value = el.muted
}
</script>
<template>
<video
ref="videoRef"
src="/media/clip.mp4"
poster="/media/poster.jpg"
preload="metadata"
playsinline
@play="playing = true"
@pause="playing = false"
@ended="playing = false"
@loadedmetadata="duration = $event.target.duration"
@timeupdate="current = $event.target.currentTime"
/>
<div>
<button @click="togglePlay">{{ playing ? 'Pause' : 'Play' }}</button>
<button @click="toggleMute">{{ muted ? 'Unmute' : 'Mute' }}</button>
<span>{{ current.toFixed(0) }}s / {{ duration.toFixed(0) }}s</span>
</div>
</template>
The template ref="videoRef" resolves to the DOM element on videoRef.value. Access it inside handlers or onMounted, not before the component mounts. In the Options API the equivalents are this.$refs.videoRef and the mounted hook. If you attach listeners manually with addEventListener, remove them in onUnmounted; binding through @event in the template avoids that class of leak entirely.
Reusable custom player component
To reuse player logic across an app, wrap the native <video> in a component that exposes its controls and state through a scoped slot, so each usage composes its own buttons and track without duplicating the playback wiring.
<!-- VideoPlayer.vue -->
<script setup>
import { ref } from 'vue'
defineProps({ src: String, poster: String })
const emit = defineEmits(['timeupdate', 'ended'])
const videoRef = ref(null)
const playing = ref(false)
function togglePlay() {
const el = videoRef.value
el.paused ? el.play() : el.pause()
}
</script>
<template>
<video
ref="videoRef"
:src="src"
:poster="poster"
playsinline
@play="playing = true"
@pause="playing = false"
@timeupdate="emit('timeupdate', $event.target.currentTime)"
@ended="emit('ended')"
/>
<slot name="controls" :playing="playing" :toggle-play="togglePlay" />
</template>
Consumers pull playing and togglePlay out of the slot props and render whatever UI they need. One base player can back a minimal play-only embed and a full player with a progress track, keeping the media logic in a single place.
<VideoPlayer src="/media/clip.mp4" @ended="onEnded">
<template #controls="{ playing, togglePlay }">
<button @click="togglePlay">{{ playing ? 'Pause' : 'Play' }}</button>
</template>
</VideoPlayer>
When should you use Video.js?
Use Video.js when you need HLS/adaptive streaming, captions and text tracks, a consistent skin, or format breadth beyond what native <video> guarantees. Install video.js, render a <video class="video-js">, initialize videojs(ref, options) in onMounted, and import the stylesheet and dispose the player on unmount.
<script setup>
import { ref, onMounted, onBeforeUnmount } from 'vue'
import videojs from 'video.js'
import 'video.js/dist/video-js.css' // required, or the player renders unstyled
const videoRef = ref(null)
let player = null
const options = {
autoplay: false,
controls: true,
preload: 'auto',
fluid: true,
sources: [{ src: '/media/clip.mp4', type: 'video/mp4' }]
}
onMounted(() => {
player = videojs(videoRef.value, options)
})
onBeforeUnmount(() => {
if (player) player.dispose()
})
</script>
<template>
<video ref="videoRef" class="video-js" playsinline />
</template>
Two things are easy to get wrong here: the template ref name must match what you read in onMounted (a mismatched videoJsPlayer/videoPlayer pair throws), and dispose() belongs in onBeforeUnmount for Vue 3. beforeDestroy is a Vue 2 hook, and Vue 2 reached end of life on December 31, 2023. The current stable line is Video.js 8.x, with 8.24.0 released in August 2026. A modular Video.js 10 is in beta and not yet generally available: the v10 repository timeline still lists general availability as work in progress, with Video.js core and contrib parity targeted for the end of 2026. Stay on 8.x for production and keep the install unpinned: npm install video.js.
Ready-made component: @videojs-player/vue
The least-code path to Video.js in Vue 3 is @videojs-player/vue, a drop-in <video-player> with reactive props (src, sources, poster, controls, loop, volume, fluid, playsinline, tracks) and a { player, state } scoped slot for custom controls. It handles initialization and teardown internally, so an HLS clip is a one-liner source swap.
<script setup>
import { VideoPlayer } from '@videojs-player/vue'
import 'video.js/dist/video-js.css'
</script>
<template>
<video-player
src="https://example.com/stream/playlist.m3u8"
poster="/media/poster.jpg"
controls
fluid
playsinline
:volume="0.6"
@ready="(payload) => console.log(payload.state)"
>
<template #default="{ player, state }">
<button @click="state.playing ? player.pause() : player.play()">
{{ state.playing ? 'Pause' : 'Play' }}
</button>
</template>
</video-player>
</template>
One caveat worth knowing before you adopt it: this is the standard Vue 3 wrapper, but its npm release has stalled at v1.0.0, published in 2022, and dependency scanners flag it as low-maintenance. Its published peer-dependency range still asks for video.js 7.x, so pairing it with Video.js 8 produces a peer-dependency warning at install time. If you’re on Vue 2, use the legacy vue-video-player build linked from the repo’s legacy section. Current releases of @videojs-player/vue target Vue 3 only.
Vue-specific gotchas checklist
Most “video won’t play” bugs in Vue trace to a handful of platform rules that pass code review but fail on real devices. Autoplay and playsinline failures in particular are invisible until a user hits them, which is exactly the class of device-specific problem session replay surfaces from real sessions.
- Clean up on unmount. Call
player.dispose()for Video.js and remove any manualaddEventListenerhandlers inonUnmounted, or each mount/unmount cycle leaks. - Import the CSS.
import 'video.js/dist/video-js.css'or the player is unstyled. - Muted autoplay is mandatory. Browsers block autoplay with sound; set both
mutedandautoplay, per the Chrome autoplay policy. playsinlinefor iOS. Without theplaysinlineattribute, iOS Safari on iPhone will usually open the video fullscreen; iPad behaves differently.- Use template refs, not DOM queries. Reach the element through
ref(), neverdocument.querySelector.
Start native: a bound <video> ships playback in minutes with zero dependencies. Step up to Video.js or @videojs-player/vue the moment you need HLS, captions, or a consistent cross-browser UI, and wire the dispose() cleanup in from the first commit so it never becomes a leak you hunt later.
FAQs
How do I play an HLS (.m3u8) stream in a Vue app?
Native HTML5 video plays HLS only in Safari, so for cross-browser HLS use Video.js or its wrapper @videojs-player/vue, which bundles HLS support through the videojs-http-streaming engine. With @videojs-player/vue you set the src to your .m3u8 playlist URL and the component handles the streaming engine internally. In plain Chrome or Firefox, native video has no built-in HLS, which is the main reason to reach for Video.js.
Why won't autoplay work in my Vue video player?
Muted autoplay is generally allowed, but autoplay with sound needs the user to have interacted with the site first, and Chrome also permits it when the site has a high Media Engagement Index score. Safari applies its own similar policy. To autoplay reliably you must set both muted and autoplay on the element or in the Video.js options. This failure is invisible in code review because it depends on browser policy and device, not on your markup, so it only surfaces when a real user loads the page.
What is the difference between @videojs-player/vue and using Video.js directly?
@videojs-player/vue is a drop-in Vue 3 component that wraps Video.js, handling initialization and teardown internally and exposing reactive props plus a player and state scoped slot, whereas using Video.js directly means you render a video element, call videojs() in onMounted, and dispose it yourself in onBeforeUnmount. The wrapper needs less code but its npm release has stalled at v1.0.0 from 2022, so direct Video.js gives you more control over versions and lifecycle.
Can I still use @videojs-player/vue with Vue 2?
No. Current releases of @videojs-player/vue target Vue 3 only. The package took its current name when React support was added, and that switch was a breaking change for Vue users. For Vue 2 you need the older vue-video-player 5.x build, which the repository still links from its legacy section. Vue 2 itself reached end of life on December 31, 2023, so new work should target Vue 3.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k