Migrate from Media Chrome
Map Media Chrome's controller, elements, and attributes onto Video.js v10, where most of the work is renaming and reshaping rather than rewriting
Video.js v10’s HTML player uses the same media-* custom-element convention as Media Chrome, so most of the work is renaming and reshaping, not rewriting. This guide maps Media Chrome’s API to Video.js v10.
Before you begin
Install Video.js and pick a preset and skin (see Installation). The fastest path is to start from a preset, then replace its controls with your migrated markup.
npm install @videojs/htmlnpm install @videojs/reactMap the controller
Media Chrome wraps a slotted <video slot="media"> in a single <media-controller>. Video.js splits that into two elements. The player owns state and draws nothing. The <media-container> is the box everything lives in, with the media as a plain child.
<!-- Media Chrome -->
<media-controller>
<video slot="media" src="video.m3u8"></video>
<media-control-bar>
<media-play-button></media-play-button>
</media-control-bar>
</media-controller><!-- Video.js -->
<video-player>
<media-container>
<video src="video.mp4"></video>
<media-controls>
<media-play-button></media-play-button>
</media-controls>
</media-container>
</video-player>Media Chrome’s React package wraps its custom elements. @videojs/react ships native components instead. VideoPlayer comes from the video preset, and Container is the box the media and controls live in:
'use client';
import { Container, PlayButton } from '@videojs/react';
import { Video, VideoPlayer } from '@videojs/react/video';
export function MyPlayer() {
return (
<VideoPlayer>
<Container>
<Video src="video.mp4" />
<PlayButton />
</Container>
</VideoPlayer>
);
}A preset player carries a fixed set of features. When you need a different set, build your own with createPlayer. Hand it a feature list and you get back a typed Player component and usePlayer hook, the same pair the preset is made of. Call it once, outside your component, and reuse it.
import { createPlayer } from '@videojs/react';
import { videoFeatures } from '@videojs/react/video';
const { Player } = createPlayer({ features: videoFeatures });Where Media Chrome asks you to restyle a shadow root, Video.js components take a render prop, so you supply the element and Video.js supplies the behavior and state:
<PlayButton
render={(props, state) => (
<button {...props}>{state.paused ? 'Play' : 'Pause'}</button>
)}
/>Map controller attributes
Media Chrome configures behavior through <media-controller> attributes. Video.js v10 has no single controller element, so these settle into three places:
- Player features — playback behavior that comes from the player’s feature set. A preset picks one for you;
createPlayerlets you name your own. <media-container>and<media-controls>— layout, focus, and autohide.- Dedicated elements —
<media-hotkey>,<media-gesture>, and friends, declared in markup.
| Media Chrome attribute | Video.js v10 | How |
|---|---|---|
audio |
the audio preset | Choose an audio player and audio skin rather than toggling at runtime. |
autohide |
media-controls (built in) |
Controls auto-hide after inactivity automatically. The delay is currently fixed; see Known gaps. |
fullscreenelement |
media-container |
Fullscreen targets the container element. |
gesturesdisabled |
media-gesture disabled, or omit |
Disable per gesture element, or leave the gestures out. |
nohotkeys |
media-hotkey disabled, or omit |
Disable per hotkey element, or leave them out. |
hotkeys="noarrowleft …" |
individual media-hotkey |
Each shortcut is its own element, so remove the ones you don’t want. |
keyboardforwardseekoffset, keyboardbackwardseekoffset |
value on media-hotkey |
Set the signed seek amount per element. |
defaultsubtitles |
media-captions-button or player state |
Partial: captions can be toggled, but “on by default” isn’t a flag yet. |
defaultstreamtype |
streamType feature |
Partial: derived from the media, with no pre-load default. |
liveedgeoffset, seektoliveoffset |
live media engine | Live-edge behavior lives in the playback engine, not a UI attribute. |
lang |
i18n translator | Localization is configured through the translator and locale registry. |
Hotkeys
Media Chrome toggles keyboard shortcuts with nohotkeys and hotkeys. Video.js declares each shortcut as its own element, so you opt in to exactly the keys you want and set seek offsets inline:
<media-hotkey keys="Space" action="togglePaused"></media-hotkey>
<media-hotkey keys="m" action="toggleMuted"></media-hotkey>
<media-hotkey keys="ArrowRight" action="seekStep" value="5"></media-hotkey>
<media-hotkey keys="ArrowLeft" action="seekStep" value="-5"></media-hotkey>To drop a shortcut, remove its element or add disabled. There’s no global “all hotkeys off” switch; omit the elements you don’t need.
Gestures
gesturesdisabled becomes per-element <media-gesture> controls. Tap and double-tap behavior — click to toggle play, double-tap to seek or go fullscreen — is declared explicitly:
<media-gesture type="tap" action="togglePaused" pointer="mouse" region="center"></media-gesture>
<media-gesture type="doubletap" action="seekStep" value="-10" region="left"></media-gesture>
<media-gesture type="doubletap" action="seekStep" value="10" region="right"></media-gesture>Remove an element or set disabled to turn a gesture off.
Map the elements
Most names match. These are the renames that bite:
| Media Chrome | Video.js v10 | Note |
|---|---|---|
media-controller |
a player plus media-container |
one element becomes two: state and layout |
media-control-bar |
media-controls, media-controls-group |
grouping |
media-time-range |
media-time-slider |
“range” becomes “slider” |
media-volume-range |
media-volume-slider |
“range” becomes “slider” |
media-time-display, media-duration-display |
media-time |
one element, set via attribute |
media-loading-indicator |
media-buffering-indicator |
rename |
media-poster-image |
media-poster |
rename, and the image source moves — see below |
media-seek-backward-button, media-seek-forward-button |
media-seek-button |
one element, direction via signed seconds |
media-rendition-menu |
media-quality-radio-group |
inside media-menu |
media-captions-menu |
media-captions-radio-group |
inside media-menu |
media-playback-rate-menu |
media-playback-rate-radio-group |
inside media-menu |
media-audio-track-menu |
media-audio-track-radio-group |
inside media-menu |
Unchanged names: media-play-button, media-mute-button, media-fullscreen-button, media-pip-button, media-airplay-button, media-cast-button, media-captions-button, media-playback-rate-button, media-tooltip, and media-thumbnail.
Sliders are compound in both, using media-slider-track, media-slider-fill, and media-slider-thumb.
media-poster is the one rename that changes shape. Where media-poster-image took its own src, media-poster wraps the image you bring:
<media-poster>
<img src="/poster.jpg" alt="" decoding="async" />
</media-poster>These are PascalCase components rather than tags — TimeSlider, VolumeSlider, BufferingIndicator, Poster, QualityRadioGroup — and the compound parts are namespaced, so media-slider-track becomes TimeSlider.Track. See the UI components concept for the full set.
Poster is the one rename that changes shape. It is the image rather than a wrapper around one, so media-poster-image’s attributes go straight on it:
<Poster src="/poster.jpg" alt="" />Rewrite your styles
Media Chrome reflects state as media* attributes such as mediapaused; Video.js uses data-*.
/* Media Chrome */
media-play-button[mediapaused] .play-icon { display: inline; }
/* Video.js */
media-play-button[data-paused] .play-icon { display: inline; }Continuous values use CSS custom properties: sliders expose --media-slider-fill and --media-slider-pointer.
Themes become skins
Media Chrome’s <template>-based themes (media-theme) become Video.js skins and presets. Start from a preset, then eject and customize with Customize skins, rather than authoring a template.
Known gaps
These Media Chrome features have no direct equivalent yet. Several can be approximated; see Workarounds.
- No configurable
autohidedelay or disable switch (autohide="-1"), and noautohideovercontrols(#1728) - No
defaultdurationplaceholder before the media loads (#1729) - No preference persistence, so Media Chrome’s
novolumepref,nomutedpref, andnosubtitleslangprefopt-outs have nothing to opt out of. Persistence is out of scope for GA; tracked at #944, with subtitle language at #1423 - No
breakpointsor container-breakpoint attributes; use CSS container queries instead - No
seektoliveoffsetornoautoseektolivecontrols, and live-edge offset and tolerance aren’t configurable (#1730) - Chapters render, but there’s no active-chapter state, no
chapterchangeevent, and no menu to jump between them (#1873) - Cue points are not supported (#1442)
Chapters themselves do work. Add a default <track kind="chapters"> and the packaged skins segment the time slider and show the chapter title on hover, via media-time-slider-chapters and media-time-slider-chapter-title.
Workarounds
Disable autohide (autohide="-1")
The skin hides controls by removing data-visible from the root <media-controls>. Which element then fades depends on the player’s width: below 32rem it’s the --primary and --secondary groups inside the root, and at or above 32rem it’s the root itself. Override both, plus the hidden cursor, in your player CSS:
.media-default-skin--video .media-controls--root:not([data-visible]) {
opacity: 1;
scale: 1;
filter: none;
pointer-events: auto;
.media-controls--primary,
.media-controls--secondary {
opacity: 1;
scale: 1;
filter: none;
pointer-events: auto;
}
}
.media-default-skin--video:has(.media-controls--root:not([data-visible])) {
cursor: auto;
}Two things make this fragile. It matches the skin’s own selectors exactly, so it only wins on source order — load your stylesheet after the skin’s. And the class names are the default skin’s; the minimal skin fades .media-minimal-skin--video .media-controls instead, with no groups to reach.
To change the delay rather than disable it, note that the idle timer is a constant inside controlsFeature. Fork that feature, swap it into a custom feature list, and pass that list to createPlayer:
const features = videoFeatures.map((f) => (f.name === 'controls' ? myControlsFeature : f));Breakpoints
The skin root is an inline-size container named media-root, so write responsive styles with container queries instead of breakpoints attributes. This is exactly how the built-in skins adapt:
@container media-root (width > 40rem) {
.media-controls { /* wide layout */ }
}Default duration
There’s no player input for a pre-load duration. Use preload="metadata", the default, so the real duration is known almost immediately; only preload="none" defers it. If you must defer loading, render your own static placeholder in markup.
Preference persistence
Media Chrome remembers volume, muted state, and language across sessions. Video.js v10 persists nothing, so restore and save the values yourself.
import { selectVolume, usePlayer } from '@videojs/react';
import { useEffect } from 'react';
function PersistVolume() {
const volume = usePlayer(selectVolume);
useEffect(() => {
const saved = localStorage.getItem('vjs:volume');
if (saved) volume.setVolume(Number(saved));
}, []);
useEffect(() => {
localStorage.setItem('vjs:volume', String(volume.volume));
}, [volume.volume]);
return null;
}Work against the store directly: selectVolume(store.state).setVolume(…) on init, and store.subscribe(…) to save on change.
Live edge offsets
seektoliveoffset and noautoseektolive are governed by the playback engine rather than the UI layer, so there’s no attribute to tune them today. The escape hatch is a custom live control built against the selectLive and selectTime player state.