Use Video.js with Radix UI
Build a player UI from Radix primitives, with Video.js supplying state, actions, and platform availability through hooks.
Build your player controls from Radix Primitives and let Video.js drive them: usePlayer supplies state, the features supply actions and availability, and the option hooks supply menu contents. Radix is the worked example; the same seams apply to any headless React library.
Install Radix and its icon set alongside @videojs/react. The examples also import two packages @videojs/react depends on but does not re-export: @videojs/utils for formatTime, the digital time formatter the built-in time components use, and @videojs/core for the translated text tokens and the thumbnail and chapter helpers.
npm install radix-ui @radix-ui/react-icons @videojs/core @videojs/utilsDrive a Radix primitive from player state
Wrap your media in <VideoPlayer> and <Container>, then read a feature with usePlayer and a selector. The feature object holds both the state and the actions, so a Radix Toggle needs nothing else: pressed comes from state, and the change handler calls the action.
import { PauseIcon, PlayIcon, SpeakerLoudIcon, SpeakerOffIcon } from '@radix-ui/react-icons';
import { muteText, pauseText, playText, unmuteText } from '@videojs/core/i18n/text/buttons';
import { Container, selectPlayback, selectVolume, usePlayer } from '@videojs/react';
import { useTranslator } from '@videojs/react/i18n';
import { Video, VideoPlayer } from '@videojs/react/video';
import { Toggle } from 'radix-ui';
// Radix has no Button primitive, so a plain <button> reads the playback feature and calls its action.
function PlayButton() {
const playback = usePlayer(selectPlayback);
// `t` translates the same text tokens the default skin uses, so labels match it and follow the player's locale.
const t = useTranslator();
if (!playback) return null;
return (
<button
type="button"
className="radix-player__button"
aria-label={t(playback.paused ? playText : pauseText)}
onClick={() => playback.togglePaused()}
>
{playback.paused ? <PlayIcon /> : <PauseIcon />}
</button>
);
}
// Radix Toggle is a controlled component: `pressed` comes from player state, the change handler dispatches the action.
function MuteToggle() {
const volume = usePlayer(selectVolume);
const t = useTranslator();
// The volume feature reports what the platform allows; hide the control where muting is not supported.
if (!volume || volume.mutedAvailability === 'unsupported') return null;
return (
<Toggle.Root
className="radix-player__button"
aria-label={t(volume.muted ? unmuteText : muteText)}
pressed={volume.muted}
onPressedChange={() => volume.toggleMuted()}
>
{volume.muted ? <SpeakerOffIcon /> : <SpeakerLoudIcon />}
</Toggle.Root>
);
}
export default function PlayAndMute() {
return (
<VideoPlayer>
<Container className="radix-player">
<Video src="https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/highest.mp4" poster="https://image.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/thumbnail.jpg" preload="metadata" muted playsInline />
<div className="radix-player__bar">
<PlayButton />
<MuteToggle />
</div>
</Container>
</VideoPlayer>
);
}
.radix-player {
position: relative;
aspect-ratio: 16 / 9;
overflow: hidden;
color: white;
background: black;
}
.radix-player video {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
/* The default skin's pill bar: 44px tall, inset 12px, frosted. */
.radix-player__bar {
position: absolute;
inset: auto 12px 12px;
display: flex;
align-items: center;
height: 44px;
padding: 4px;
background: rgb(255 255 255 / 10%);
backdrop-filter: blur(16px) saturate(1.5);
border-radius: 9999px;
}
/* Radix ships no styles; this is the 36px round icon button with an 18px icon. */
.radix-player__button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
color: inherit;
cursor: pointer;
background: none;
border: 0;
border-radius: 9999px;
}
.radix-player__button:hover,
.radix-player__button:focus-visible {
background: rgb(255 255 255 / 15%);
outline: none;
}
.radix-player__button svg {
width: 18px;
height: 18px;
}
Three details carry through every control in this guide:
- Guard the feature. A selector returns
undefinedwhen the player was built without that feature, so returnnullbefore reading it. - Read the availability flag. Platform-dependent features expose
*Availabilityproperties. The mute toggle returnsnullwhenmutedAvailabilityis'unsupported', the same rule Video.js’s own MuteButton follows. Use'unavailable'fordisabled. - Translate the labels. Radix renders no text of its own, so every
aria-labelis yours to set.useTranslatorreturnst, which accepts the text tokens under@videojs/core/i18n/text/*:playText,muteText,settingsText, and the rest. They are the same tokens the default skin uses, so your controls read identically and switch language with the player’s locale. See Internationalize the player for setting that locale.
Radix ships no Button primitive, so the play button is a plain <button> with the same class as the Toggle.
Add a seek slider
Radix Slider takes an array of values. Feed it currentTime and duration from the time feature, keep the pointer value in local state while dragging, and call seek() on commit. The buffered range comes from the buffer feature and is drawn as a plain <div> under Radix’s Range.
import { PauseIcon, PlayIcon } from '@radix-ui/react-icons';
import { pauseText, playText } from '@videojs/core/i18n/text/buttons';
import { seekText } from '@videojs/core/i18n/text/slider';
import { Container, selectBuffer, selectPlayback, selectTime, usePlayer } from '@videojs/react';
import { useTranslator } from '@videojs/react/i18n';
import { Video, VideoPlayer } from '@videojs/react/video';
import { formatTime } from '@videojs/utils/time';
import { Slider } from 'radix-ui';
import { useState } from 'react';
function PlayButton() {
const playback = usePlayer(selectPlayback);
const t = useTranslator();
if (!playback) return null;
return (
<button
type="button"
className="radix-player__button"
aria-label={t(playback.paused ? playText : pauseText)}
onClick={() => playback.togglePaused()}
>
{playback.paused ? <PlayIcon /> : <PauseIcon />}
</button>
);
}
function SeekSlider() {
const time = usePlayer(selectTime);
const buffer = usePlayer(selectBuffer);
const t = useTranslator();
// While dragging, show the pointer value; the seek happens on commit so playback does not stutter.
const [dragValue, setDragValue] = useState<number | null>(null);
if (!time || !Number.isFinite(time.duration) || time.duration <= 0) return null;
const value = dragValue ?? time.currentTime;
const bufferedEnd = buffer?.buffered.at(-1)?.[1] ?? 0;
return (
<Slider.Root
className="radix-player__slider"
min={0}
max={time.duration}
step={0.1}
value={[value]}
onValueChange={([next]) => setDragValue(next ?? null)}
onValueCommit={([next]) => {
setDragValue(null);
if (next !== undefined) time.seek(next);
}}
>
<Slider.Track className="radix-player__track">
<div className="radix-player__buffer" style={{ width: `${(bufferedEnd / time.duration) * 100}%` }} />
<Slider.Range className="radix-player__range" />
</Slider.Track>
<Slider.Thumb
className="radix-player__thumb"
aria-label={t(seekText)}
aria-valuetext={formatTime(value, time.duration)}
/>
</Slider.Root>
);
}
function TimeDisplay() {
const time = usePlayer(selectTime);
if (!time) return null;
return (
<span className="radix-player__time">
{formatTime(time.currentTime, time.duration)} / {formatTime(time.duration)}
</span>
);
}
export default function SeekSliderDemo() {
return (
<VideoPlayer>
<Container className="radix-player">
<Video src="https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/highest.mp4" poster="https://image.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/thumbnail.jpg" preload="metadata" muted playsInline />
<div className="radix-player__bar">
<PlayButton />
<SeekSlider />
<TimeDisplay />
</div>
</Container>
</VideoPlayer>
);
}
.radix-player {
position: relative;
aspect-ratio: 16 / 9;
overflow: hidden;
color: white;
background: black;
}
.radix-player video {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
.radix-player__bar {
position: absolute;
inset: auto 12px 12px;
display: flex;
gap: 10px;
align-items: center;
height: 44px;
padding: 4px 12px 4px 4px;
background: rgb(255 255 255 / 10%);
backdrop-filter: blur(16px) saturate(1.5);
border-radius: 9999px;
}
.radix-player__button {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
color: inherit;
cursor: pointer;
background: none;
border: 0;
border-radius: 9999px;
}
.radix-player__button:hover,
.radix-player__button:focus-visible {
background: rgb(255 255 255 / 15%);
outline: none;
}
.radix-player__button svg {
width: 18px;
height: 18px;
}
.radix-player__time {
font-size: 13px;
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
/* Radix Slider parts: the root is the pointer surface, the track the visible bar. */
.radix-player__slider {
position: relative;
display: flex;
flex: 1;
align-items: center;
height: 32px;
touch-action: none;
user-select: none;
}
.radix-player__track {
position: relative;
flex: 1;
height: 4px;
overflow: hidden;
background: rgb(255 255 255 / 20%);
border-radius: 9999px;
}
.radix-player__buffer {
position: absolute;
inset: 0 auto 0 0;
background: rgb(255 255 255 / 20%);
}
.radix-player__range {
position: absolute;
height: 100%;
background: white;
}
/* The thumb appears on hover and while dragging, like the default skin. */
.radix-player__thumb {
display: block;
width: 12px;
height: 12px;
background: white;
border-radius: 9999px;
opacity: 0;
transition: opacity 150ms;
}
.radix-player__slider:hover .radix-player__thumb,
.radix-player__thumb:focus-visible,
.radix-player__thumb[data-state="active"] {
opacity: 1;
}
.radix-player__thumb:focus-visible {
outline: 2px solid rgb(255 255 255 / 60%);
}
Radix places role="slider" on the Thumb, so the translated aria-label and the aria-valuetext go there. formatTime takes the duration as a guide so 0:05 and 12:05 render at the same width, the way the built-in Time component does.
Build a settings menu from the option hooks
Video.js exposes each settings list as a hook that returns the same shape: value, selectedLabel, options, setValue, and hidden. That shape maps onto a Radix DropdownMenu.RadioGroup, so one submenu component serves useQualityOptions, useAudioTrackOptions, usePlaybackRateOptions, and useCaptionsOptions.
import {
ChatBubbleIcon,
CheckIcon,
ChevronRightIcon,
GearIcon,
GlobeIcon,
MixerHorizontalIcon,
PauseIcon,
PlayIcon,
StopwatchIcon,
} from '@radix-ui/react-icons';
import { pauseText, playText } from '@videojs/core/i18n/text/buttons';
import { audioText, captionsText, qualityText, settingsText, speedText } from '@videojs/core/i18n/text/menu';
import {
Container,
selectPlayback,
useAudioTrackOptions,
useCaptionsOptions,
useContainer,
usePlaybackRateOptions,
usePlayer,
useQualityOptions,
} from '@videojs/react';
import { useTranslator } from '@videojs/react/i18n';
import { HlsJsVideo } from '@videojs/react/media/hlsjs-video';
import { VideoPlayer } from '@videojs/react/video';
import { DropdownMenu } from 'radix-ui';
import type { ReactNode } from 'react';
function PlayButton() {
const playback = usePlayer(selectPlayback);
const t = useTranslator();
if (!playback) return null;
return (
<button
type="button"
className="radix-player__button"
aria-label={t(playback.paused ? playText : pauseText)}
onClick={() => playback.togglePaused()}
>
{playback.paused ? <PlayIcon /> : <PauseIcon />}
</button>
);
}
// Every option hook returns the same shape, so one submenu component serves quality, audio, speed, and captions.
type Options = ReturnType<typeof useQualityOptions>;
function OptionSubmenu({ icon, label, options }: { icon: ReactNode; label: string; options: Options }) {
const container = useContainer();
// `hidden` is the hook's own availability flag: no renditions, one audio track, no caption tracks.
if (!options || options.hidden) return null;
return (
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger className="radix-player__menu-item radix-player__menu-item--trigger">
{icon}
{label}
<span className="radix-player__menu-hint">
{options.selectedLabel}
<ChevronRightIcon />
</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.Portal container={container}>
<DropdownMenu.SubContent className="radix-player__menu" sideOffset={6}>
<DropdownMenu.RadioGroup value={options.value} onValueChange={options.setValue}>
{options.options.map((option) => (
<DropdownMenu.RadioItem
key={option.value}
value={option.value}
disabled={option.disabled}
className="radix-player__menu-item radix-player__menu-item--radio"
>
<DropdownMenu.ItemIndicator className="radix-player__menu-check">
<CheckIcon />
</DropdownMenu.ItemIndicator>
{option.label}
</DropdownMenu.RadioItem>
))}
</DropdownMenu.RadioGroup>
</DropdownMenu.SubContent>
</DropdownMenu.Portal>
</DropdownMenu.Sub>
);
}
function SettingsMenu() {
// Portal into the player container so the menu follows the player into fullscreen.
const container = useContainer();
const quality = useQualityOptions();
const audio = useAudioTrackOptions();
const rates = usePlaybackRateOptions();
const captions = useCaptionsOptions();
const t = useTranslator();
return (
<DropdownMenu.Root modal={false}>
<DropdownMenu.Trigger asChild>
<button type="button" className="radix-player__button" aria-label={t(settingsText)}>
<GearIcon />
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal container={container}>
<DropdownMenu.Content className="radix-player__menu" side="top" align="end" sideOffset={8}>
<OptionSubmenu icon={<MixerHorizontalIcon />} label={t(qualityText)} options={quality} />
<OptionSubmenu icon={<GlobeIcon />} label={t(audioText)} options={audio} />
<OptionSubmenu icon={<StopwatchIcon />} label={t(speedText)} options={rates} />
<OptionSubmenu icon={<ChatBubbleIcon />} label={t(captionsText)} options={captions} />
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}
export default function SettingsMenuDemo() {
return (
<VideoPlayer>
<Container className="radix-player">
<HlsJsVideo
src="https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM.m3u8"
poster="https://image.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/thumbnail.jpg"
preload="metadata"
muted
playsInline
crossOrigin="anonymous"
>
<track kind="captions" src="/docs/demos/captions-button/captions.vtt" srcLang="en" label="English" />
</HlsJsVideo>
<div className="radix-player__bar">
<PlayButton />
<span className="radix-player__spacer" />
<SettingsMenu />
</div>
</Container>
</VideoPlayer>
);
}
.radix-player {
position: relative;
aspect-ratio: 16 / 9;
overflow: hidden;
color: white;
background: black;
}
.radix-player video {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
.radix-player__bar {
position: absolute;
inset: auto 12px 12px;
display: flex;
align-items: center;
height: 44px;
padding: 4px;
background: rgb(255 255 255 / 10%);
backdrop-filter: blur(16px) saturate(1.5);
border-radius: 9999px;
}
.radix-player__spacer {
flex: 1;
}
.radix-player__button {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
color: inherit;
cursor: pointer;
background: none;
border: 0;
border-radius: 9999px;
}
.radix-player__button:hover,
.radix-player__button:focus-visible,
.radix-player__button[data-state="open"] {
background: rgb(255 255 255 / 15%);
outline: none;
}
.radix-player__button svg {
width: 18px;
height: 18px;
}
/* Menu surfaces: Radix positions them; this is only the look. */
.radix-player__menu {
z-index: 50;
min-width: 14rem;
max-height: min(70vh, 20rem);
padding: 4px;
overflow-y: auto;
font-size: 13px;
color: white;
background: rgb(23 23 23 / 90%);
backdrop-filter: blur(16px) saturate(1.5);
border: 1px solid rgb(255 255 255 / 10%);
border-radius: 12px;
outline: none;
box-shadow: 0 10px 30px rgb(0 0 0 / 40%);
}
.radix-player__menu-item {
position: relative;
display: flex;
gap: 8px;
align-items: center;
padding: 8px 12px;
cursor: pointer;
user-select: none;
border-radius: 8px;
outline: none;
}
.radix-player__menu-item svg {
width: 16px;
height: 16px;
}
.radix-player__menu-item--radio {
padding-left: 32px;
}
.radix-player__menu-item[data-highlighted],
.radix-player__menu-item[data-state="open"] {
background: rgb(255 255 255 / 15%);
}
.radix-player__menu-item[data-disabled] {
cursor: not-allowed;
opacity: 0.5;
}
.radix-player__menu-hint {
display: flex;
gap: 4px;
align-items: center;
margin-left: auto;
padding-left: 16px;
color: rgb(255 255 255 / 60%);
}
.radix-player__menu-check {
position: absolute;
left: 8px;
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
}
Two things to notice:
hiddenis the availability gate for menus. It istruewhen there is nothing to choose: one rendition, one audio track, no caption tracks. The demo’s HLS source has several renditions and one audio track, so Quality shows and Audio does not.- Portal into the player. Radix portals to
document.bodyby default, which is outside the fullscreen element, so a menu opened in fullscreen would not appear. Pass the element fromuseContainerascontaineron everyPortal: menus, submenus, tooltips, popovers, and dialogs.
Full example
A player at feature parity with the default video skin: tooltips with hotkey hints, a hover volume popover, a seek slider with chapters and storyboard thumbnails, captions, settings, remote playback, picture-in-picture, fullscreen, a buffering indicator, an error dialog, auto-hiding controls, keyboard shortcuts, and click-to-toggle. The error dialog’s title, description, and dismiss label come from the same helpers Video.js’s own dialog uses, so those translate too.
import {
ChatBubbleIcon,
CheckIcon,
ChevronRightIcon,
CopyIcon,
DesktopIcon,
EnterFullScreenIcon,
ExitFullScreenIcon,
ExitIcon,
GearIcon,
GlobeIcon,
MixerHorizontalIcon,
PauseIcon,
PlayIcon,
ReloadIcon,
SpeakerLoudIcon,
SpeakerModerateIcon,
SpeakerOffIcon,
SpeakerQuietIcon,
StopwatchIcon,
UpdateIcon,
} from '@radix-ui/react-icons';
import {
getErrorDialogDismissText,
getErrorDialogTitleText,
mapCuesToThumbnails,
normalizeChapterCues,
resolveErrorDialogDescription,
ThumbnailCore,
} from '@videojs/core';
import { startText as airplayStartText, stopText as airplayStopText } from '@videojs/core/i18n/text/airplay';
import { muteText, pauseText, playText, replayText, unmuteText } from '@videojs/core/i18n/text/buttons';
import { disableText as captionsDisableText, enableText as captionsEnableText } from '@videojs/core/i18n/text/captions';
import { enterText as fullscreenEnterText, exitText as fullscreenExitText } from '@videojs/core/i18n/text/fullscreen';
import { audioText, captionsText, qualityText, settingsText, speedText } from '@videojs/core/i18n/text/menu';
import { enterText as pipEnterText, exitText as pipExitText } from '@videojs/core/i18n/text/pip';
import { seekText } from '@videojs/core/i18n/text/slider';
import { showDurationText, showRemainingText } from '@videojs/core/i18n/text/time';
import { labelText as volumeText } from '@videojs/core/i18n/text/volume';
import {
Container,
Gesture,
Hotkey,
selectBuffer,
selectControls,
selectError,
selectFullscreen,
selectPiP,
selectPlayback,
selectRemotePlayback,
selectTextTrack,
selectTime,
selectVolume,
useAudioTrackOptions,
useCaptionsOptions,
useContainer,
useHotkeyShortcut,
usePlaybackRateOptions,
usePlayer,
useQualityOptions,
} from '@videojs/react';
import { isText, useTranslator } from '@videojs/react/i18n';
import { HlsJsVideo } from '@videojs/react/media/hlsjs-video';
import { VideoPlayer } from '@videojs/react/video';
import { formatTime } from '@videojs/utils/time';
import { Dialog, DropdownMenu, Popover, Slider, Toggle, Tooltip } from 'radix-ui';
import { type ReactElement, type ReactNode, useEffect, useMemo, useRef, useState } from 'react';
// Tooltip with the hotkey hint the default skin shows. Portal into the container so it follows the player into fullscreen.
function HotkeyTooltip({ label, action, children }: { label: string; action?: string; children: ReactElement }) {
const container = useContainer();
const shortcut = useHotkeyShortcut(action);
return (
<Tooltip.Root>
<Tooltip.Trigger asChild>{children}</Tooltip.Trigger>
<Tooltip.Portal container={container}>
<Tooltip.Content className="radix-player__tooltip" side="top" sideOffset={8}>
{label}
{shortcut.shortcut ? <kbd className="radix-player__tooltip-key">{shortcut.shortcut}</kbd> : null}
</Tooltip.Content>
</Tooltip.Portal>
</Tooltip.Root>
);
}
// Radix Icons has one glyph for captions and remote playback, so the "on" state is an underline under the icon.
function ActiveIcon({ on, children }: { on: boolean; children: ReactNode }) {
return (
<span className="radix-player__icon" data-on={on || undefined}>
{children}
</span>
);
}
function PlayToggle() {
const playback = usePlayer(selectPlayback);
const t = useTranslator();
if (!playback) return null;
const label = t(playback.ended ? replayText : playback.paused ? playText : pauseText);
return (
<HotkeyTooltip label={label} action="togglePaused">
<button type="button" className="radix-player__button" aria-label={label} onClick={() => playback.togglePaused()}>
{playback.ended ? <ReloadIcon /> : playback.paused ? <PlayIcon /> : <PauseIcon />}
</button>
</HotkeyTooltip>
);
}
// Hover-open state that survives the pointer crossing the gap between the trigger and the portaled popover.
function useHoverOpen(closeDelay = 150) {
const [open, setOpen] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const cancel = () => {
if (timer.current !== null) clearTimeout(timer.current);
timer.current = null;
};
const onPointerEnter = () => {
cancel();
setOpen(true);
};
const onPointerLeave = () => {
cancel();
timer.current = setTimeout(() => setOpen(false), closeDelay);
};
useEffect(() => cancel, []);
return { open, setOpen, hoverProps: { onPointerEnter, onPointerLeave } };
}
// Mute toggle whose hover opens a volume popover. Radix Popover has no hover-open, so the open state is controlled.
function VolumeControl() {
const volume = usePlayer(selectVolume);
const container = useContainer();
const { open, setOpen, hoverProps } = useHoverOpen();
const t = useTranslator();
if (!volume || volume.mutedAvailability === 'unsupported') return null;
const level = volume.muted ? 0 : volume.volume;
const Icon =
level === 0
? SpeakerOffIcon
: level < 0.34
? SpeakerQuietIcon
: level < 0.67
? SpeakerModerateIcon
: SpeakerLoudIcon;
const label = t(volume.muted ? unmuteText : muteText);
return (
<span className="radix-player__volume-anchor" {...hoverProps}>
<Popover.Root open={open && volume.volumeAvailability === 'available'} onOpenChange={setOpen}>
<Popover.Anchor asChild>
<Toggle.Root
className="radix-player__button"
aria-label={label}
pressed={volume.muted}
onPressedChange={() => volume.toggleMuted()}
>
<Icon />
</Toggle.Root>
</Popover.Anchor>
<Popover.Portal container={container}>
<Popover.Content
className="radix-player__popover"
side="top"
sideOffset={8}
onOpenAutoFocus={(event) => event.preventDefault()}
data-interactive=""
{...hoverProps}
>
<Slider.Root
className="radix-player__volume"
orientation="vertical"
min={0}
max={1}
step={0.05}
value={[volume.muted ? 0 : volume.volume]}
onValueChange={([next]) => {
if (next !== undefined) volume.setVolume(next);
}}
>
<Slider.Track className="radix-player__volume-track">
<Slider.Range className="radix-player__volume-range" />
</Slider.Track>
<Slider.Thumb className="radix-player__thumb radix-player__thumb--always" aria-label={t(volumeText)} />
</Slider.Root>
</Popover.Content>
</Popover.Portal>
</Popover.Root>
</span>
);
}
const PREVIEW_WIDTH = 160;
const CHAPTER_GAP = 4;
const thumbnailCore = new ThumbnailCore();
// Radix Slider fed by the time, buffer, and text-track features. Radix exposes no pointer position, so hover time comes
// from the root's rect; the chapter title and storyboard tile are derived from it with the core's helpers.
function SeekSlider() {
const time = usePlayer(selectTime);
const buffer = usePlayer(selectBuffer);
const textTrack = usePlayer(selectTextTrack);
const [dragValue, setDragValue] = useState<number | null>(null);
const [hover, setHover] = useState<number | null>(null);
const t = useTranslator();
const thumbnails = useMemo(
() => mapCuesToThumbnails(textTrack?.thumbnailCues ?? [], textTrack?.thumbnailTrackSrc ?? undefined),
[textTrack?.thumbnailCues, textTrack?.thumbnailTrackSrc]
);
if (!time || !Number.isFinite(time.duration) || time.duration <= 0) return null;
const { duration } = time;
const value = dragValue ?? time.currentTime;
const bufferedEnd = buffer?.buffered.at(-1)?.[1] ?? 0;
const hoverTime = hover === null ? null : hover * duration;
const thumbnail = hoverTime === null ? undefined : thumbnailCore.findActiveThumbnail(thumbnails, hoverTime);
const thumbnailScale = thumbnail?.width ? PREVIEW_WIDTH / thumbnail.width : 1;
// One track segment per chapter plus fillers for gaps between cues, so the track is always contiguous.
const segments = normalizeChapterCues(textTrack?.chaptersCues ?? [], 0, duration);
const hovered =
hoverTime === null ? undefined : segments.find((segment) => hoverTime >= segment.start && hoverTime < segment.end);
return (
<Slider.Root
className="radix-player__slider"
min={0}
max={duration}
step={0.1}
value={[value]}
onValueChange={([next]) => setDragValue(next ?? null)}
onValueCommit={([next]) => {
setDragValue(null);
if (next !== undefined) time.seek(next);
}}
onPointerMove={(event) => {
const rect = event.currentTarget.getBoundingClientRect();
setHover(Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width)));
}}
onPointerLeave={() => setHover(null)}
>
<Slider.Track className="radix-player__track">
{segments.map(({ key, start, end }, index) => {
const isFirst = index === 0;
const isLast = index === segments.length - 1;
const span = end - start;
const fraction = (value: number) => `${Math.min(1, Math.max(0, (value - start) / span)) * 100}%`;
const inset = (isFirst ? 0 : CHAPTER_GAP / 2) + (isLast ? 0 : CHAPTER_GAP / 2);
return (
<div
key={key}
className="radix-player__segment"
data-highlighted={hovered?.key === key || undefined}
style={{
left: `calc(${(start / duration) * 100}% + ${isFirst ? 0 : CHAPTER_GAP / 2}px)`,
width: `calc(${(span / duration) * 100}% - ${inset}px)`,
}}
>
<div className="radix-player__buffer" style={{ width: fraction(bufferedEnd) }} />
<div className="radix-player__fill" style={{ width: fraction(value) }} />
</div>
);
})}
</Slider.Track>
<Slider.Thumb
className="radix-player__thumb"
aria-label={t(seekText)}
aria-valuetext={formatTime(value, duration)}
/>
{hoverTime !== null ? (
<div
className="radix-player__preview"
style={{
left: `clamp(${PREVIEW_WIDTH / 2}px, ${hover! * 100}%, calc(100% - ${PREVIEW_WIDTH / 2}px))`,
width: PREVIEW_WIDTH,
}}
>
{thumbnail?.width && thumbnail.height ? (
<div className="radix-player__thumbnail" style={{ height: thumbnail.height * thumbnailScale }}>
<img
alt=""
src={thumbnail.url}
style={{
transform: `scale(${thumbnailScale}) translate(-${thumbnail.coords?.x ?? 0}px, -${thumbnail.coords?.y ?? 0}px)`,
}}
/>
</div>
) : null}
<div className="radix-player__preview-label">
{hovered?.cue ? <span className="radix-player__preview-chapter">{hovered.cue.text}</span> : null}
<span>{formatTime(hoverTime, duration)}</span>
</div>
</div>
) : null}
</Slider.Root>
);
}
function CurrentTime() {
const time = usePlayer(selectTime);
if (!time) return null;
return <span className="radix-player__time">{formatTime(time.currentTime, time.duration)}</span>;
}
// Remaining time that toggles to the duration on click, worded like the default skin's time display.
function RemainingTime() {
const time = usePlayer(selectTime);
const [remaining, setRemaining] = useState(true);
const t = useTranslator();
if (!time) return null;
const shown = remaining
? `-${formatTime(time.duration - time.currentTime, time.duration)}`
: formatTime(time.duration);
return (
<button
type="button"
className="radix-player__time radix-player__time--button"
aria-label={t(remaining ? showDurationText : showRemainingText, { duration: shown })}
onClick={() => setRemaining((current) => !current)}
>
{shown}
</button>
);
}
function CaptionsToggle() {
const textTrack = usePlayer(selectTextTrack);
const t = useTranslator();
const hasTracks = textTrack?.textTrackList.some((track) => track.kind === 'subtitles' || track.kind === 'captions');
if (!textTrack || !hasTracks) return null;
const label = t(textTrack.subtitlesShowing ? captionsDisableText : captionsEnableText);
return (
<HotkeyTooltip label={label} action="toggleSubtitles">
<Toggle.Root
className="radix-player__button"
aria-label={label}
pressed={textTrack.subtitlesShowing}
onPressedChange={() => textTrack.toggleSubtitles()}
>
<ActiveIcon on={textTrack.subtitlesShowing}>
<ChatBubbleIcon />
</ActiveIcon>
</Toggle.Root>
</HotkeyTooltip>
);
}
type Options = ReturnType<typeof useQualityOptions>;
function OptionSubmenu({ icon, label, options }: { icon: ReactNode; label: string; options: Options }) {
const container = useContainer();
if (!options || options.hidden) return null;
return (
<DropdownMenu.Sub>
<DropdownMenu.SubTrigger className="radix-player__menu-item radix-player__menu-item--trigger">
{icon}
{label}
<span className="radix-player__menu-hint">
{options.selectedLabel}
<ChevronRightIcon />
</span>
</DropdownMenu.SubTrigger>
<DropdownMenu.Portal container={container}>
<DropdownMenu.SubContent className="radix-player__menu" sideOffset={6}>
<DropdownMenu.RadioGroup value={options.value} onValueChange={options.setValue}>
{options.options.map((option) => (
<DropdownMenu.RadioItem
key={option.value}
value={option.value}
disabled={option.disabled}
className="radix-player__menu-item radix-player__menu-item--radio"
>
<DropdownMenu.ItemIndicator className="radix-player__menu-check">
<CheckIcon />
</DropdownMenu.ItemIndicator>
{option.label}
</DropdownMenu.RadioItem>
))}
</DropdownMenu.RadioGroup>
</DropdownMenu.SubContent>
</DropdownMenu.Portal>
</DropdownMenu.Sub>
);
}
function SettingsMenu() {
const container = useContainer();
const quality = useQualityOptions();
const audio = useAudioTrackOptions();
const rates = usePlaybackRateOptions();
const captions = useCaptionsOptions();
const t = useTranslator();
return (
<DropdownMenu.Root modal={false}>
<HotkeyTooltip label={t(settingsText)}>
<DropdownMenu.Trigger asChild>
<button type="button" className="radix-player__button" aria-label={t(settingsText)}>
<GearIcon />
</button>
</DropdownMenu.Trigger>
</HotkeyTooltip>
<DropdownMenu.Portal container={container}>
<DropdownMenu.Content className="radix-player__menu" side="top" align="end" sideOffset={8}>
<OptionSubmenu icon={<MixerHorizontalIcon />} label={t(qualityText)} options={quality} />
<OptionSubmenu icon={<GlobeIcon />} label={t(audioText)} options={audio} />
<OptionSubmenu icon={<StopwatchIcon />} label={t(speedText)} options={rates} />
<OptionSubmenu icon={<ChatBubbleIcon />} label={t(captionsText)} options={captions} />
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
);
}
// Hidden where the platform has no remote playback, disabled while no receiver is reachable.
function RemotePlaybackToggle() {
const remote = usePlayer(selectRemotePlayback);
const t = useTranslator();
if (!remote || remote.remotePlaybackAvailability === 'unsupported') return null;
const connected = remote.remotePlaybackState === 'connected';
const label = t(connected ? airplayStopText : airplayStartText);
return (
<HotkeyTooltip label={label}>
<Toggle.Root
className="radix-player__button"
aria-label={label}
pressed={connected}
disabled={remote.remotePlaybackAvailability === 'unavailable'}
onPressedChange={() => remote.toggleRemotePlayback()}
>
<ActiveIcon on={connected}>
<DesktopIcon />
</ActiveIcon>
</Toggle.Root>
</HotkeyTooltip>
);
}
function PiPToggle() {
const pip = usePlayer(selectPiP);
const t = useTranslator();
if (!pip || pip.pipAvailability === 'unsupported') return null;
const label = t(pip.pip ? pipExitText : pipEnterText);
return (
<HotkeyTooltip label={label} action="togglePictureInPicture">
<Toggle.Root
className="radix-player__button"
aria-label={label}
pressed={pip.pip}
disabled={pip.pipAvailability === 'unavailable'}
onPressedChange={() => pip.togglePictureInPicture()}
>
{pip.pip ? <ExitIcon /> : <CopyIcon />}
</Toggle.Root>
</HotkeyTooltip>
);
}
function FullscreenToggle() {
const fullscreen = usePlayer(selectFullscreen);
const t = useTranslator();
if (!fullscreen || fullscreen.fullscreenAvailability === 'unsupported') return null;
const label = t(fullscreen.fullscreen ? fullscreenExitText : fullscreenEnterText);
return (
<HotkeyTooltip label={label} action="toggleFullscreen">
<Toggle.Root
className="radix-player__button"
aria-label={label}
pressed={fullscreen.fullscreen}
onPressedChange={() => fullscreen.toggleFullscreen()}
>
{fullscreen.fullscreen ? <ExitFullScreenIcon /> : <EnterFullScreenIcon />}
</Toggle.Root>
</HotkeyTooltip>
);
}
function BufferingSpinner() {
const playback = usePlayer(selectPlayback);
if (!playback?.waiting) return null;
return (
<div className="radix-player__spinner">
<UpdateIcon />
</div>
);
}
// Radix AlertDialog is always page-modal, so this is a non-modal Dialog with role="alertdialog" that ignores outside
// interaction and paints its own scrim over the player only. The copy comes from the player's error-dialog texts.
function ErrorAlert() {
const error = usePlayer(selectError);
const container = useContainer();
const t = useTranslator();
if (!error) return null;
const open = error.error !== null;
const description = resolveErrorDialogDescription(error.error);
return (
<Dialog.Root modal={false} open={open} onOpenChange={(next) => !next && error.dismissError()}>
<Dialog.Portal container={container}>
{open ? <div className="radix-player__scrim" /> : null}
<Dialog.Content
className="radix-player__dialog"
role="alertdialog"
data-interactive=""
onInteractOutside={(event) => event.preventDefault()}
>
<Dialog.Title className="radix-player__dialog-title">{t(getErrorDialogTitleText())}</Dialog.Title>
<Dialog.Description className="radix-player__dialog-description">
{isText(description) ? t(description) : description}
</Dialog.Description>
<div className="radix-player__dialog-actions">
<Dialog.Close className="radix-player__text-button">{t(getErrorDialogDismissText())}</Dialog.Close>
</div>
</Dialog.Content>
</Dialog.Portal>
</Dialog.Root>
);
}
// The default skin's keyboard shortcuts and click-to-toggle gesture; both are state-only Video.js components.
function Behaviors() {
return (
<>
<Hotkey keys="Space" action="togglePaused" />
<Hotkey keys="k" action="togglePaused" />
<Hotkey keys="m" action="toggleMuted" />
<Hotkey keys="ArrowRight" action="seekStep" />
<Hotkey keys="ArrowLeft" action="seekStep" />
<Hotkey keys="ArrowUp" action="volumeStep" />
<Hotkey keys="ArrowDown" action="volumeStep" />
<Hotkey keys="f" action="toggleFullscreen" />
<Hotkey keys="c" action="toggleSubtitles" />
<Hotkey keys="i" action="togglePictureInPicture" />
<Gesture type="tap" action="togglePaused" pointer="mouse" region="center" />
<Gesture type="tap" action="toggleControls" pointer="touch" />
<Gesture type="doubletap" action="toggleFullscreen" region="center" />
</>
);
}
function Controls() {
const controls = usePlayer(selectControls);
const hidden = controls ? !controls.controlsVisible : false;
return (
<>
<BufferingSpinner />
<ErrorAlert />
<div className="radix-player__backdrop" data-hidden={hidden || undefined} />
<Tooltip.Provider delayDuration={300}>
{/* `data-interactive` tells the container's gestures to ignore clicks inside the bar. */}
<div className="radix-player__bar" data-hidden={hidden || undefined} data-interactive="">
<PlayToggle />
<VolumeControl />
<div className="radix-player__time-group">
<CurrentTime />
<SeekSlider />
<RemainingTime />
</div>
<CaptionsToggle />
<SettingsMenu />
<div className="radix-player__group">
<RemotePlaybackToggle />
<PiPToggle />
<FullscreenToggle />
</div>
</div>
</Tooltip.Provider>
<Behaviors />
</>
);
}
export default function RadixPlayer() {
return (
<VideoPlayer>
<Container className="radix-player">
<HlsJsVideo
src="https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM.m3u8"
poster="https://image.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/thumbnail.jpg"
preload="metadata"
muted
playsInline
crossOrigin="anonymous"
>
<track kind="metadata" label="thumbnails" src="https://image.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/storyboard.vtt" default />
<track kind="chapters" src="/docs/demos/time-slider/chapters.vtt" srcLang="en" default />
<track kind="captions" src="/docs/demos/captions-button/captions.vtt" srcLang="en" label="English" />
</HlsJsVideo>
<Controls />
</Container>
</VideoPlayer>
);
}
.radix-player {
position: relative;
aspect-ratio: 16 / 9;
overflow: hidden;
font-size: 13px;
color: white;
background: black;
}
.radix-player video {
display: block;
width: 100%;
height: 100%;
object-fit: contain;
}
/* The default skin's gradient behind the bar and the bar itself; both fade with the controls feature. */
.radix-player__backdrop {
position: absolute;
inset: 0;
pointer-events: none;
background: linear-gradient(to top, rgb(0 0 0 / 50%), rgb(0 0 0 / 30%) 25%, transparent);
transition: opacity 300ms;
}
.radix-player__bar {
position: absolute;
inset: auto 12px 12px;
display: flex;
align-items: center;
height: 44px;
padding: 4px;
background: rgb(255 255 255 / 10%);
backdrop-filter: blur(16px) saturate(1.5);
border-radius: 9999px;
transition: opacity 300ms;
}
.radix-player__backdrop[data-hidden],
.radix-player__bar[data-hidden] {
pointer-events: none;
opacity: 0;
}
.radix-player__group {
display: flex;
align-items: center;
}
.radix-player__time-group {
display: flex;
flex: 1;
gap: 10px;
align-items: center;
padding: 0 12px;
}
/* Buttons */
.radix-player__button {
display: inline-flex;
flex-shrink: 0;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
color: inherit;
cursor: pointer;
background: none;
border: 0;
border-radius: 9999px;
transition: background-color 150ms;
}
.radix-player__button:hover,
.radix-player__button:focus-visible,
.radix-player__button[data-state="open"] {
background: rgb(255 255 255 / 15%);
outline: none;
}
.radix-player__button:focus-visible {
outline: 2px solid rgb(255 255 255 / 60%);
}
.radix-player__button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.radix-player__button svg {
width: 18px;
height: 18px;
}
/* On/off state for glyphs Radix has only one of: an underline while on, dimmed while off. */
.radix-player__icon {
position: relative;
display: inline-flex;
opacity: 0.8;
}
.radix-player__icon[data-on] {
opacity: 1;
}
.radix-player__icon[data-on]::after {
position: absolute;
bottom: -6px;
left: 50%;
width: 16px;
height: 2px;
content: "";
background: currentColor;
border-radius: 9999px;
transform: translateX(-50%);
}
.radix-player__text-button {
display: inline-flex;
align-items: center;
height: 32px;
padding: 0 12px;
font: inherit;
font-weight: 500;
color: white;
cursor: pointer;
background: rgb(255 255 255 / 15%);
border: 0;
border-radius: 9999px;
}
.radix-player__text-button:hover,
.radix-player__text-button:focus-visible {
background: rgb(255 255 255 / 25%);
outline: none;
}
/* Time */
.radix-player__time {
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.radix-player__time--button {
padding: 0;
font: inherit;
color: inherit;
cursor: pointer;
background: none;
border: 0;
}
/* Seek slider: the root is the pointer surface; each chapter is its own rounded segment with a 4px gap. */
.radix-player__slider {
position: relative;
display: flex;
flex: 1;
align-items: center;
height: 32px;
touch-action: none;
user-select: none;
}
.radix-player__track {
position: relative;
display: flex;
flex: 1;
align-items: center;
height: 4px;
}
.radix-player__segment {
position: absolute;
height: 4px;
overflow: hidden;
background: rgb(255 255 255 / 20%);
border-radius: 9999px;
transition: height 300ms;
}
.radix-player__segment[data-highlighted] {
height: 7px;
}
.radix-player__buffer,
.radix-player__fill {
position: absolute;
inset: 0 auto 0 0;
}
.radix-player__buffer {
background: rgb(255 255 255 / 20%);
}
.radix-player__fill {
background: white;
}
.radix-player__thumb {
display: block;
width: 12px;
height: 12px;
background: white;
border-radius: 9999px;
opacity: 0;
transition: opacity 150ms;
}
.radix-player__slider:hover .radix-player__thumb,
.radix-player__thumb:focus-visible,
.radix-player__thumb[data-state="active"],
.radix-player__thumb--always {
opacity: 1;
}
.radix-player__thumb:focus-visible {
outline: 2px solid rgb(255 255 255 / 60%);
}
/* Preview: the thumbnail sits 36px above the track, the label 42px above it, over the thumbnail's gradient. */
.radix-player__preview {
position: absolute;
bottom: 0;
pointer-events: none;
transform: translateX(-50%);
}
.radix-player__thumbnail {
position: absolute;
bottom: 36px;
left: 0;
width: 160px;
overflow: hidden;
background: rgb(0 0 0 / 90%);
border-radius: 12px;
box-shadow: 0 10px 30px rgb(0 0 0 / 40%);
}
.radix-player__thumbnail img {
position: absolute;
top: 0;
left: 0;
max-width: none;
transform-origin: top left;
}
.radix-player__thumbnail::after {
position: absolute;
inset: 0;
pointer-events: none;
content: "";
background: linear-gradient(to top, rgb(0 0 0 / 50%), rgb(0 0 0 / 10%), transparent);
}
.radix-player__preview-label {
position: absolute;
bottom: 42px;
left: 0;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
font-variant-numeric: tabular-nums;
text-shadow: 0 1px 2px rgb(0 0 0 / 60%);
}
.radix-player__preview-chapter {
width: 100%;
padding: 0 12px;
overflow: hidden;
text-align: center;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Tooltip */
.radix-player__tooltip {
z-index: 50;
padding: 4px 10px;
font-size: 12px;
font-weight: 500;
color: #171717;
background: white;
border-radius: 6px;
box-shadow: 0 4px 12px rgb(0 0 0 / 30%);
}
.radix-player__tooltip-key {
margin-left: 6px;
font: inherit;
color: #737373;
}
/* Popups: Radix positions them; this is only the surface. */
.radix-player__popover,
.radix-player__menu {
z-index: 50;
padding: 4px;
color: white;
background: rgb(23 23 23 / 90%);
backdrop-filter: blur(16px) saturate(1.5);
border: 1px solid rgb(255 255 255 / 10%);
border-radius: 12px;
outline: none;
box-shadow: 0 10px 30px rgb(0 0 0 / 40%);
}
.radix-player__volume-anchor {
display: inline-flex;
}
.radix-player__popover {
display: flex;
align-items: center;
height: 144px;
padding: 12px;
}
.radix-player__volume {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
width: 20px;
height: 100%;
touch-action: none;
user-select: none;
}
.radix-player__volume-track {
position: relative;
flex: 1;
width: 4px;
overflow: hidden;
background: rgb(255 255 255 / 20%);
border-radius: 9999px;
}
.radix-player__volume-range {
position: absolute;
width: 100%;
background: white;
}
/* Menu */
.radix-player__menu {
min-width: 14rem;
max-height: min(70vh, 20rem);
overflow-y: auto;
}
.radix-player__menu-item {
position: relative;
display: flex;
gap: 8px;
align-items: center;
padding: 8px 12px;
cursor: pointer;
user-select: none;
border-radius: 8px;
outline: none;
}
.radix-player__menu-item svg {
width: 16px;
height: 16px;
}
.radix-player__menu-item--radio {
padding-left: 32px;
}
.radix-player__menu-item[data-highlighted],
.radix-player__menu-item[data-state="open"] {
background: rgb(255 255 255 / 15%);
}
.radix-player__menu-item[data-disabled] {
cursor: not-allowed;
opacity: 0.5;
}
.radix-player__menu-hint {
display: flex;
gap: 4px;
align-items: center;
margin-left: auto;
padding-left: 16px;
color: rgb(255 255 255 / 60%);
}
.radix-player__menu-check {
position: absolute;
left: 8px;
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
}
/* Indicators */
.radix-player__spinner {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
}
.radix-player__spinner svg {
width: 48px;
height: 48px;
animation: radix-player-spin 1s linear infinite;
}
@keyframes radix-player-spin {
to {
transform: rotate(360deg);
}
}
/* Error dialog, scoped to the player: our own scrim plus a centered, non-modal Radix Dialog. */
.radix-player__scrim {
position: absolute;
inset: 0;
z-index: 50;
background: rgb(0 0 0 / 60%);
}
.radix-player__dialog {
position: absolute;
inset: 0;
z-index: 50;
display: flex;
flex-direction: column;
gap: 12px;
width: fit-content;
max-width: 24rem;
height: fit-content;
margin: auto;
padding: 20px;
color: white;
background: #171717;
border: 1px solid rgb(255 255 255 / 10%);
border-radius: 12px;
outline: none;
box-shadow: 0 20px 50px rgb(0 0 0 / 50%);
}
.radix-player__dialog-title {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.radix-player__dialog-description {
margin: 0;
font-size: 14px;
color: rgb(255 255 255 / 80%);
}
.radix-player__dialog-actions {
display: flex;
justify-content: flex-end;
}
How it works
- State and actions.
usePlayer(selector)subscribes to one feature and re-renders when it changes. The returned object carries that feature’s actions, so a control never needs the whole store. See Build your own UI component for the selector list. - Availability. Volume, fullscreen, picture-in-picture, and remote playback report
'available','unavailable', or'unsupported'. The example hides on'unsupported'and disables on'unavailable', matching the default skin. - Option hooks. The four
use*Optionshooks are the seam for a foreign menu. They own the selection logic; Radix owns the menu. - Container portals.
useContainer()returns the element that goes fullscreen. Portaling into it keeps popups inside the player and inside the scoped stylesheet. - Gestures and
data-interactive.Gesturelistens on the container natively, before React handlers run, soevent.stopPropagation()in a Radix handler does not stop a tap from toggling playback. The container skips any element that has thedata-interactiveattribute, which Video.js’s ownControls.Contentsets. The example sets it on the bar, the volume popover, and the dialog. - Text.
tfromuseTranslatoraccepts a text token or a plain string key and returns the string for the active locale. The error dialog usesgetErrorDialogTitleText,resolveErrorDialogDescription, andgetErrorDialogDismissText, the helpers behind ErrorDialog. - Chapters and thumbnails. The text track feature exposes
chaptersCues,thumbnailCues, andthumbnailTrackSrc.normalizeChapterCuespartitions the timeline into contiguous segments, filling gaps between cues.mapCuesToThumbnailsandThumbnailCore.findActiveThumbnailresolve the storyboard tile for a time. - Auto-hide. The controls feature reports
controlsVisiblefrom user activity on the container. The bar and its backdrop fade on that flag.
Availability and constraints
- Radix
AlertDialogis page-modal. It hides the rest of the document from assistive technology, locks scroll, and blocks outside pointer events. Video.js’sErrorDialogscopes all of that to the player. UseDialogwithmodal={false}androle="alertdialog", ignore outside interaction withonInteractOutside, and paint your own scrim, since a non-modalDialogrenders noOverlay. - Radix
Sliderexposes no pointer position. Hover time comes from the root’s bounding rect inonPointerMove. Video.js’s TimeSlider provides it as--media-slider-pointeranddata-pointing; a foreign slider has to derive it. - Radix
Popoverhas no hover-open. The volume popover is controlled from pointer enter and leave on both the trigger wrapper and the content, with a short delay so the pointer can cross the gap between them. - Radix
DropdownMenuopens onpointerdown, notclick. Tests that callelement.click()will not open it; dispatch pointer events instead. - Radix has no Button primitive. Plain
<button>elements share a class with theToggleroots. - Popups take no controls lock. Video.js popups keep the controls visible while open. A Radix menu does not, so the bar can fade under an open menu after the inactivity delay. Read
controlsVisibleand hold the menu open, or skip auto-hide while a popup is open, if that matters to you. - Radix Icons has no closed-caption, picture-in-picture, cast, or speedometer glyph. The example uses the nearest stand-ins and marks on/off state with an underline where Radix has a single glyph.
Common variations
Mix in Video.js components
Nothing requires an all-or-nothing choice. Video.js components and Radix primitives read the same store, so you can place Poster, BufferingIndicator, or a complete TimeSlider next to Radix buttons inside the same Container. The UI components page covers restyling those with CSS.
Use another headless library
The seams are library-agnostic: controlled state from a selector, actions from the feature object, hidden from the option hooks, useContainer() for portals, and data-interactive on anything the gesture layer should ignore. Base UI, React Aria, and similar libraries slot in the same way; only the primitive names and the modality and hover behaviors differ.
Troubleshooting
Clicking the slider or a button also toggles playback
A Gesture on the container saw the tap. Put data-interactive="" on the control bar, and on any portaled surface such as a popover or dialog. Stopping propagation in React does not help because the gesture listens natively on the container.
Menus and tooltips disappear in fullscreen
They are portaled to document.body, which is outside the fullscreen element. Pass container={useContainer()} to every Radix Portal.
The error dialog dims the whole page
You are using AlertDialog, which is always page-modal. Switch to Dialog with modal={false} and role="alertdialog", as in the full example.
The settings menu is empty or a submenu is missing
Each option hook sets hidden when there is nothing to choose. Quality needs a source with several renditions, such as HLS or DASH; audio needs more than one audio track; captions need at least one subtitles or captions track.
The volume popover closes before the pointer reaches it
The trigger and the portaled content are separate elements with a gap between them. Attach the hover handlers to both and close on a short timer, as useHoverOpen does in the full example.
Related components
Related API
- usePlayer
- useContainer
- useHotkeyShortcut
- useTranslator
- useQualityOptions
- useAudioTrackOptions
- usePlaybackRateOptions
- useCaptionsOptions