Skip to content
FrameworkStyle

Skins

Packaged player designs that include both UI components and their styles.

A skin wraps the media component and provides the player’s complete interface. It combines a specific set of UI components with the styles that arrange and present them.

Packaged and ejected skins

When you choose a skin you have two options for how you use it: packaged or ejected. It’s usually easiest to start with a packaged skin and later eject its internal components into your project when you need more customization.

Packaged skins cover common visual changes through documented CSS custom properties. Packaged video skins also let you replace the poster. When you need more than that, eject and change the components and styles directly.

Packaged Ejected
Single component Many UI components
Documented CSS custom properties Components and styles you own
Future design updates auto-applied by bumping the version Future design updates manually applied, or intentionally ignored

Packaged skin

import { Video, VideoPlayer, VideoSkin } from '@videojs/react/video';
import '@videojs/react/video/skin.css';

export function Player() {
  return (
    <VideoPlayer>
      <VideoSkin>
        <Video src="video.mp4" />
      </VideoSkin>
    </VideoPlayer>
  );
}

Don’t depend on elements, class names, or undocumented custom properties inside a packaged skin. They may change between releases.

Ejected skin

An ejected skin exposes its UI components and styles as app code that you own. You can add, remove, rearrange, or restyle any part of it.

'use client';

import { type SVGProps, type ComponentProps, type ReactElement, type ReactNode, type CSSProperties } from 'react';
import { SpinnerIcon as SpinnerIconPrimitive, ChevronIcon as ChevronIconPrimitive, CaptionsOnIcon as CaptionsOnIconPrimitive, CaptionsOffIcon as CaptionsOffIconPrimitive, FullscreenEnterIcon as FullscreenEnterIconPrimitive, FullscreenExitIcon as FullscreenExitIconPrimitive, PipEnterIcon as PipEnterIconPrimitive, PipExitIcon as PipExitIconPrimitive, PlayIcon as PlayIconPrimitive, PauseIcon as PauseIconPrimitive, VolumeHighIcon as VolumeHighIconPrimitive, VolumeLowIcon as VolumeLowIconPrimitive, VolumeOffIcon as VolumeOffIconPrimitive, AirPlayEnterIcon as AirPlayEnterIconPrimitive, AirPlayExitIcon as AirPlayExitIconPrimitive, CastEnterIcon as CastEnterIconPrimitive, CastExitIcon as CastExitIconPrimitive, RestartIcon as RestartIconPrimitive, CheckIcon as CheckIconPrimitive, GearIcon as GearIconPrimitive } from '@videojs/react/icons';
import { SpinnerIcon as SpinnerIconPrimitive2, ChevronIcon as ChevronIconPrimitive2, CaptionsOnIcon as CaptionsOnIconPrimitive2, CaptionsOffIcon as CaptionsOffIconPrimitive2, FullscreenEnterIcon as FullscreenEnterIconPrimitive2, FullscreenExitIcon as FullscreenExitIconPrimitive2, PipEnterIcon as PipEnterIconPrimitive2, PipExitIcon as PipExitIconPrimitive2, PlayIcon as PlayIconPrimitive2, PauseIcon as PauseIconPrimitive2, VolumeHighIcon as VolumeHighIconPrimitive2, VolumeLowIcon as VolumeLowIconPrimitive2, VolumeOffIcon as VolumeOffIconPrimitive2, AirPlayEnterIcon as AirPlayEnterIconPrimitive2, AirPlayExitIcon as AirPlayExitIconPrimitive2, CastEnterIcon as CastEnterIconPrimitive2, CastExitIcon as CastExitIconPrimitive2, RestartIcon as RestartIconPrimitive2, CheckIcon as CheckIconPrimitive2, GearIcon as GearIconPrimitive2 } from '@videojs/react/icons/minimal';
import { BufferingIndicator as BufferingIndicatorPrimitive, ErrorDialog as ErrorDialogPrimitive, Container as ContainerPrimitive, Poster as PosterPrimitive, Gesture, Hotkey, SeekIndicator as SeekIndicatorPrimitive, StatusAnnouncer as StatusAnnouncerPrimitive, StatusIndicator as StatusIndicatorPrimitive, VolumeIndicator as VolumeIndicatorPrimitive, Tooltip, AirPlayButton as AirPlayButtonPrimitive, CaptionsButton as CaptionsButtonPrimitive, CastButton as CastButtonPrimitive, FullscreenButton as FullscreenButtonPrimitive, PiPButton as PiPButtonPrimitive, PlayButton as PlayButtonPrimitive, MuteButton as MuteButtonPrimitive, VolumeSlider as VolumeSliderPrimitive, VolumePopover as VolumePopoverPrimitive, TimeSlider as TimeSliderPrimitive, Slider, Menu, Text as TextPrimitive, Controls, Time } from '@videojs/react';
import { Video } from '@videojs/react/video';
import './player.css';
import { Player } from './player';
import { type VolumeSliderProps as CoreVolumeSliderProps, type SliderPreviewOverflow } from '@videojs/core';
import { audioText, captionsText, speedText, qualityText, settingsText } from '@videojs/core/i18n/text/menu';
import { type Poster } from '@videojs/react';

type ContainerProps = ContainerPrimitive.Props;

function Container({ children, className, ...props }: ContainerProps) {
  return (
    <ContainerPrimitive className={`media-container ${className ?? ''}`} {...props}>
      {children}
    </ContainerPrimitive>
  );
}

interface PosterProps extends Omit<PosterPrimitive.Props, 'children' | 'render'> {
  children?: PosterPrimitive.Props['render'];
}

function Poster({ children, className, src, ...props }: PosterProps = {}) {
  return (
    <PosterPrimitive
      render={children}
      className={(state) => `media-poster ${resolveClassName(className, state)}`}
      src={src}
      {...props}
    />
  );
}

interface DefaultVideoSkinProps extends Omit<NonNullable<ComponentProps<typeof Container>>, 'children'> {
  src: string;
  poster?: string | undefined;
  poster?: string | NonNullable<ComponentProps<typeof Poster>>['children'];
}

type BufferingIndicatorProps = Omit<BufferingIndicatorPrimitive.Props, 'children'>;

function BufferingIndicator({ className, ...props }: BufferingIndicatorProps = {}) {
  return (
    <BufferingIndicatorPrimitive
      className={(state) => `media-buffering-indicator ${resolveClassName(className, state)}`}
      {...props}
    >
      <>
        <span className="contents theme-minimal:hidden">
          <SpinnerIconPrimitive className={'media-buffering-indicator-spinner-icon'} />
        </span>
        <span className="hidden theme-minimal:contents">
          <SpinnerIconPrimitive2 className={'media-buffering-indicator-spinner-icon'} />
        </span>
      </>
    </BufferingIndicatorPrimitive>
  );
}

type ErrorDialogProps = Omit<ErrorDialogPrimitive.PopupProps, 'children'>;

type ButtonProps = ComponentProps<'button'>;

type PlayButtonProps = Omit<PlayButtonPrimitive.Props, 'children'>;

interface ButtonTooltipProps extends Tooltip.RootProps {
  children: ReactElement;
  label?: ReactNode;
}

function ButtonTooltip({ children, label, ...props }: ButtonTooltipProps) {
  return (
    <Tooltip.Root {...props}>
      <Tooltip.Trigger render={children} />
      <Tooltip.Popup className={'media-tooltip'}>
        {label ?? <Tooltip.Label />}
        {!label && <Tooltip.Shortcut className={'media-tooltip-shortcut'} />}
      </Tooltip.Popup>
    </Tooltip.Root>
  );
}

function PlayButton({ className, ...props }: PlayButtonProps = {}) {
  return (
    <ButtonTooltip side="top">
      <PlayButtonPrimitive
        render={<Button />}
        className={(state) => `media-play-button ${resolveClassName(className, state)}`}
        {...props}
      >
        <>
          <span className="contents theme-minimal:hidden">
            <RestartIconPrimitive className={'media-button-icon media-play-button-restart-icon'} />
          </span>
          <span className="hidden theme-minimal:contents">
            <RestartIconPrimitive2 className={'media-button-icon media-play-button-restart-icon'} />
          </span>
        </>
        <>
          <span className="contents theme-minimal:hidden">
            <PlayIconPrimitive className={'media-button-icon media-play-button-play-icon'} />
          </span>
          <span className="hidden theme-minimal:contents">
            <PlayIconPrimitive2 className={'media-button-icon media-play-button-play-icon'} />
          </span>
        </>
        <>
          <span className="contents theme-minimal:hidden">
            <PauseIconPrimitive className={'media-button-icon media-play-button-pause-icon'} />
          </span>
          <span className="hidden theme-minimal:contents">
            <PauseIconPrimitive2 className={'media-button-icon media-play-button-pause-icon'} />
          </span>
        </>
      </PlayButtonPrimitive>
    </ButtonTooltip>
  );
}

interface VolumePopoverProps extends Omit<VolumePopoverPrimitive.RootProps, 'children'> {
  className?: string | undefined;
  orientation?: CoreVolumeSliderProps['orientation'];
  showTooltip?: boolean;
  popupClassName?: ClassValue;
}

type MuteButtonProps = Omit<MuteButtonPrimitive.Props, 'children'>;

function MuteButton({ className, ...props }: MuteButtonProps = {}) {
  return (
    <MuteButtonPrimitive
      render={<Button />}
      className={(state) => `media-mute-button ${resolveClassName(className, state)}`}
      {...props}
    >
      <>
        <span className="contents theme-minimal:hidden">
          <VolumeOffIconPrimitive className={'media-button-icon media-mute-button-off-icon'} />
        </span>
        <span className="hidden theme-minimal:contents">
          <VolumeOffIconPrimitive2 className={'media-button-icon media-mute-button-off-icon'} />
        </span>
      </>
      <>
        <span className="contents theme-minimal:hidden">
          <VolumeLowIconPrimitive className={'media-button-icon media-mute-button-low-icon'} />
        </span>
        <span className="hidden theme-minimal:contents">
          <VolumeLowIconPrimitive2 className={'media-button-icon media-mute-button-low-icon'} />
        </span>
      </>
      <>
        <span className="contents theme-minimal:hidden">
          <VolumeHighIconPrimitive className={'media-button-icon media-mute-button-high-icon'} />
        </span>
        <span className="hidden theme-minimal:contents">
          <VolumeHighIconPrimitive2 className={'media-button-icon media-mute-button-high-icon'} />
        </span>
      </>
    </MuteButtonPrimitive>
  );
}

type VolumeSliderProps = Omit<VolumeSliderPrimitive.RootProps, 'children'>;

function VolumeSlider({ className, ...props }: VolumeSliderProps = {}) {
  return (
    <VolumeSliderPrimitive.Root
      className={(state) => `media-slider media-volume-slider ${resolveClassName(className, state)}`}
      thumbAlignment="edge"
      {...props}
    >
      <VolumeSliderPrimitive.Track render={<SliderTrack />}>
        <VolumeSliderPrimitive.Fill render={<SliderFill />} />
      </VolumeSliderPrimitive.Track>
      <VolumeSliderPrimitive.Thumb render={<SliderThumb />} className={'media-volume-slider-thumb'} />
    </VolumeSliderPrimitive.Root>
  );
}

interface TimeSliderProps extends Omit<TimeSliderPrimitive.RootProps, 'children'> {
  previewOverflow?: SliderPreviewOverflow | undefined;
}

function TimeSlider({ className, previewOverflow = 'visible', ...props }: TimeSliderProps = {}) {
  return (
    <TimeSliderPrimitive.Root
      className={(state) => `media-slider media-time-slider ${resolveClassName(className, state)}`}
      {...props}
    >
      <TimeSliderPrimitive.Chapters
        className={'media-time-slider-chapters'}
        renderChapter={(props) => (
          <div className={'media-time-slider-chapter'} {...props}>
            <TimeSliderPrimitive.Track render={<SliderTrack />} className={'media-time-slider-chapter-track'}>
              <TimeSliderPrimitive.Buffer render={<SliderBuffer />} />
              <TimeSliderPrimitive.Fill render={<SliderFill />} />
            </TimeSliderPrimitive.Track>
          </div>
        )}
      ></TimeSliderPrimitive.Chapters>
      <TimeSliderPrimitive.Thumb render={<SliderThumb />} className={'media-time-slider-thumb'} />
      <TimeSliderPrimitive.Preview className={'media-slider-preview'} overflow={previewOverflow}>
        <div className={'media-slider-thumbnail'}>
          <Slider.Thumbnail className={'media-slider-thumbnail-image'} />
          <>
            <span className="contents theme-minimal:hidden">
              <SpinnerIconPrimitive className={'media-slider-thumbnail-spinner-icon'} />
            </span>
            <span className="hidden theme-minimal:contents">
              <SpinnerIconPrimitive2 className={'media-slider-thumbnail-spinner-icon'} />
            </span>
          </>
        </div>
        <div className={'media-time-slider-preview-content'}>
          <TimeSliderPrimitive.ChapterTitle className={'media-time-slider-chapter-title'} />
          <TimeSliderPrimitive.Value className={'media-time-slider-value'} type="pointer" />
        </div>
      </TimeSliderPrimitive.Preview>
    </TimeSliderPrimitive.Root>
  );
}

type CaptionsButtonProps = Omit<CaptionsButtonPrimitive.Props, 'children'>;

function CaptionsButton({ className, ...props }: CaptionsButtonProps = {}) {
  return (
    <ButtonTooltip side="top">
      <CaptionsButtonPrimitive
        render={<Button />}
        className={(state) => `media-captions-button ${resolveClassName(className, state)}`}
        {...props}
      >
        <>
          <span className="contents theme-minimal:hidden">
            <CaptionsOffIconPrimitive className={'media-button-icon media-captions-button-off-icon'} />
          </span>
          <span className="hidden theme-minimal:contents">
            <CaptionsOffIconPrimitive2 className={'media-button-icon media-captions-button-off-icon'} />
          </span>
        </>
        <>
          <span className="contents theme-minimal:hidden">
            <CaptionsOnIconPrimitive className={'media-button-icon media-captions-button-on-icon'} />
          </span>
          <span className="hidden theme-minimal:contents">
            <CaptionsOnIconPrimitive2 className={'media-button-icon media-captions-button-on-icon'} />
          </span>
        </>
      </CaptionsButtonPrimitive>
    </ButtonTooltip>
  );
}

interface SettingsMenuProps extends Menu.RootProps {
  className?: string | undefined;
}

function SettingsMenu({ children, className, ...props }: SettingsMenuProps) {
  return (
    <Menu.Root side="top" align="center" {...props}>
      <ButtonTooltip label={<TextPrimitive token={settingsText.key}>{settingsText.text}</TextPrimitive>} side="top">
        <Menu.Trigger render={<Button />} className={'media-settings-menu-trigger'}>
          <>
            <span className="contents theme-minimal:hidden">
              <GearIconPrimitive className={'media-button-icon media-settings-menu-trigger-icon'} />
            </span>
            <span className="hidden theme-minimal:contents">
              <GearIconPrimitive2 className={'media-button-icon media-settings-menu-trigger-icon'} />
            </span>
          </>
          <TextPrimitive className={'media-settings-menu-trigger-label'} token={settingsText.key}>
            {settingsText.text}
          </TextPrimitive>
        </Menu.Trigger>
      </ButtonTooltip>
      <Menu.Popup keepMounted className={(state) => `media-menu-popup ${resolveClassName(className, state)}`}>
        <Menu.Content className={'media-menu-content'}>{children}</Menu.Content>
      </Menu.Popup>
    </Menu.Root>
  );
}

type VideoSettingsMenuProps = Omit<NonNullable<ComponentProps<typeof SettingsMenu>>, 'children'>;

interface QualityMenuProps extends MenuProps {
  className?: ClassValue;
}

interface MenuChevronProps {
  back?: boolean;
  className?: ClassValue;
}

function MenuChevron({ back = false, className }: MenuChevronProps = {}) {
  return (
    <>
      <span className="contents theme-minimal:hidden">
        <ChevronIconPrimitive
          className={`${back ? 'media-menu-back-chevron' : 'media-menu-forward-chevron'} ${className ?? ''}`}
        />
      </span>
      <span className="hidden theme-minimal:contents">
        <ChevronIconPrimitive2
          className={`${back ? 'media-menu-back-chevron' : 'media-menu-forward-chevron'} ${className ?? ''}`}
        />
      </span>
    </>
  );
}

type RadioItemProps = Menu.RadioItemProps;

function RadioItem({ children, className, ...props }: RadioItemProps) {
  return (
    <Menu.RadioItem className={(state) => `media-menu-radio-item ${resolveClassName(className, state)}`} {...props}>
      {children}
      <Menu.ItemIndicator forceMount className={'media-menu-item-indicator'}>
        <>
          <span className="contents theme-minimal:hidden">
            <CheckIconPrimitive className={'media-menu-radio-item-icon'} />
          </span>
          <span className="hidden theme-minimal:contents">
            <CheckIconPrimitive2 className={'media-menu-radio-item-icon'} />
          </span>
        </>
      </Menu.ItemIndicator>
    </Menu.RadioItem>
  );
}

function QualityMenu({ className, ...props }: QualityMenuProps = {}) {
  return (
    <Menu.Root {...props}>
      <QualityRadioGroup.Root>
        <Menu.Trigger className={'media-menu-trigger-item'}>
          <>
            <span className="contents theme-minimal:hidden">
              <SwitchesIconPrimitive className={'media-menu-trigger-item-icon'} />
            </span>
            <span className="hidden theme-minimal:contents">
              <SwitchesIconPrimitive2 className={'media-menu-trigger-item-icon'} />
            </span>
          </>
          <TextPrimitive token={qualityText.key}>{qualityText.text}</TextPrimitive>
          <span className={'media-menu-hint'}>
            <QualityRadioGroup.Value className={'media-menu-hint-label'} />
            <MenuChevron />
          </span>
        </Menu.Trigger>
        <Menu.Content className={(state) => `media-menu-content ${resolveClassName(className, state)}`}>
          <Menu.Item className={'media-menu-back-item'}>
            <MenuChevron back />
            <TextPrimitive token={qualityText.key}>{qualityText.text}</TextPrimitive>
          </Menu.Item>
          <Menu.Separator className={'media-menu-separator'} />
          <QualityRadioGroup.Options
            className={'media-menu-radio-group'}
            renderItem={(props, item) => (
              <RadioItem {...props}>
                <span>
                  <span>{item.label}</span>
                  {item.tier ? <sup className={'media-menu-tier'}>{item.tier}</sup> : null}
                </span>
                {item.badge ? <span className={'media-menu-badge'}>{item.badge}</span> : null}
              </RadioItem>
            )}
          ></QualityRadioGroup.Options>
        </Menu.Content>
      </QualityRadioGroup.Root>
    </Menu.Root>
  );
}

interface AudioTrackMenuProps extends MenuProps {
  className?: ClassValue;
}

function AudioTrackMenu({ className, ...props }: AudioTrackMenuProps = {}) {
  return (
    <Menu.Root {...props}>
      <AudioTrackRadioGroup.Root>
        <Menu.Trigger className={'media-menu-trigger-item'}>
          <>
            <span className="contents theme-minimal:hidden">
              <SpeechIconPrimitive className={'media-menu-trigger-item-icon'} />
            </span>
            <span className="hidden theme-minimal:contents">
              <SpeechIconPrimitive2 className={'media-menu-trigger-item-icon'} />
            </span>
          </>
          <TextPrimitive token={audioText.key}>{audioText.text}</TextPrimitive>
          <span className={'media-menu-hint'}>
            <AudioTrackRadioGroup.Value className={'media-menu-hint-label'} />
            <MenuChevron />
          </span>
        </Menu.Trigger>
        <Menu.Content className={(state) => `media-menu-content ${resolveClassName(className, state)}`}>
          <Menu.Item className={'media-menu-back-item'}>
            <MenuChevron back />
            <TextPrimitive token={audioText.key}>{audioText.text}</TextPrimitive>
          </Menu.Item>
          <Menu.Separator className={'media-menu-separator'} />
          <AudioTrackRadioGroup.Options
            className={'media-menu-radio-group'}
            renderItem={(props, item) => (
              <RadioItem {...props}>
                <span>{item.label}</span>
              </RadioItem>
            )}
          ></AudioTrackRadioGroup.Options>
        </Menu.Content>
      </AudioTrackRadioGroup.Root>
    </Menu.Root>
  );
}

interface PlaybackRateSubmenuProps extends MenuProps {
  className?: ClassValue;
}

function PlaybackRateSubmenu({ className, ...props }: PlaybackRateSubmenuProps = {}) {
  return (
    <Menu.Root {...props}>
      <PlaybackRateRadioGroup.Root>
        <Menu.Trigger className={'media-menu-trigger-item'}>
          <>
            <span className="contents theme-minimal:hidden">
              <SpeedIconPrimitive className={'media-menu-trigger-item-icon'} />
            </span>
            <span className="hidden theme-minimal:contents">
              <SpeedIconPrimitive2 className={'media-menu-trigger-item-icon'} />
            </span>
          </>
          <TextPrimitive token={speedText.key}>{speedText.text}</TextPrimitive>
          <span className={'media-menu-hint'}>
            <PlaybackRateRadioGroup.Value className={'media-menu-hint-label'} />
            <MenuChevron />
          </span>
        </Menu.Trigger>
        <Menu.Content className={(state) => `media-menu-content ${resolveClassName(className, state)}`}>
          <Menu.Item className={'media-menu-back-item'}>
            <MenuChevron back />
            <TextPrimitive token={speedText.key}>{speedText.text}</TextPrimitive>
          </Menu.Item>
          <Menu.Separator className={'media-menu-separator'} />
          <PlaybackRateRadioGroup.Options
            className={'media-menu-radio-group'}
            renderItem={(props, item) => (
              <RadioItem {...props}>
                <span>{item.label}</span>
              </RadioItem>
            )}
          ></PlaybackRateRadioGroup.Options>
        </Menu.Content>
      </PlaybackRateRadioGroup.Root>
    </Menu.Root>
  );
}

interface CaptionsSubmenuProps extends MenuProps {
  className?: ClassValue;
}

function CaptionsSubmenu({ className, ...props }: CaptionsSubmenuProps = {}) {
  return (
    <Menu.Root {...props}>
      <CaptionsRadioGroup.Root>
        <Menu.Trigger className={'media-menu-trigger-item'}>
          <>
            <span className="contents theme-minimal:hidden">
              <CaptionsOffIconPrimitive className={'media-menu-trigger-item-icon'} />
            </span>
            <span className="hidden theme-minimal:contents">
              <CaptionsOffIconPrimitive2 className={'media-menu-trigger-item-icon'} />
            </span>
          </>
          <TextPrimitive token={captionsText.key}>{captionsText.text}</TextPrimitive>
          <span className={'media-menu-hint'}>
            <CaptionsRadioGroup.Value className={'media-menu-hint-label'} />
            <MenuChevron />
          </span>
        </Menu.Trigger>
        <Menu.Content className={(state) => `media-menu-content ${resolveClassName(className, state)}`}>
          <Menu.Item className={'media-menu-back-item'}>
            <MenuChevron back />
            <TextPrimitive token={captionsText.key}>{captionsText.text}</TextPrimitive>
          </Menu.Item>
          <Menu.Separator className={'media-menu-separator'} />
          <CaptionsRadioGroup.Options
            className={'media-menu-radio-group'}
            renderItem={(props, item) => (
              <RadioItem {...props}>
                <span>{item.label}</span>
              </RadioItem>
            )}
          ></CaptionsRadioGroup.Options>
        </Menu.Content>
      </CaptionsRadioGroup.Root>
    </Menu.Root>
  );
}

function VideoSettingsMenu(props: VideoSettingsMenuProps = {}) {
  return (
    <SettingsMenu {...props}>
      <QualityMenu />
      <AudioTrackMenu />
      <PlaybackRateSubmenu />
      <CaptionsSubmenu />
    </SettingsMenu>
  );
}

type CastButtonProps = Omit<CastButtonPrimitive.Props, 'children'>;

function CastButton({ className, ...props }: CastButtonProps = {}) {
  return (
    <ButtonTooltip side="top">
      <CastButtonPrimitive
        render={<Button />}
        className={(state) => `media-cast-button ${resolveClassName(className, state)}`}
        {...props}
      >
        <>
          <span className="contents theme-minimal:hidden">
            <CastEnterIconPrimitive className={'media-button-icon media-cast-button-enter-icon'} />
          </span>
          <span className="hidden theme-minimal:contents">
            <CastEnterIconPrimitive2 className={'media-button-icon media-cast-button-enter-icon'} />
          </span>
        </>
        <>
          <span className="contents theme-minimal:hidden">
            <CastExitIconPrimitive className={'media-button-icon media-cast-button-exit-icon'} />
          </span>
          <span className="hidden theme-minimal:contents">
            <CastExitIconPrimitive2 className={'media-button-icon media-cast-button-exit-icon'} />
          </span>
        </>
      </CastButtonPrimitive>
    </ButtonTooltip>
  );
}

type AirPlayButtonProps = Omit<AirPlayButtonPrimitive.Props, 'children'>;

function AirPlayButton({ className, ...props }: AirPlayButtonProps = {}) {
  return (
    <ButtonTooltip side="top">
      <AirPlayButtonPrimitive
        render={<Button />}
        className={(state) => `media-airplay-button ${resolveClassName(className, state)}`}
        {...props}
      >
        <>
          <span className="contents theme-minimal:hidden">
            <AirPlayEnterIconPrimitive className={'media-button-icon media-airplay-button-enter-icon'} />
          </span>
          <span className="hidden theme-minimal:contents">
            <AirPlayEnterIconPrimitive2 className={'media-button-icon media-airplay-button-enter-icon'} />
          </span>
        </>
        <>
          <span className="contents theme-minimal:hidden">
            <AirPlayExitIconPrimitive className={'media-button-icon media-airplay-button-exit-icon'} />
          </span>
          <span className="hidden theme-minimal:contents">
            <AirPlayExitIconPrimitive2 className={'media-button-icon media-airplay-button-exit-icon'} />
          </span>
        </>
      </AirPlayButtonPrimitive>
    </ButtonTooltip>
  );
}

type PiPButtonProps = Omit<PiPButtonPrimitive.Props, 'children'>;

function PiPButton({ className, ...props }: PiPButtonProps = {}) {
  return (
    <ButtonTooltip side="top">
      <PiPButtonPrimitive
        render={<Button />}
        className={(state) => `media-pip-button ${resolveClassName(className, state)}`}
        {...props}
      >
        <>
          <span className="contents theme-minimal:hidden">
            <PipEnterIconPrimitive className={'media-button-icon media-pip-button-enter-icon'} />
          </span>
          <span className="hidden theme-minimal:contents">
            <PipEnterIconPrimitive2 className={'media-button-icon media-pip-button-enter-icon'} />
          </span>
        </>
        <>
          <span className="contents theme-minimal:hidden">
            <PipExitIconPrimitive className={'media-button-icon media-pip-button-exit-icon'} />
          </span>
          <span className="hidden theme-minimal:contents">
            <PipExitIconPrimitive2 className={'media-button-icon media-pip-button-exit-icon'} />
          </span>
        </>
      </PiPButtonPrimitive>
    </ButtonTooltip>
  );
}

type FullscreenButtonProps = Omit<FullscreenButtonPrimitive.Props, 'children'>;

function FullscreenButton({ className, ...props }: FullscreenButtonProps = {}) {
  return (
    <ButtonTooltip side="top">
      <FullscreenButtonPrimitive
        render={<Button />}
        className={(state) => `media-fullscreen-button ${resolveClassName(className, state)}`}
        {...props}
      >
        <>
          <span className="contents theme-minimal:hidden">
            <FullscreenEnterIconPrimitive className={'media-button-icon media-fullscreen-button-enter-icon'} />
          </span>
          <span className="hidden theme-minimal:contents">
            <FullscreenEnterIconPrimitive2 className={'media-button-icon media-fullscreen-button-enter-icon'} />
          </span>
        </>
        <>
          <span className="contents theme-minimal:hidden">
            <FullscreenExitIconPrimitive className={'media-button-icon media-fullscreen-button-exit-icon'} />
          </span>
          <span className="hidden theme-minimal:contents">
            <FullscreenExitIconPrimitive2 className={'media-button-icon media-fullscreen-button-exit-icon'} />
          </span>
        </>
      </FullscreenButtonPrimitive>
    </ButtonTooltip>
  );
}

function DefaultVideoControls() {
  return (
    <Controls.Root>
      <Controls.Backdrop className={'media-controls__backdrop'} />
      <Controls.Content className={'media-controls media-controls'}>
        <Tooltip.Provider>
          <Controls.Group className={'media-controls-primary'}>
            <PlayButton />
            <VolumePopover className={'media-controls-volume-button'} />

            <Controls.Group className={'media-time-slider-group'}>
              <Time.Value className={'media-time-current-value'} type="current" />
              <TimeSlider />
              <Time.Value className={'media-time-remaining-value'} type="remaining" toggle />
            </Controls.Group>

            <CaptionsButton className={'media-controls-captions-button'} />
            <VideoSettingsMenu className={'media-controls-settings-button'} />
          </Controls.Group>

          <Controls.Group className={'media-controls-secondary'}>
            <CastButton />
            <AirPlayButton />
            <PiPButton />
            <FullscreenButton />
          </Controls.Group>
        </Tooltip.Provider>
      </Controls.Content>
    </Controls.Root>
  );
}

interface VideoHotkeysProps {
  disabled?: boolean | undefined;
}

interface PlaybackHotkeysProps {
  disabled?: boolean | undefined;
}

function PlaybackHotkeys({ disabled = false }: PlaybackHotkeysProps = {}) {
  return (
    <>
      <Hotkey disabled={disabled} keys="Space" action="togglePaused" />
      <Hotkey disabled={disabled} keys="k" action="togglePaused" />
      <Hotkey disabled={disabled} keys="m" action="toggleMuted" />
      <Hotkey disabled={disabled} keys="ArrowRight" action="seekStep" value={5} />
      <Hotkey disabled={disabled} keys="ArrowLeft" action="seekStep" value={-5} />
      <Hotkey disabled={disabled} keys="l" action="seekStep" value={10} />
      <Hotkey disabled={disabled} keys="j" action="seekStep" value={-10} />
      <Hotkey disabled={disabled} keys="ArrowUp" action="volumeStep" value={0.05} />
      <Hotkey disabled={disabled} keys="ArrowDown" action="volumeStep" value={-0.05} />
      <Hotkey disabled={disabled} keys="0-9" action="seekToPercent" />
      <Hotkey disabled={disabled} keys="Home" action="seekToPercent" value={0} />
      <Hotkey disabled={disabled} keys="End" action="seekToPercent" value={100} />
      <Hotkey disabled={disabled} keys=">" action="speedUp" />
      <Hotkey disabled={disabled} keys="<" action="speedDown" />
    </>
  );
}

function VideoHotkeys({ disabled = false }: VideoHotkeysProps = {}) {
  return (
    <>
      <PlaybackHotkeys disabled={disabled} />
      <Hotkey disabled={disabled} keys="f" action="toggleFullscreen" />
      <Hotkey disabled={disabled} keys="c" action="toggleSubtitles" />
      <Hotkey disabled={disabled} keys="i" action="togglePictureInPicture" />
    </>
  );
}

interface VideoGesturesProps {
  disabled?: boolean | undefined;
}

function VideoGestures({ disabled = false }: VideoGesturesProps = {}) {
  return (
    <>
      <Gesture disabled={disabled} type="tap" action="togglePaused" pointer="mouse" region="center" />
      <Gesture disabled={disabled} type="tap" action="toggleControls" pointer="touch" />
      <Gesture disabled={disabled} type="doubletap" action="seekStep" value={-10} region="left" />
      <Gesture disabled={disabled} type="doubletap" action="toggleFullscreen" region="center" />
      <Gesture disabled={disabled} type="doubletap" action="seekStep" value={10} region="right" />
    </>
  );
}

type VideoStatusIndicatorsProps = Omit<ComponentProps<'div'>, 'children'>;

type StatusAnnouncerProps = Omit<StatusAnnouncerPrimitive.Props, 'children'>;

function StatusAnnouncer({ className, ...props }: StatusAnnouncerProps = {}) {
  return (
    <StatusAnnouncerPrimitive
      className={(state) => `media-status-announcer ${resolveClassName(className, state)}`}
      {...props}
    />
  );
}

type VolumeIndicatorProps = Omit<VolumeIndicatorPrimitive.RootProps, 'children'>;

function VolumeIndicator({ className, ...props }: VolumeIndicatorProps = {}) {
  return (
    <VolumeIndicatorPrimitive.Root
      className={(state) => `media-volume-indicator ${resolveClassName(className, state)}`}
      {...props}
    >
      <VolumeIndicatorPrimitive.Fill className={'media-volume-indicator-fill'}>
        <>
          <span className="contents theme-minimal:hidden">
            <VolumeHighIconPrimitive className={'media-volume-indicator-high-icon'} />
          </span>
          <span className="hidden theme-minimal:contents">
            <VolumeHighIconPrimitive2 className={'media-volume-indicator-high-icon'} />
          </span>
        </>
        <>
          <span className="contents theme-minimal:hidden">
            <VolumeLowIconPrimitive className={'media-volume-indicator-low-icon'} />
          </span>
          <span className="hidden theme-minimal:contents">
            <VolumeLowIconPrimitive2 className={'media-volume-indicator-low-icon'} />
          </span>
        </>
        <>
          <span className="contents theme-minimal:hidden">
            <VolumeOffIconPrimitive className={'media-volume-indicator-off-icon'} />
          </span>
          <span className="hidden theme-minimal:contents">
            <VolumeOffIconPrimitive2 className={'media-volume-indicator-off-icon'} />
          </span>
        </>
        <VolumeIndicatorPrimitive.Value className={'media-volume-indicator-value'} />
      </VolumeIndicatorPrimitive.Fill>
    </VolumeIndicatorPrimitive.Root>
  );
}

type StatusIndicatorProps = Omit<StatusIndicatorPrimitive.RootProps, 'children'>;

const TOP_STATUS_ACTIONS = ['toggleSubtitles', 'toggleFullscreen', 'togglePictureInPicture'] as const;

function StatusIndicator({ className, ...props }: StatusIndicatorProps = {}) {
  return (
    <StatusIndicatorPrimitive.Root
      actions={TOP_STATUS_ACTIONS}
      className={(state) => `media-status-indicator ${resolveClassName(className, state)}`}
      {...props}
    >
      <div className={'media-status-indicator-content'}>
        <>
          <span className="contents theme-minimal:hidden">
            <CaptionsOnIconPrimitive className={'media-status-indicator-captions-on-icon'} />
          </span>
          <span className="hidden theme-minimal:contents">
            <CaptionsOnIconPrimitive2 className={'media-status-indicator-captions-on-icon'} />
          </span>
        </>
        <>
          <span className="contents theme-minimal:hidden">
            <CaptionsOffIconPrimitive className={'media-status-indicator-captions-off-icon'} />
          </span>
          <span className="hidden theme-minimal:contents">
            <CaptionsOffIconPrimitive2 className={'media-status-indicator-captions-off-icon'} />
          </span>
        </>
        <>
          <span className="contents theme-minimal:hidden">
            <FullscreenEnterIconPrimitive className={'media-status-indicator-fullscreen-enter-icon'} />
          </span>
          <span className="hidden theme-minimal:contents">
            <FullscreenEnterIconPrimitive2 className={'media-status-indicator-fullscreen-enter-icon'} />
          </span>
        </>
        <>
          <span className="contents theme-minimal:hidden">
            <FullscreenExitIconPrimitive className={'media-status-indicator-fullscreen-exit-icon'} />
          </span>
          <span className="hidden theme-minimal:contents">
            <FullscreenExitIconPrimitive2 className={'media-status-indicator-fullscreen-exit-icon'} />
          </span>
        </>
        <>
          <span className="contents theme-minimal:hidden">
            <PipEnterIconPrimitive className={'media-status-indicator-pip-enter-icon'} />
          </span>
          <span className="hidden theme-minimal:contents">
            <PipEnterIconPrimitive2 className={'media-status-indicator-pip-enter-icon'} />
          </span>
        </>
        <>
          <span className="contents theme-minimal:hidden">
            <PipExitIconPrimitive className={'media-status-indicator-pip-exit-icon'} />
          </span>
          <span className="hidden theme-minimal:contents">
            <PipExitIconPrimitive2 className={'media-status-indicator-pip-exit-icon'} />
          </span>
        </>
        <StatusIndicatorPrimitive.Value className={'media-status-indicator-value'} />
      </div>
    </StatusIndicatorPrimitive.Root>
  );
}

type SeekIndicatorProps = Omit<SeekIndicatorPrimitive.RootProps, 'children'>;

function SeekIndicator({ className, ...props }: SeekIndicatorProps = {}) {
  return (
    <SeekIndicatorPrimitive.Root
      className={(state) => `media-seek-indicator ${resolveClassName(className, state)}`}
      {...props}
    >
      <>
        <span className="contents theme-minimal:hidden">
          <ChevronIconPrimitive className={'media-seek-indicator-icon'} />
        </span>
        <span className="hidden theme-minimal:contents">
          <ChevronIconPrimitive2 className={'media-seek-indicator-icon'} />
        </span>
      </>
      <SeekIndicatorPrimitive.Value className={'media-seek-indicator-value'} />
    </SeekIndicatorPrimitive.Root>
  );
}

type PlaybackStatusIndicatorProps = Omit<StatusIndicatorPrimitive.RootProps, 'children'>;

const PLAYBACK_STATUS_ACTIONS = ['togglePaused'] as const;

function PlaybackStatusIndicator({ className, ...props }: PlaybackStatusIndicatorProps = {}) {
  return (
    <StatusIndicatorPrimitive.Root
      actions={PLAYBACK_STATUS_ACTIONS}
      className={(state) => `media-playback-status-indicator ${resolveClassName(className, state)}`}
      {...props}
    >
      <>
        <span className="contents theme-minimal:hidden">
          <PlayIconPrimitive className={'media-playback-status-indicator-play-icon'} />
        </span>
        <span className="hidden theme-minimal:contents">
          <PlayIconPrimitive2 className={'media-playback-status-indicator-play-icon'} />
        </span>
      </>
      <>
        <span className="contents theme-minimal:hidden">
          <PauseIconPrimitive className={'media-playback-status-indicator-pause-icon'} />
        </span>
        <span className="hidden theme-minimal:contents">
          <PauseIconPrimitive2 className={'media-playback-status-indicator-pause-icon'} />
        </span>
      </>
    </StatusIndicatorPrimitive.Root>
  );
}

function VideoStatusIndicators({ className, ...props }: VideoStatusIndicatorsProps = {}) {
  return (
    <>
      <StatusAnnouncer />
      <div className={`media-video-status-indicators ${className ?? ''}`} {...props}>
        <VolumeIndicator />
        <StatusIndicator />
        <SeekIndicator />
        <PlaybackStatusIndicator />
      </div>
    </>
  );
}

function DefaultVideoSkin({ children, className, poster, ...props }: DefaultVideoSkinProps = {}) {
  const isPosterString = typeof poster === 'string';

  return (
    <Container className={`media-skin media-skin--video ${className ?? ''}`} {...props}>
      {children}
      <Poster src={isPosterString ? poster : undefined}>{isPosterString ? undefined : poster}</Poster>
      <BufferingIndicator />
      <ErrorDialog />

      <DefaultVideoControls />

      <VideoHotkeys />
      <VideoGestures />
      <VideoStatusIndicators />
    </Container>
  );
}

const Skin = DefaultVideoSkin;

// ================================================================
// Player
// ================================================================

export interface VideoPlayerProps {
  children?: ReactNode;
  style?: CSSProperties;
  className?: string;
  renderPoster?: Poster.Props['render'];
}

export function VideoSkin({ renderPoster, ...props }: VideoSkinProps) {
  return <Skin poster={renderPoster} {...props} />;
}

// ================================================================
// Components
// ================================================================

function Button({ className, ...props }: ButtonProps) {
  return <button className={`media-button ${className ?? ''}`} {...props} />;
}

type SliderTrackProps = ComponentProps<'div'>;

function SliderTrack({ className, ...props }: SliderTrackProps) {
  return <div className={`media-slider-track ${className ?? ''}`} {...props} />;
}

type SliderFillProps = ComponentProps<'div'>;

function SliderFill({ className, ...props }: SliderFillProps) {
  return <div className={`media-slider-fill ${className ?? ''}`} {...props} />;
}

type SliderThumbProps = ComponentProps<'div'>;

function SliderThumb({ className, ...props }: SliderThumbProps) {
  return <div className={`media-slider-thumb ${className ?? ''}`} {...props} />;
}

function VolumePopover({
  className,
  popupClassName,
  showTooltip = false,
  side = 'top',
  orientation = 'vertical',
  ...props
}: VolumePopoverProps = {}) {
  return (
    <VolumePopoverPrimitive.Root openOnHover delay={200} closeDelay={100} side={side} {...props}>
      <ButtonTooltip delay={0} disabled={!showTooltip} sticky side="top">
        <VolumePopoverPrimitive.Trigger render={<MuteButton className={className} />} />
      </ButtonTooltip>
      <VolumePopoverPrimitive.Popup className={`media-volume-popover ${popupClassName}`}>
        <VolumeSlider orientation={orientation} />
      </VolumePopoverPrimitive.Popup>
    </VolumePopoverPrimitive.Root>
  );
}

type SliderBufferProps = ComponentProps<'div'>;

function SliderBuffer({ className, ...props }: SliderBufferProps) {
  return <div className={`media-slider-buffer ${className ?? ''}`} {...props} />;
}

// ================================================================
// Error Dialog
// ================================================================

function ErrorDialog({ className, ...props }: ErrorDialogProps = {}) {
  return (
    <ErrorDialogPrimitive.Root>
      <ErrorDialogPrimitive.Backdrop className={'media-dialog-backdrop'} />
      <ErrorDialogPrimitive.Popup
        className={(state) => `media-dialog-popup ${resolveClassName(className, state)}`}
        {...props}
      >
        <div className={'media-dialog-content'}>
          <ErrorDialogPrimitive.Title className={'media-dialog-title'} />
          <ErrorDialogPrimitive.Description className={'media-dialog-description'} />
        </div>
        <div className={'media-dialog-actions'}>
          <ErrorDialogPrimitive.Close render={<Button />} className={'media-dialog-close'} />
        </div>
      </ErrorDialogPrimitive.Popup>
    </ErrorDialogPrimitive.Root>
  );
}

Skins, features, and presets

Each skin is built with specific features in mind. For example, a video skin renders fullscreen and picture-in-picture controls. An audio skin doesn’t.

You’ll find a preconfigured player, its typed hook, a skin, and the matching feature bundle exported from the same path. We call these paths presets.

ImportDescriptionDetails
@videojs/react/videoGeneral-purpose video player preset with full playback controls.
@videojs/react/audioAudio-only player preset with playback and volume controls.
@videojs/react/backgroundAmbient background video preset with no user controls.
@videojs/react/live-audioLive audio player preset — audio minus playback rate, plus the live feature, with a skin that swaps the time slider and time displays for a Live button.
@videojs/react/live-videoLive video player preset — video minus playback rate, quality selection, and audio-track selection, plus the live feature, with a skin that swaps the time slider and time displays for a Live button.

Styling

Packaged skins use vanilla CSS. Their documented CSS custom properties cover common visual changes.

Import the stylesheet listed for each skin in the preset table.

To use Tailwind or change the underlying CSS, eject the skin and edit its styles in your app.

Shared limitations

  • Skin spacing and icons use a fixed 16px base by default. Vanilla CSS font sizes use the same base, while Tailwind font sizes use rem units and assume a 16px root font size.
  • The default font stack includes Inter, but we do not load the webfont for you. If Inter is unavailable, the skin uses system fonts.

Ejected vanilla CSS

  • We use a BEM classname structure and every component classname is scoped with a media- prefix.

Ejected Tailwind

  • Ejected skins establish their own spacing scale but otherwise assume the default configuration. A future CLI eject command will support custom class prefixes. For now, edit the ejected skin to use your prefix.
  • Ejected skins currently support Tailwind 4.3.x.