Show captions and subtitles
Show captions and subtitles, and let users turn them on and pick a language.
Add captions and subtitles to media, and let users turn them on and pick a language.
Recommended approach
Add WebVTT tracks as <track> children of the media element, and give users a CaptionsButton to toggle them.
import { CaptionsButton, Container } from '@videojs/react';
import { Video, VideoPlayer } from '@videojs/react/video';
export default function BasicUsage() {
return (
<VideoPlayer>
<Container className="media-container">
<Video src="https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/highest.mp4" autoPlay muted playsInline loop>
<track kind="captions" src="/docs/demos/text-tracks/captions.vtt" srcLang="en" label="English" default />
</Video>
<CaptionsButton
className="media-captions-button"
render={(props, state) => (
<button {...props}>{state.subtitlesShowing ? 'Captions Off' : 'Captions On'}</button>
)}
/>
</Container>
</VideoPlayer>
);
}
.media-container {
position: relative;
}
.media-container video {
width: 100%;
aspect-ratio: 16 / 9;
}
.media-captions-button {
position: absolute;
bottom: 10px;
left: 10px;
padding-block: 8px;
padding-inline: 20px;
color: black;
cursor: pointer;
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 9999px;
backdrop-filter: blur(10px);
}
<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/text-tracks/captions.vtt" srclang="en" label="English" default />
</video>
<media-captions-button class="media-captions-button">
<span class="active">Captions Off</span>
<span class="inactive">Captions On</span>
</media-captions-button>
</media-container>
</video-player>
.video-player {
position: relative;
}
.video-player video {
width: 100%;
aspect-ratio: 16 / 9;
}
.media-captions-button {
position: absolute;
bottom: 10px;
left: 10px;
padding-block: 8px;
padding-inline: 20px;
color: black;
cursor: pointer;
background: rgba(255, 255, 255, 0.7);
border: 1px solid rgba(255, 255, 255, 0.3);
border-radius: 9999px;
backdrop-filter: blur(10px);
}
.media-captions-button .active {
display: none;
}
.media-captions-button .inactive {
display: none;
}
.media-captions-button[data-active] .active {
display: inline;
}
.media-captions-button:not([data-active]) .inactive {
display: inline;
}
import '@videojs/html/video/player';
import '@videojs/html/ui/captions-button';
How it works
The text track feature mirrors the media element’s textTracks into player state:
textTrackListholds every track with itsid,kind,label,language, andmode.subtitlesShowingistruewhen any caption or subtitle track is showing.toggleSubtitles(forceShow?)shows or hides all caption and subtitle tracks.selectSubtitlesTrack(id)shows one track and disables the rest; pass'off'to disable all.
Tracks don’t have to come from <track> elements: tracks that streaming media exposes (for example in-manifest HLS captions) appear in textTrackList the same way.
The browser renders the cues. Style them with the ::cue pseudo-element.
Availability and constraints
- Captions availability is
'unavailable'until the media has at least one caption or subtitle track; caption controls render nothing in that case. - Cues load asynchronously. A track appears in
textTrackListbefore its cues are parsed, so cue-driven UI fills in when the track finishes loading. - Cross-origin track files require CORS: serve the VTT with CORS headers and set
crossOriginon the media element. - Track selection UIs identify tracks by
labelandlanguage; give every track both. defaulton a<track>makes the browser show it initially.
Common variations
Language selection menu
For multiple languages, render a menu instead of a toggle.
useCaptionsOptions provides the option list (including “Off”), the selected value, and availability:
import { Menu, useCaptionsOptions } from '@videojs/react';
function CaptionsMenu() {
const captions = useCaptionsOptions();
if (captions?.state.availability !== 'available') return null;
return (
<Menu.Root side="top" align="end">
<Menu.Trigger render={<button type="button" />}>Captions</Menu.Trigger>
<Menu.Content>
<Menu.RadioGroup value={captions.value} onValueChange={captions.setValue} aria-label="Captions">
{captions.options.map((option) => (
<Menu.RadioItem key={option.value} value={option.value} disabled={option.disabled}>
{option.label}
</Menu.RadioItem>
))}
</Menu.RadioGroup>
</Menu.Content>
</Menu.Root>
);
}See the CaptionsRadioGroup reference for the complete pattern.
Use <media-captions-radio-group> inside a menu or popover for explicit track selection.
Troubleshooting
Captions don’t appear
Confirm that:
- The track
kindiscaptionsorsubtitles. - The VTT file loads (check the network panel for the track request).
- The track is enabled:
defaulton the track element, a CaptionsButton toggle, ortoggleSubtitles(true).
Captions work locally but not in production
The track file is served from another origin without CORS headers. Serve it with Access-Control-Allow-Origin and set crossOrigin on the media element.
The selection menu is empty
The media has no caption or subtitle tracks, so captions availability is 'unavailable'. For streaming sources, confirm the manifest actually declares text tracks.