# 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 React components mirror Media Chrome’s elements — `media-play-button` becomes `PlayButton` — so most of the work is **renaming and reshaping**, not rewriting.

> **Note**
>
> Scoped to apps that compose their own player from Media Chrome elements. If you use `<mux-player>`, follow the Mux Player migration guide instead.

## Before you begin

Install Video.js and choose a [preset](https://videojs.org/docs/framework/react/guides/presets) (see [Installation](https://videojs.org/docs/guides/installation/react)). Start with its ready-made skin if those controls fit your player. If you need to keep a custom control bar, build it from the individual Video.js UI components instead.

```bash
npm install @videojs/react
```

## Map the controller

Media Chrome wraps a slotted `<video slot="media">` in a single `<media-controller>`. Video.js splits that into two components. The player owns state and draws nothing. The container is the box everything lives in, with the media as a plain child.

```html
<!-- 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>
```

Media Chrome’s React package wraps its custom elements. `@videojs/react` ships native components instead. `VideoPlayer` comes from the video [preset](https://videojs.org/docs/framework/react/guides/presets), and `Container` is the box the media and controls live in:

```tsx
'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
          render={(props, state) => (
            <button {...props} className="play-button">
              {state.paused ? <span className="play-icon">Play</span> : <span className="pause-icon">Pause</span>}
            </button>
          )}
        />
      </Container>
    </VideoPlayer>
  );
}
```

A preset player carries a fixed set of [features](https://videojs.org/docs/framework/react/guides/features). When you need a different set, build your own with [`createPlayer`](https://videojs.org/docs/framework/react/reference/api/create-player). 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.

```tsx
import { createPlayer } from '@videojs/react';
import { videoFeatures } from '@videojs/react/video';

const { Player } = createPlayer({ features: videoFeatures });
```

Read player state with [`usePlayer`](https://videojs.org/docs/framework/react/reference/api/use-player), `useMedia`, and [`useStore`](https://videojs.org/docs/framework/react/reference/api/use-store). Player hooks must run in a descendant component rendered inside `VideoPlayer` or the `Player` returned by `createPlayer`. A hook in the same component that returns the provider is still outside that provider.

## Map controller attributes

Media Chrome configures behavior through `<media-controller>` attributes. Video.js v10 has no single controller component, so these settle into three places:

- **Player features** — playback behavior that comes from the player’s feature set. A preset picks one for you; `createPlayer` lets you name your own.
- **`Container` and `Controls`** — layout, focus, and autohide.
- **Dedicated components** — `Hotkey`, `Gesture`, and friends, rendered inside the player.

| Media Chrome attribute | Video.js v10 | How |
| --- | --- | --- |
| `audio` | the audio preset | Choose an audio player and audio skin rather than toggling at runtime. |
| `autohide` | `Controls` (built in) | Controls auto-hide after inactivity automatically. `autohide="-1"` becomes `visibility="always"` on `Controls.Root`; the delay is currently fixed, see [Known gaps](#known-gaps). |
| `fullscreenelement` | `Container` | Fullscreen targets the container element. |
| `gesturesdisabled` | `Gesture disabled`, or omit | Disable per gesture component, or leave the gestures out. |
| `nohotkeys` | `Hotkey disabled`, or omit | Disable per hotkey component, or leave them out. |
| `hotkeys="noarrowleft …"` | individual `Hotkey` | Each shortcut is its own component, so remove the ones you don’t want. |
| `keyboardforwardseekoffset`, `keyboardbackwardseekoffset` | `value` on `Hotkey` | Set the signed seek amount per component. |
| `defaultsubtitles` | `<track default>` for authored tracks | Add `default` to an authored captions or subtitles track. Tracks discovered from a provider or manifest don’t have one generic player-level default flag. |
| `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](https://videojs.org/docs/framework/react/guides/internationalization). |

### Hotkeys

Media Chrome toggles keyboard shortcuts with `nohotkeys` and `hotkeys`. Video.js declares each shortcut as its own component, so you opt in to exactly the keys you want and set seek offsets inline:

```tsx
import { Hotkey } from '@videojs/react';

<Hotkey keys="Space" action="togglePaused" />
<Hotkey keys="m" action="toggleMuted" />
<Hotkey keys="ArrowRight" action="seekStep" value={5} />
<Hotkey keys="ArrowLeft" action="seekStep" value={-5} />
```

To drop a shortcut, remove its component or pass `disabled`. There’s no global “all hotkeys off” switch; omit the components you don’t need.

### Gestures

`gesturesdisabled` becomes per-component `Gesture` controls. Tap and double-tap behavior — click to toggle play, double-tap to seek or go fullscreen — is declared explicitly:

```tsx
import { Gesture } from '@videojs/react';

<Gesture type="tap" action="togglePaused" pointer="mouse" region="center" />
<Gesture type="doubletap" action="seekStep" value={-10} region="left" />
<Gesture type="doubletap" action="seekStep" value={10} region="right" />
```

Remove a component or pass `disabled` to turn a gesture off.

## Map the elements

Most names carry over as PascalCase components without the `media-` prefix. These are the renames that bite:

| Media Chrome | Video.js v10 | Note |
| --- | --- | --- |
| `media-controller` | a player plus `Container` | one element becomes two: state and layout |
| `media-control-bar` | `Controls.Root`, `Controls.Group` | grouping |
| `media-time-range` | `TimeSlider` | “range” becomes “slider” |
| `media-volume-range` | `VolumeSlider` | “range” becomes “slider” |
| `media-time-display`, `media-duration-display` | `Time.Value` | one component, set via `type` |
| `media-loading-indicator` | `BufferingIndicator` | rename |
| `media-poster-image` | `Poster` | rename, and the image source moves — see below |
| `media-seek-backward-button`, `media-seek-forward-button` | `SeekButton` | one component, direction via signed `seconds` |
| `media-rendition-menu` | `QualityRadioGroup` | inside `Menu` |
| `media-captions-menu` | `CaptionsRadioGroup` | inside `Menu` |
| `media-playback-rate-menu` | `PlaybackRateRadioGroup` | inside `Menu` |
| `media-audio-track-menu` | `AudioTrackRadioGroup` | inside `Menu` |

Unchanged names, in PascalCase: `PlayButton`, `MuteButton`, `FullscreenButton`, `PiPButton`, `AirPlayButton`, `CastButton`, `CaptionsButton`, `PlaybackRateButton`, `Tooltip`, and `Thumbnail`.

> **Caution**
>
> Matching names do not mean matching appearance. Media Chrome buttons include default icons and styles. Individual Video.js buttons provide behavior, state, and an accessible name, but no visible content or finished styling. Add text or an SVG, plus your own CSS. Without visible content, a button can work while showing nothing. Sliders also need their child parts and CSS. Use a ready-made skin when you want finished controls.

React buttons do not include visible content either. Use a button’s `render` prop to add it, as shown in [Map the controller](#map-the-controller). Build sliders from parts such as `TimeSlider.Track`, `TimeSlider.Fill`, and `TimeSlider.Thumb`, then add your own CSS.

Compound parts are namespaced under their component, such as `TimeSlider.Track`. See the [UI components](https://videojs.org/docs/framework/react/guides/ui-components) concept for the full set.

`Poster` is the one rename that changes shape. Put the poster URL on `VideoPlayer`; `Poster.Root` owns its state and `Poster.Image` accepts image attributes such as `alt`, `srcSet`, and `sizes`:

```tsx
<VideoPlayer poster="/poster.jpg">
  <Container>
    <Poster.Root>
      <Poster.Image alt="" />
    </Poster.Root>
  </Container>
</VideoPlayer>
```

## Rewrite your styles

Media Chrome reflects state as `media*` attributes such as `mediapaused`; Video.js uses `data-*`.

```css
/* Media Chrome */
media-play-button[mediapaused] .play-icon { display: inline; }
```

```css
/* Video.js React: target the className on your rendered button */
.play-button[data-paused] .play-icon { display: inline; }
```

Continuous values use CSS custom properties: sliders expose `--media-slider-fill` and `--media-slider-pointer`.

### Map theme variables by meaning

Media Chrome and Video.js both use names beginning with `--media-`, but that shared prefix is a naming convention, not a compatibility layer. Keep a variable only when its documented meaning matches.

| Media Chrome theme variable | Packaged Video.js skin | Migration |
| --- | --- | --- |
| `--media-accent-color` or `--media-range-bar-color` | `--media-accent-color` | Closest match for slider fills and accented controls. |
| `--media-primary-color` | no direct equivalent | Often colors every control icon in Media Chrome; Video.js’s accent variable has narrower semantics. |
| `--media-secondary-color` | no direct equivalent | Add the skin source and author this palette role in your CSS. |
| `--media-control-background` | no direct equivalent | Add the skin source and author the CSS. |
| `--media-control-hover-background` | no direct equivalent | Add the skin source and author the CSS. |
| `--media-range-track-height` | no direct equivalent | Add the skin source and author the slider CSS. |

The packaged Video.js skins expose `--media-accent-color`, `--media-accent-text-color`, `--media-border-radius`, and `--media-scale-unit` for broad customization. Do not assume another Media Chrome variable will work because its name starts with `--media-`.

## Themes become skins

Media Chrome’s `<template>`-based themes (`media-theme`) become Video.js [skins](https://videojs.org/docs/framework/react/guides/skins) and [presets](https://videojs.org/docs/framework/react/guides/presets). Start from a preset, then [add its skin source to your project](https://videojs.org/docs/framework/react/guides/customize-skins#style-skin-source), rather than authoring a template.

React skins do not use Shadow DOM. Use `render` props when you build individual controls. Add the files for a ready-made skin when you need to change its control set or layout.

## Control the player imperatively

Control everything through the player’s store. Media Chrome had no player object; Video.js gives you one, with an action for every operation — `play`, `togglePaused`, `seek`, `setVolume`, `toggleFullscreen` — so one mental model covers playback and the state the browser doesn’t own, such as fullscreen, captions, and quality.

Select an action with the same [`usePlayer`](https://videojs.org/docs/framework/react/reference/api/use-player) hook you read state with:

```tsx
import { usePlayer } from '@videojs/react/video';

function FullscreenShortcut() {
  const toggleFullscreen = usePlayer((state) => state.toggleFullscreen);
  // Call toggleFullscreen() from your own UI.
  return null;
}
```

The Media Chrome habit of scripting the media element directly still works, unchanged. The media is a plain child of the player, and Video.js derives player state from the native media events, so `video.play()` or `video.currentTime = 30` never leaves the controls out of sync. The media element also stays the way to swap `src`, replace the media component, and listen for native events.

Put a `ref` on the media component to reach the rendered `HTMLVideoElement`; there’s no `media` slot to reach through. For the Video.js media object, call `useMedia` from a component inside the player; media that wrap a playback engine expose it there through the `engine` escape hatch.

## Known gaps

These Media Chrome features have no direct equivalent yet. Several can be approximated; see [Workarounds](#workarounds).

- No configurable `autohide` delay and no `autohideovercontrols` ([#1728](https://github.com/videojs/v10/issues/1728)). Disabling autohide (`autohide="-1"`) is covered by `visibility="always"` on the controls component; see [Workarounds](#workarounds).
- No `defaultduration` placeholder before the media loads ([#1729](https://github.com/videojs/v10/issues/1729))
- Volume and muted preferences are not persisted across sessions, so Media Chrome’s `novolumepref` and `nomutedpref` opt-outs have nothing to opt out of ([#944](https://github.com/videojs/v10/issues/944)). Video.js can choose an initial subtitle track from the locale, but it does not yet remember the viewer’s later language choice ([#1786](https://github.com/videojs/v10/issues/1786)).
- No `breakpoints` or container-breakpoint attributes; use CSS container queries instead
- No `seektoliveoffset` or `noautoseektolive` controls, and live-edge offset and tolerance aren’t configurable ([#1730](https://github.com/videojs/v10/issues/1730))
- Chapters render and each time-slider segment reflects `data-active`, but there’s no player-level active chapter value, `chapterchange` event, or menu to jump between them ([#1873](https://github.com/videojs/v10/issues/1873))
- Cue points are not supported ([#1442](https://github.com/videojs/v10/issues/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 `TimeSlider.Chapters` and `TimeSlider.ChapterTitle`.

## Workarounds

### Disable autohide (`autohide="-1"`)

Set `visibility="always"` on `Controls.Root` and the controls stay visible regardless of activity. Packaged skins don’t expose that prop; to keep a preset skin’s look, [add the skin source to your project](https://videojs.org/docs/framework/react/guides/customize-skins#style-skin-source) and set it on the skin’s controls component rather than overriding private selectors.

### 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:

```css
@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.

```tsx
import { selectVolume, usePlayer } from '@videojs/react';
import { useEffect, useState } from 'react';

function PersistVolume() {
  const volume = usePlayer(selectVolume);
  const [readyToSave, setReadyToSave] = useState(false);

  useEffect(() => {
    const saved = localStorage.getItem('vjs:volume');
    if (saved !== null) {
      const savedVolume = Number(saved);
      if (Number.isFinite(savedVolume)) volume.setVolume(savedVolume);
    }
    setReadyToSave(true);
  }, []);

  useEffect(() => {
    if (!readyToSave) return;
    localStorage.setItem('vjs:volume', String(volume.volume));
  }, [readyToSave, volume.volume]);

  return null;
}
```

### 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.

## See also

- [Installation](https://videojs.org/docs/guides/installation/react)
- [UI components](https://videojs.org/docs/framework/react/guides/ui-components)
- [Skins](https://videojs.org/docs/framework/react/guides/skins) and [Customize skins](https://videojs.org/docs/framework/react/guides/customize-skins)

---

React documentation: https://videojs.org/docs/framework/react/llms.txt
All documentation: https://videojs.org/llms.txt
