Migrate from Plyr
Move an existing Plyr integration to Video.js v10, mapping Plyr options and its instance API onto components and player state
Video.js v10 is not a drop-in replacement for Plyr. Plyr wraps one media element with a constructor and an options object; Video.js v10 is built a bit differently. The player is composed, not configured. That means the player you ship is smaller and faster, and when you’re ready to customize it, it’s easy to go under the hood and build what you need. It also means there’s a bit more set-up to get started.
The migration is mostly moving configuration into markup and replacing Plyr’s instance API with native media APIs or Video.js player state.
Why switch?
Plyr is simple and familiar, so the reason to migrate should be practical. These are the strongest wins when your app can use HTML5, HLS, DASH, Vimeo, YouTube, or Mux-backed media. We’ve taken our vast experience building some of the best players and thrown it all into Video.js v10.
- ♿ Accessibility starts in the component model. Plyr has long-running reports around WCAG compliance, slider semantics, keyboard behavior, focus visibility, and menu roles (#905, #103). Video.js v10 exposes buttons, sliders, menus, radio groups, and tooltips as focused components with state attributes and accessibility behavior built into each part.
- 📺 Streaming controls are first-class media state. Streaming was an afterthought in Plyr. HLS quality switching is one of Plyr’s most-requested gaps (#1741, #218). Video.js v10 models HLS, DASH, and Mux-backed media directly, so quality UI can read rendition state instead of parsing manifests and wiring hls.js in application code.
- 🧩 Composition keeps player code cleaner. Players, media elements, skins, controls, gestures, and hotkeys are separate pieces. You can replace one part without wrapping or forking the whole player.
- ✨ Two polished skins are ready to use. Minimal is the closest starting point for Plyr migrations, while Default gives you a modern, frosted design. Both can be ejected when you want to customize the layout, styling, or icons.
- 📡 Remote playback is part of the built-in video UI. Plyr has an open Google Cast request dating back years (#112). Video.js v10 includes Cast and AirPlay controls through a shared remote playback feature.
- ⚛️ React gets a native API. Plyr integrations in React tend to wrap an imperative constructor around framework-owned DOM (#254), and lifecycle cleanup can get awkward when routes or components unmount (#1001).
@videojs/reactgives you players, components, hooks, and TypeScript types instead. - 🎛️ Layout and skins are easier to own. Plyr’s iframe and aspect-ratio behavior has produced workarounds for cropping and fullscreen sizing (#339). Video.js v10 separates the player, container, media, and skin, so sizing belongs to the container or skin and the UI can be ejected when you need full control.
- ⌨️ Input behavior is explicit. Plyr has recurring mobile tap and fullscreen threads (#718, #1190). Video.js v10 has common gestures and hotkey support built into the skins, and you can eject to customize them.
Basic migration
Let’s start with the most basic example of getting Plyr up and running and replace it with a Video.js v10 player.
This snippet will render a basic video player with thumbnail previews, captions and a poster image.
<link rel="stylesheet" href="path/to/plyr.css" />
<video id="player" src="/path/to/video.mp4" playsinline controls data-poster="/path/to/poster.jpg">
<track kind="captions" label="English" src="/path/to/captions/en.vtt" srclang="en" default />
</video>
<script src="https://cdn.plyr.io/3.8.4/plyr.js"></script>
<script>
const player = new Plyr('#player', {
previewThumbnails: {
enabled: true,
src: '/path/to/storyboard.vtt',
},
});
</script>The closest Video.js skin to the Plyr skin is the Minimal skin, so we’ll use that for this example.
The easiest path is the CDN:
<script type="module" src="https://cdn.jsdelivr.net/npm/@videojs/html/cdn/video-minimal.js"></script>
<video-player>
<video-minimal-skin>
<video src="/path/to/video.mp4" playsinline>
<track kind="captions" label="English" src="/path/to/captions/en.vtt" srclang="en" default />
<track kind="metadata" label="thumbnails" src="/path/to/storyboard.vtt" default />
</video>
<img slot="poster" src="/path/to/poster.jpg" alt="Video poster" />
</video-minimal-skin>
</video-player>If you prefer not to use the CDN, you can use the individual modules:
import '@videojs/html/video/player';
import '@videojs/html/video/minimal-skin';
import '@videojs/html/video/minimal-skin.css';Notes
- CSS styles are injected automatically into the Shadow DOM we create for the player.
- The poster is a slotted
imgrather than aposterattribute, which gives the skin control over how it appears. That’s much like Plyr’sdata-poster. - There’s also a default skin with a modern, frosted appearance that some may prefer. Try it by removing the
-minimalsuffix from the scriptsrcif you’re using the CDN, or theminimal-prefix from the imports.
First, install the dependency:
npm install @videojs/reactThen create a reusable player component in your app:
'use client';
import '@videojs/react/video/minimal-skin.css';
import { MinimalVideoSkin, Video, VideoPlayer } from '@videojs/react/video';
interface AppVideoPlayerProps {
origin: string;
}
export function AppVideoPlayer({ origin }: AppVideoPlayerProps) {
return (
<VideoPlayer>
<MinimalVideoSkin poster={`${origin}/poster.jpg`}>
<Video src={`${origin}/video.mp4`} playsInline>
<track kind="captions" label="English" src={`${origin}/captions/en.vtt`} srclang="en" default />
<track kind="metadata" label="thumbnails" src={`${origin}/storyboard.vtt`} default />
</Video>
</MinimalVideoSkin>
</VideoPlayer>
);
}Notes
@videojs/react/videois a preset: a player, a skin, and a media element that already fit together.VideoPlayeris the piece that owns the state, built from the standard set of features for video.- The poster is a prop on the skin rather than a
posterattribute on the media, which gives the skin control over how it appears. That’s much like Plyr’sdata-poster. - This example assumes all your files for a given asset live in the same path, using consistent filenames. This will almost certainly need adjusting for your implementation.
- There’s also a default skin with a modern, frosted appearance that some may prefer. Try it by switching the CSS import filename to
skin.cssand theMinimalVideoSkinimport and usage toVideoSkin.
Advanced migration
Streaming
If you’re using HLS or DASH to stream your media, you’re in luck; we have prebuilt media components you can slot in with much improved integration over Plyr implementations.
HLS
Replace <video> with <hls-video>. If you’re using the CDN, add an additional script:
<script type="module" src="https://cdn.jsdelivr.net/npm/@videojs/html/cdn/media/hls-video.js"></script>Or add an additional import:
import '@videojs/html/media/hls-video';Replace <Video> with <HlsVideo> and add the import:
import { HlsVideo } from '@videojs/react/media/hls-video';Set the src to the URL for the m3u8 manifest.
DASH
Very similar to HLS in that we have a drop-in component.
Replace <video> with <dash-video>. If you’re using the CDN, add an additional script:
<script type="module" src="https://cdn.jsdelivr.net/npm/@videojs/html/cdn/media/dash-video.js"></script>Or add an additional import:
import '@videojs/html/media/dash-video';Replace <Video> with <DashVideo> and update your import:
import { DashVideo } from '@videojs/react/media/dash-video';Set the src to the URL for the mpd manifest.
Vimeo
Vimeo is supported through a prebuilt component.
Replace <video> with <vimeo-video> and add the module import:
import '@videojs/html/media/vimeo-video';Replace <Video> with <VimeoVideo> and add the import:
import { VimeoVideo } from '@videojs/react/media/vimeo-video';Set the src to the URL for the Vimeo video, for example https://vimeo.com/648359100.
YouTube
YouTube is supported through a first-class media component.
Replace <video> with <youtube-video> and add the module import:
import '@videojs/html/media/youtube-video';<video-player>
<video-minimal-skin>
<youtube-video src="https://youtu.be/aqz-KE-bpKQ" playsinline></youtube-video>
</video-minimal-skin>
</video-player>Replace <Video> with <YouTubeVideo> and add the import:
import { YouTubeVideo } from '@videojs/react/media/youtube-video';<VideoPlayer>
<MinimalVideoSkin>
<YouTubeVideo src="https://youtu.be/aqz-KE-bpKQ" playsInline />
</MinimalVideoSkin>
</VideoPlayer>The component accepts YouTube watch, short, embed, Shorts, live, playlist, and privacy-enhanced URLs, as well as raw 11-character video IDs.
Internationalization
Video.js v10 ships with English labels by default and includes locale packs for:
ar, az, bg, bn, bs, ca, cs, cy, da, de, el, es, et, eu, fa, fi, fr, gd, gl, he, hi, hr, hu, id, it, ja, ko, lt, lv, mr, nb, ne, nl, nn, oc, pl, pt-BR, pt-PT, ro, ru, sk, sl, sr, sv, te, th, tr, uk, vi, zh-CN, and zh-TW.
The shorthand tags pt and zh are also available as aliases. See Internationalization for the full picture.
For CDN users:
<script type="module">
import { registerI18n } from 'https://cdn.jsdelivr.net/npm/@videojs/html/cdn/i18n.js';
registerI18n('en', {
buttons: {
play: 'Start video',
pause: 'Pause video',
},
menu: {
settings: 'Options',
},
});
</script>
<script type="module" src="https://cdn.jsdelivr.net/npm/@videojs/html/cdn/video-minimal.js"></script>
<video-player>
<video-minimal-skin>
<video src="/video.mp4" playsinline></video>
</video-minimal-skin>
</video-player>For package users, same idea but import from @videojs/html/i18n.
Use the React provider when you want scoped overrides:
'use client';
import '@videojs/react/video/minimal-skin.css';
import { I18nProvider } from '@videojs/react/i18n';
import { MinimalVideoSkin, Video, VideoPlayer } from '@videojs/react/video';
export function MyPlayer() {
return (
<VideoPlayer>
<I18nProvider
locale="en"
translations={{
buttons: {
play: 'Start video',
pause: 'Pause video',
},
menu: {
settings: 'Options',
},
}}
>
<MinimalVideoSkin>
<Video src="/video.mp4" playsInline />
</MinimalVideoSkin>
</I18nProvider>
</VideoPlayer>
);
}Configuration
Plyr uses an object to set configuration options whereas Video.js v10 uses a component structure and attributes instead. We’re using a composition model rather than a configuration model. This reduces bundle size and only includes functionality you actually require.
Here’s a matrix for configuration options in Plyr and how each maps to Video.js v10:
| Plyr option | Video.js v10 |
|---|---|
controls |
The skins include all the common controls, laid out in a familiar way that users would expect. If you want to customize the skin beyond basic colors, you can eject the skin and change layout, styles, or icons. |
settings |
Included automatically in the skins when quality, speed, audio tracks, or captions are available. |
autoplay, muted, loop, playsinline, preload |
These are attributes on your media (e.g. <video>) component. |
poster / data-poster |
The skin owns the poster, as shown in Basic migration above. |
ratio |
Set aspect-ratio in CSS on the skin component. |
hideControls |
Preset skins auto-hide controls based on activity. The delay is currently not configurable. |
clickToPlay |
Preset video skins include click and tap gestures. Eject the skin to remove or change them. |
keyboard |
Preset video skins include common hotkeys. Eject the skin to remove or change them. |
tooltips |
Preset skins include tooltips for common controls. Eject the skin to customize them. |
captions |
Add <track kind="captions"> or <track kind="subtitles">; preset skins show captions controls when tracks are available. |
previewThumbnails |
Add <track kind="metadata" label="thumbnails">; preset video skins show slider thumbnails when thumbnail cues are available. |
quality |
Works when the media provider exposes renditions. Plain MP4 source arrays do not currently become a quality menu automatically. |
speed |
Included in the preset settings menu when playback rates are available. |
fullscreen |
Native fullscreen is supported; Plyr’s full-window fallback is not a matching feature. |
provider: 'vimeo' |
Use the Vimeo media component inside the player skin as shown in Vimeo above. |
provider: 'youtube' |
Use the YouTube media component inside the player skin as shown in YouTube above. |
storage |
Unsupported at this time. |
i18n |
The most common languages are available by default but you can also provide custom translations, if required. See Internationalization above for more info. |
ads |
Unsupported at this time. |
Dynamic sources
In Video.js v10 you can swap out the src or the media component itself and the UI will update.
Imperative API
Use the media element for standard playback operations:
| Plyr | Video.js v10 |
|---|---|
player.play() |
video.play() or player action |
player.pause() |
video.pause() or player action |
player.currentTime = 10 |
video.currentTime = 10 |
player.volume = 0.5 |
video.volume = 0.5 |
player.muted = true |
video.muted = true |
player.speed = 1.5 |
video.playbackRate = 1.5 |
player.fullscreen.enter() |
Fullscreen feature or fullscreen control |
player.toggleCaptions() |
Text-track feature or captions control |
For custom UI, read and write through the Video.js player store.
Import the preset’s usePlayer hook and call it directly or with a selector:
import { usePlayer } from '@videojs/react/video';
const currentTime = usePlayer((state) => state.currentTime);Use PlayerController inside a custom element:
import { PlayerController, playerContext, ReactiveElement, selectTime } from '@videojs/html';
class MyElapsed extends ReactiveElement {
#time = new PlayerController(this, playerContext, selectTime);
}Rewrite styles
Video.js v10 skins offer similar color customization via CSS custom properties. Eject the skin when you need deeper control over layout, control structure, icons, or interaction styling.
/* Plyr */
.plyr {
--plyr-color-main: rebeccapurple;
}
/* Video.js */
video-player {
--media-accent-color: rebeccapurple;
}--media-accent-color reaches the sliders, active buttons, and accent surfaces, so it’s the closest match for Plyr’s --plyr-color-main. Video.js picks a readable text color to sit on top of it; set --media-accent-text-color to choose that yourself. --media-border-radius and --media-scale-unit cover rounding and control sizing. See Customize skins for the full list.
Ejecting
If you need extra customization of layout, styles, or icons, you can “eject” the skin into your application, much like a shadcn installation. Copy the skin and its CSS into your project, then modify the components and styles directly. Customize skins has copy-paste-ready implementations for every packaged skin.
Known gaps
- Plyr’s
adsoption has no built-in equivalent. - Plyr’s
storageoption has no built-in equivalent for persisted volume, captions language, muted state, speed, or quality. Player setting persistence is tracked in #944; subtitle language preference is tracked in #1423. - Plyr’s full-window fullscreen fallback has no matching Video.js feature. This was designed as a fallback when the Fullscreen API wasn’t supported, but given browser support for fullscreen is around 96%, it’s unlikely to be required.
- Plain MP4 source arrays with
sizemetadata do not automatically create a quality menu. Use Mux, HLS, or DASH for adaptive quality when possible. A simpler source-driven quality menu may be considered later. - Preset skins segment the time slider and show chapter titles when the media includes a default
<track kind="chapters">. Dedicated cue-point APIs are not complete yet; see #1442. - The controls auto-hide delay and disabled state are not configurable yet (#1728).
- Native controls are not automatically removed when custom controls load; see #1160.