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 ComponentProps, type SVGProps, type ReactElement, type ReactNode, type CSSProperties } from 'react';
import { ErrorDialog as ErrorDialogPrimitive, Container as ContainerPrimitive, Poster as PosterPrimitive, Gesture, Hotkey, BufferingIndicator as BufferingIndicatorPrimitive, Tooltip, AirPlayButton as AirPlayButtonPrimitive, CaptionsButton as CaptionsButtonPrimitive, CastButton as CastButtonPrimitive, FullscreenButton as FullscreenButtonPrimitive, PiPButton as PiPButtonPrimitive, PlayButton as PlayButtonPrimitive, VolumeSlider as VolumeSliderPrimitive, MuteButton as MuteButtonPrimitive, VolumePopover as VolumePopoverPrimitive, TimeSlider as TimeSliderPrimitive, Slider, Menu, Text as TextPrimitive, AudioTrackRadioGroup, CaptionsRadioGroup, PlaybackRateRadioGroup, QualityRadioGroup, Controls, Time, StatusAnnouncer as StatusAnnouncerPrimitive, SeekIndicator as SeekIndicatorPrimitive, StatusIndicator as StatusIndicatorPrimitive, VolumeIndicator as VolumeIndicatorPrimitive } from '@videojs/react';
import { Video } from '@videojs/react/video';
import './player.css';
import { Player } from './player';
import { SpinnerIcon as SpinnerIconPrimitive, AirPlayEnterIcon as AirPlayEnterIconPrimitive, AirPlayExitIcon as AirPlayExitIconPrimitive, CaptionsOffIcon as CaptionsOffIconPrimitive, CaptionsOnIcon as CaptionsOnIconPrimitive, CastEnterIcon as CastEnterIconPrimitive, CastExitIcon as CastExitIconPrimitive, FullscreenEnterIcon as FullscreenEnterIconPrimitive, FullscreenExitIcon as FullscreenExitIconPrimitive, PipEnterIcon as PipEnterIconPrimitive, PipExitIcon as PipExitIconPrimitive, RestartIcon as RestartIconPrimitive, PlayIcon as PlayIconPrimitive, PauseIcon as PauseIconPrimitive, VolumeOffIcon as VolumeOffIconPrimitive, VolumeLowIcon as VolumeLowIconPrimitive, VolumeHighIcon as VolumeHighIconPrimitive, ChevronIcon as ChevronIconPrimitive, CheckIcon as CheckIconPrimitive, SpeechIcon as SpeechIconPrimitive, SpeedIcon as SpeedIconPrimitive, SwitchesIcon as SwitchesIconPrimitive, GearIcon as GearIconPrimitive } from '@videojs/react/icons';
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-skin 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;
  renderPoster?: 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}
    >
      <SpinnerIconPrimitive className={'media-buffering-indicator-spinner-icon'} />
    </BufferingIndicatorPrimitive>
  );
}

type ButtonProps = ComponentProps<'button'>;

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-popup media-popup-safe-area media-popup-transition media-popup-surface media-tooltip'}
      >
        {label ?? <Tooltip.Label />}
        {!label && <Tooltip.Shortcut className={'media-tooltip-shortcut'} />}
      </Tooltip.Popup>
    </Tooltip.Root>
  );
}

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

function PlayButton({ className, ...props }: PlayButtonProps = {}) {
  return (
    <PlayButtonPrimitive
      render={<Button />}
      className={(state) => `media-play-button ${resolveClassName(className, state)}`}
      {...props}
    >
      <RestartIconPrimitive className={'media-button-icon media-play-button-restart-icon'} />
      <PlayIconPrimitive className={'media-button-icon media-play-button-play-icon'} />
      <PauseIconPrimitive className={'media-button-icon media-play-button-pause-icon'} />
    </PlayButtonPrimitive>
  );
}

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

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

function MuteButton({ className, ...props }: MuteButtonProps = {}) {
  return (
    <MuteButtonPrimitive
      render={<Button />}
      className={(state) => `media-mute-button ${resolveClassName(className, state)}`}
      {...props}
    >
      <VolumeOffIconPrimitive className={'media-button-icon media-mute-button-off-icon'} />
      <VolumeLowIconPrimitive className={'media-button-icon media-mute-button-low-icon'} />
      <VolumeHighIconPrimitive className={'media-button-icon media-mute-button-high-icon'} />
    </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-preview-content media-popup-surface media-slider-thumbnail'}>
          <Slider.Thumbnail className={'media-slider-thumbnail-image'} />
          <SpinnerIconPrimitive className={'media-slider-thumbnail-spinner-icon'} />
        </div>
        <div className={'media-slider-preview-content 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 (
    <CaptionsButtonPrimitive
      render={<Button />}
      className={(state) => `media-captions-button ${resolveClassName(className, state)}`}
      {...props}
    >
      <CaptionsOffIconPrimitive className={'media-button-icon media-captions-button-off-icon'} />
      <CaptionsOnIconPrimitive className={'media-button-icon media-captions-button-on-icon'} />
    </CaptionsButtonPrimitive>
  );
}

interface SettingsMenuProps extends Menu.RootProps {
  className?: ClassValue;
}

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={(state) => `media-settings-menu-trigger ${resolveClassName(className, state)}`}
        >
          <GearIconPrimitive className={'media-button-icon media-settings-menu-trigger-icon'} />
          <TextPrimitive className={'media-settings-menu-trigger-label'} token={settingsText.key}>
            {settingsText.text}
          </TextPrimitive>
        </Menu.Trigger>
      </ButtonTooltip>
      <Menu.Popup
        keepMounted
        className={'media-popup media-popup-surface media-menu-popup media-menu-resizable-popup'}
      >
        <Menu.Content className={'media-menu-content'}>{children}</Menu.Content>
      </Menu.Popup>
    </Menu.Root>
  );
}

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

type QualityMenuProps = Omit<Menu.RootProps, 'children'>;

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

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

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'}>
        <CheckIconPrimitive className={'media-menu-radio-item-icon'} />
      </Menu.ItemIndicator>
    </Menu.RadioItem>
  );
}

function QualityMenu({ ...props }: QualityMenuProps = {}) {
  return (
    <Menu.Root {...props}>
      <QualityRadioGroup.Root>
        <Menu.Trigger className={'media-menu-trigger-item'}>
          <SwitchesIconPrimitive className={'media-menu-trigger-item-icon'} />
          <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={'media-menu-content'}>
          <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>
  );
}

type AudioTrackMenuProps = Omit<Menu.RootProps, 'children'>;

function AudioTrackMenu({ ...props }: AudioTrackMenuProps = {}) {
  return (
    <Menu.Root {...props}>
      <AudioTrackRadioGroup.Root>
        <Menu.Trigger className={'media-menu-trigger-item'}>
          <SpeechIconPrimitive className={'media-menu-trigger-item-icon'} />
          <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={'media-menu-content'}>
          <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>
  );
}

type PlaybackRateSubmenuProps = Omit<Menu.RootProps, 'children'>;

function PlaybackRateSubmenu({ ...props }: PlaybackRateSubmenuProps = {}) {
  return (
    <Menu.Root {...props}>
      <PlaybackRateRadioGroup.Root>
        <Menu.Trigger className={'media-menu-trigger-item'}>
          <SpeedIconPrimitive className={'media-menu-trigger-item-icon'} />
          <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={'media-menu-content'}>
          <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>
  );
}

type CaptionsSubmenuProps = Omit<Menu.RootProps, 'children'>;

function CaptionsSubmenu({ ...props }: CaptionsSubmenuProps = {}) {
  return (
    <Menu.Root {...props}>
      <CaptionsRadioGroup.Root>
        <Menu.Trigger className={'media-menu-trigger-item'}>
          <CaptionsOffIconPrimitive className={'media-menu-trigger-item-icon'} />
          <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={'media-menu-content'}>
          <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 (
    <CastButtonPrimitive
      render={<Button />}
      className={(state) => `media-cast-button ${resolveClassName(className, state)}`}
      {...props}
    >
      <CastEnterIconPrimitive className={'media-button-icon media-cast-button-enter-icon'} />
      <CastExitIconPrimitive className={'media-button-icon media-cast-button-exit-icon'} />
    </CastButtonPrimitive>
  );
}

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

function AirPlayButton({ className, ...props }: AirPlayButtonProps = {}) {
  return (
    <AirPlayButtonPrimitive
      render={<Button />}
      className={(state) => `media-airplay-button ${resolveClassName(className, state)}`}
      {...props}
    >
      <AirPlayEnterIconPrimitive className={'media-button-icon media-airplay-button-enter-icon'} />
      <AirPlayExitIconPrimitive className={'media-button-icon media-airplay-button-exit-icon'} />
    </AirPlayButtonPrimitive>
  );
}

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

function PiPButton({ className, ...props }: PiPButtonProps = {}) {
  return (
    <PiPButtonPrimitive
      render={<Button />}
      className={(state) => `media-pip-button ${resolveClassName(className, state)}`}
      {...props}
    >
      <PipEnterIconPrimitive className={'media-button-icon media-pip-button-enter-icon'} />
      <PipExitIconPrimitive className={'media-button-icon media-pip-button-exit-icon'} />
    </PiPButtonPrimitive>
  );
}

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

function FullscreenButton({ className, ...props }: FullscreenButtonProps = {}) {
  return (
    <FullscreenButtonPrimitive
      render={<Button />}
      className={(state) => `media-fullscreen-button ${resolveClassName(className, state)}`}
      {...props}
    >
      <FullscreenEnterIconPrimitive className={'media-button-icon media-fullscreen-button-enter-icon'} />
      <FullscreenExitIconPrimitive className={'media-button-icon media-fullscreen-button-exit-icon'} />
    </FullscreenButtonPrimitive>
  );
}

function DefaultVideoControls() {
  return (
    <Controls.Root>
      <Controls.Backdrop className={'video-controls-backdrop'} />
      <Controls.Content className={'video-controls video-controls-content'}>
        <Tooltip.Provider>
          <Controls.Group className={'video-controls-primary'}>
            <ButtonTooltip side="top">
              <PlayButton />
            </ButtonTooltip>
            <VolumePopover className={'video-controls-volume-button'} />

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

            <ButtonTooltip side="top">
              <CaptionsButton className={'video-controls-captions-button'} />
            </ButtonTooltip>
            <VideoSettingsMenu className={'video-controls-settings-button'} />
          </Controls.Group>

          <Controls.Group className={'video-controls-secondary'}>
            <ButtonTooltip side="top">
              <CastButton />
            </ButtonTooltip>
            <ButtonTooltip side="top">
              <AirPlayButton />
            </ButtonTooltip>
            <ButtonTooltip side="top">
              <PiPButton />
            </ButtonTooltip>
            <ButtonTooltip side="top">
              <FullscreenButton />
            </ButtonTooltip>
          </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" />
      <Hotkey disabled={disabled} keys="ArrowLeft" action="seekStep" />
      <Hotkey disabled={disabled} keys="l" action="seekStep" />
      <Hotkey disabled={disabled} keys="j" action="seekStep" />
      <Hotkey disabled={disabled} keys="ArrowUp" action="volumeStep" />
      <Hotkey disabled={disabled} keys="ArrowDown" action="volumeStep" />
      <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" region="left" />
      <Gesture disabled={disabled} type="doubletap" action="toggleFullscreen" region="center" />
      <Gesture disabled={disabled} type="doubletap" action="seekStep" 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-indicator media-volume-indicator ${resolveClassName(className, state)}`}
      {...props}
    >
      <VolumeIndicatorPrimitive.Fill className={'media-indicator-content media-volume-indicator-fill'}>
        <VolumeHighIconPrimitive className={'media-volume-indicator-high-icon'} />
        <VolumeLowIconPrimitive className={'media-volume-indicator-low-icon'} />
        <VolumeOffIconPrimitive className={'media-volume-indicator-off-icon'} />
        <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-indicator media-status-indicator ${resolveClassName(className, state)}`}
      {...props}
    >
      <div className={'media-indicator-content media-status-indicator-content'}>
        <CaptionsOnIconPrimitive className={'media-status-indicator-captions-on-icon'} />
        <CaptionsOffIconPrimitive className={'media-status-indicator-captions-off-icon'} />
        <FullscreenEnterIconPrimitive className={'media-status-indicator-fullscreen-enter-icon'} />
        <FullscreenExitIconPrimitive className={'media-status-indicator-fullscreen-exit-icon'} />
        <PipEnterIconPrimitive className={'media-status-indicator-pip-enter-icon'} />
        <PipExitIconPrimitive className={'media-status-indicator-pip-exit-icon'} />
        <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}
    >
      <ChevronIconPrimitive className={'media-seek-indicator-icon'} />
      <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}
    >
      <PlayIconPrimitive className={'media-playback-status-indicator-play-icon'} />
      <PauseIconPrimitive className={'media-playback-status-indicator-pause-icon'} />
    </StatusIndicatorPrimitive.Root>
  );
}

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

function DefaultVideoSkin({ children, className, renderPoster, ...props }: DefaultVideoSkinProps = {}) {
  return (
    <Container className={`video-skin ${className ?? ''}`} data-theme="default" data-preset="video" {...props}>
      {children}
      <Poster>{renderPoster}</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(props: VideoSkinProps) {
  return <Skin {...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,
  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-popup media-popup-safe-area media-popup-transition media-popup-surface media-volume-popover'}
      >
        <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() {
  return (
    <ErrorDialogPrimitive.Root>
      <ErrorDialogPrimitive.Backdrop className={'media-dialog-backdrop'} />
      <ErrorDialogPrimitive.Popup className={'media-dialog-popup'}>
        <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.