CaptionsRadioGroup
A menu radio group for selecting caption and subtitle tracks
Creates radio items from the player text track state and selects the showing caption or subtitle track.
Anatomy
<Menu.RadioGroup>
<Menu.GroupLabel />
<Menu.RadioItem>
<Menu.ItemIndicator />
</Menu.RadioItem>
</Menu.RadioGroup>
<media-captions-radio-group>
<template>
<media-menu-radio-item>
<span data-part="label"></span>
<media-menu-item-indicator></media-menu-item-indicator>
</media-menu-radio-item>
</template>
</media-captions-radio-group>
Behavior
The group is available when the configured media exposes at least one caption or subtitle track. The first generated option is Off; selecting it hides captions. Track labels use the track label, then language, then kind. Pass formatTrack to customize the visible labels.
useCaptionsOptions returns null when the text tracks feature is not configured. Use the returned options with Menu.RadioGroup.
<media-captions-radio-group> generates <media-menu-radio-item> children, including the Off item. Add an optional <template> with a single <media-menu-radio-item> to customize each generated item.
Styling
| Attribute | Values | Description |
|---|---|---|
data-active
|
Present / absent | Present when captions are enabled. |
data-disabled
|
Present / absent | Present when track selection is disabled. |
data-availability
|
"available" / "unavailable"
|
Whether caption or subtitle tracks are available. |
media-captions-radio-group[data-availability="unavailable"] {
display: none;
}
Accessibility
The group uses the menu radio group pattern.
Give Menu.RadioGroup an accessible name with aria-label or a nested Menu.GroupLabel.
The element receives an accessible label from the label property or defaults to Enable captions / Disable captions based on the current state.
Examples
Basic usage
import { createPlayer, Menu, useCaptionsOptions } from '@videojs/react';
import { Video, videoFeatures } from '@videojs/react/video';
import type { ReactNode } from 'react';
const Player = createPlayer({ features: videoFeatures });
function CaptionsMenu(): ReactNode {
const captions = useCaptionsOptions();
if (captions?.state.availability !== 'available') return null;
return (
<Menu.Root side="top" align="end">
<Menu.Trigger className="settings-trigger" render={<button type="button" />}>
Captions
</Menu.Trigger>
<Menu.Content className="menu">
<Menu.RadioGroup
className="menu-group"
value={captions.value}
onValueChange={captions.setValue}
aria-label="Captions"
>
{captions.options.map((option) => (
<Menu.RadioItem key={option.value} value={option.value} disabled={option.disabled} className="menu-item">
{option.label}
<Menu.ItemIndicator checked={option.value === captions.value} forceMount className="menu-indicator">
✓
</Menu.ItemIndicator>
</Menu.RadioItem>
))}
</Menu.RadioGroup>
</Menu.Content>
</Menu.Root>
);
}
export default function BasicUsage() {
return (
<Player.Provider>
<Player.Container className="media-container">
<Video src="https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/highest.mp4" autoPlay muted playsInline loop>
<track kind="captions" src="/docs/demos/captions-button/captions.vtt" srcLang="en" label="English" />
<track kind="subtitles" src="/docs/demos/captions-button/captions.vtt" srcLang="es" label="Spanish" />
</Video>
<div className="menu-bar">
<CaptionsMenu />
</div>
</Player.Container>
</Player.Provider>
);
}
.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-indicator {
opacity: 0;
}
[role="menuitemradio"][aria-checked="true"] .menu-indicator {
opacity: 1;
}
<video-player class="video-player">
<media-container>
<video
src="https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/highest.mp4"
autoplay
muted
playsinline
loop
>
<track kind="captions" src="/docs/demos/captions-button/captions.vtt" srclang="en" label="English" />
<track kind="subtitles" src="/docs/demos/captions-button/captions.vtt" srclang="es" label="Spanish" />
</video>
<div class="menu-bar">
<button type="button" commandfor="captions-menu" class="settings-trigger">Captions</button>
<media-menu id="captions-menu" side="top" align="end" class="menu">
<media-captions-radio-group class="menu-group" label="Captions">
<template>
<media-menu-radio-item class="menu-item">
<span data-part="label"></span>
<media-menu-item-indicator force-mount class="menu-indicator">✓</media-menu-item-indicator>
</media-menu-radio-item>
</template>
</media-captions-radio-group>
</media-menu>
</div>
</media-container>
</video-player>
.video-player,
.video-player media-container {
position: relative;
display: block;
}
.video-player 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;
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-indicator {
opacity: 0;
}
media-menu-radio-item[aria-checked="true"] .menu-indicator {
opacity: 1;
}
import '@videojs/html/video/player';
import '@videojs/html/ui/menu';
import '@videojs/html/ui/captions-radio-group';
API Reference
Props
| Prop | Type | Default | Details |
|---|---|---|---|
disabled
|
boolean
|
false
|
|
|
|||
formatTrack
|
function
|
formatTrackLabel
|
|
|
|||
label
|
string | function
|
''
|
|
|
|||
State
render, className, and style props.
| Property | Type | Details |
|---|---|---|
tracks
|
CaptionsRadioGroupTrack[]
|
|
value
|
string
|
|
disabled
|
boolean
|
|
availability
|
'available' | 'unavailable'
|
|
subtitlesShowing
|
boolean
|
|
|
||
label
|
string
|
|
Data attributes
| Attribute | Type | Details |
|---|---|---|
data-active
|
||
|
||
data-disabled
|
||
|
||
data-availability
|
'available' | 'unavailable'
|
|
|
||