Add a quality selector
Read available renditions, let the engine adapt automatically, and offer manual quality selection.
Read the renditions a stream offers, let the streaming engine adapt quality automatically, and give users manual control when they want it.
Quality selection applies to streaming sources. A progressive MP4 plays at one fixed quality and exposes no renditions; HLS and DASH streams offer several. See Media sources.
Recommended approach
Let automatic quality selection do its job by default, and offer a quality menu for users who want to pin a rendition. Render the menu only when quality selection is available.
import { Container, createPlayer, Menu, QualityRadioGroup } from '@videojs/react';
import { HlsJsVideo } from '@videojs/react/media/hlsjs-video';
import { videoFeatures } from '@videojs/react/video';
import type { ReactNode } from 'react';
const { Player } = createPlayer({ features: videoFeatures });
const src = 'https://stream.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4.m3u8';
function QualityMenu(): ReactNode {
return (
<Menu.Root side="top" align="end">
<Menu.Trigger className="settings-trigger" render={<button type="button" />}>
Quality
</Menu.Trigger>
<Menu.Popup className="menu">
<Menu.Content>
<QualityRadioGroup
className="menu-group"
renderItem={(props, item) => (
<Menu.RadioItem {...props} className="menu-item">
<span>
{item.label}
{item.tier ? <sup className="menu-tier">{item.tier}</sup> : null}
</span>
{item.badge ? <span className="menu-badge">{item.badge}</span> : null}
<Menu.ItemIndicator checked={item.checked} forceMount className="menu-indicator">
✓
</Menu.ItemIndicator>
</Menu.RadioItem>
)}
/>
</Menu.Content>
</Menu.Popup>
</Menu.Root>
);
}
export default function BasicUsage() {
return (
<Player>
<Container className="media-container">
<HlsJsVideo src={src} autoPlay crossOrigin="anonymous" muted playsInline loop />
<div className="menu-bar">
<QualityMenu />
</div>
</Container>
</Player>
);
}
.media-container {
position: relative;
}
.media-container video {
width: 100%;
}
.menu-bar {
position: absolute;
right: 10px;
bottom: 10px;
}
.settings-trigger {
padding: 6px 16px;
color: black;
cursor: pointer;
background: rgba(255, 255, 255, 0.75);
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 9999px;
backdrop-filter: blur(10px);
}
.menu {
--media-menu-side-offset: 8px;
box-sizing: border-box;
display: grid;
gap: 2px;
min-width: 180px;
max-width: var(--media-menu-available-width, var(--media-popover-available-width, none));
max-height: var(--media-menu-available-height, var(--media-popover-available-height, none));
padding: 6px;
margin: 0;
overflow: auto;
overscroll-behavior: none;
font-size: 14px;
color: white;
background: rgba(0, 0, 0, 0.88);
border: 0;
border-radius: 8px;
backdrop-filter: blur(10px);
}
.menu-group {
display: grid;
gap: 2px;
}
.menu-item {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
min-height: 32px;
padding: 0 10px;
font: inherit;
color: inherit;
cursor: pointer;
background: none;
border: 0;
border-radius: 6px;
}
.menu-item[data-highlighted] {
background: rgba(255, 255, 255, 0.16);
}
.menu-tier {
margin-left: 2px;
font-size: 10px;
}
.menu-badge {
margin-left: auto;
color: rgba(255, 255, 255, 0.72);
}
.menu-indicator {
opacity: 0;
}
[role="menuitemradio"][aria-checked="true"] .menu-indicator {
opacity: 1;
}
<video-player class="video-player">
<media-container>
<hlsjs-video
class="video-media"
src="https://stream.mux.com/lhnU49l1VGi3zrTAZhDm9LUUxSjpaPW9BL4jY25Kwo4.m3u8"
autoplay
crossorigin="anonymous"
muted
playsinline
loop
></hlsjs-video>
<div class="menu-bar">
<button type="button" commandfor="quality-menu" class="settings-trigger">Quality</button>
<media-menu id="quality-menu" side="top" align="end" class="menu">
<media-menu-content>
<media-quality-radio-group class="menu-group" label="Quality">
<template>
<media-menu-radio-item class="menu-item">
<span>
<span data-part="label"></span>
<sup data-part="tier" class="menu-tier"></sup>
</span>
<span data-part="badge" class="menu-badge"></span>
<media-menu-item-indicator force-mount class="menu-indicator">✓</media-menu-item-indicator>
</media-menu-radio-item>
</template>
</media-quality-radio-group>
</media-menu-content>
</media-menu>
</div>
</media-container>
</video-player>
.video-player media-container {
position: relative;
display: block;
}
.video-media {
width: 100%;
}
.menu-bar {
position: absolute;
right: 10px;
bottom: 10px;
}
.menu-bar:has([data-availability="unavailable"]) {
display: none;
}
.settings-trigger {
padding: 6px 16px;
color: black;
cursor: pointer;
background: rgba(255, 255, 255, 0.75);
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 9999px;
backdrop-filter: blur(10px);
}
.menu {
--media-menu-side-offset: 8px;
box-sizing: border-box;
min-width: 180px;
max-width: var(--media-menu-available-width, var(--media-popover-available-width, none));
max-height: var(--media-menu-available-height, var(--media-popover-available-height, none));
padding: 6px;
overflow: auto;
overscroll-behavior: none;
font-size: 14px;
color: white;
background: rgba(0, 0, 0, 0.88);
border-radius: 8px;
backdrop-filter: blur(10px);
}
.menu-group {
display: grid;
gap: 2px;
}
.menu-item {
display: flex;
gap: 8px;
align-items: center;
justify-content: space-between;
min-height: 32px;
padding: 0 10px;
font: inherit;
color: inherit;
cursor: pointer;
background: none;
border: 0;
border-radius: 6px;
}
.menu-item[data-highlighted] {
background: rgba(255, 255, 255, 0.16);
}
.menu-tier {
margin-left: 2px;
font-size: 10px;
}
.menu-badge {
margin-left: auto;
color: rgba(255, 255, 255, 0.72);
}
.menu-indicator {
opacity: 0;
}
media-menu-radio-item[aria-checked="true"] .menu-indicator {
opacity: 1;
}
import '@videojs/html/video/player';
import '@videojs/html/ui/container';
import '@videojs/html/media/hlsjs-video';
import '@videojs/html/ui/menu';
import '@videojs/html/ui/quality-radio-group';
How it works
The quality feature mirrors the media element’s video renditions into player state:
videoRenditionListholds each rendition’sid,width,height,bitrate,frameRate,codec, and whether it’sselected.activeVideoRenditionis the rendition currently playing. When the engine doesn’t report one directly, the player matches it from the video’s current dimensions.selectVideoRendition(value)pins one rendition; pass'auto'to return control to the engine’s adaptive selection.
useQualityOptions turns this state into ready-made menu options — labels like “1080p”, an “Auto” entry, and availability — as shown in the demo above.
<media-quality-radio-group> turns this state into a ready-made radio menu with resolution labels and an “Auto” entry, as shown in the demo above.
Selecting “Auto” keeps adaptive bitrate switching active: the engine picks the best rendition for current bandwidth and viewport. Pinning a rendition disables adaptation until the user selects “Auto” again.
Availability and constraints
- Quality availability is
'unavailable'for media that doesn’t offer a choice — progressive files, streams before the manifest loads, and single-rendition streams. Render quality UI conditionally; the demo’s availability check does this. - Renditions come from the streaming engine, so the list depends on what the manifest declares. A single-rendition stream offers no meaningful selection, so availability stays
'unavailable'. - Pinning a high rendition on a slow connection causes buffering: the engine can no longer step down. Keep “Auto” the default.
- Rendition lists change on source change; selection resets with them.
Common variations
Auto quality only
If you don’t want to expose manual selection, do nothing: adaptive selection is on by default and needs no UI.
Cap or pin quality programmatically
import { Container } from '@videojs/react';
import { HlsJsVideo } from '@videojs/react/media/hlsjs-video';
import { usePlayer, VideoPlayer } from '@videojs/react/video';
function PinLowestRendition() {
const store = usePlayer();
const renditions = usePlayer((s) => s.videoRenditionList);
const lowest = [...renditions].sort((a, b) => (a.height ?? 0) - (b.height ?? 0))[0];
return (
<button type="button" onClick={() => lowest?.id && store.selectVideoRendition(lowest.id)}>
Data saver
</button>
);
}
export default function App() {
return (
<VideoPlayer>
<Container>
<HlsJsVideo src="https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM.m3u8" muted playsInline />
<PinLowestRendition />
</Container>
</VideoPlayer>
);
}Access the player store through the player controller and call selectVideoRendition with a rendition id from videoRenditionList, or 'auto' to restore adaptive selection.
Troubleshooting
The quality menu doesn’t render
Quality availability is 'unavailable'. The source is progressive (no renditions), the manifest hasn’t loaded yet, or the media element doesn’t support renditions. Use a streaming media element such as HlsJsVideo.
The menu doesn’t render for a single-rendition stream
The manifest declares a single rendition, so quality availability stays 'unavailable' — one entry offers no choice. Encode the stream as a multi-rendition ladder to give the engine and users something to choose between.
Playback buffers after selecting a quality
The pinned rendition exceeds available bandwidth. Selecting “Auto” lets the engine step down again.