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 CSSProperties, type ComponentProps, forwardRef, type ReactNode } from 'react';
import { audioText, captionsText, playbackRateText, qualityText, settingsText, speedText } from '@videojs/core/i18n/text/menu';
import { AirPlayEnterIcon, AirPlayExitIcon, CaptionsOffIcon, CaptionsOnIcon, CastEnterIcon, CastExitIcon, CheckIcon, ChevronIcon, FullscreenEnterIcon, FullscreenExitIcon, GearIcon, PauseIcon, PipEnterIcon, PipExitIcon, PlayIcon, QualityIcon, RestartIcon, SpeechIcon, SpeedIcon, SpinnerIcon, VolumeHighIcon, VolumeLowIcon, VolumeOffIcon } from '@videojs/react/icons';
import { Poster, useTranslator, Container, usePlayer, AirPlayButton, useAudioTrackOptions, AudioTrackRadioGroup, BufferingIndicator, CaptionsButton, CaptionsRadioGroup, useCaptionsOptions, CastButton, Controls, ErrorDialog, FullscreenButton, Gesture, Hotkey, Menu, MuteButton, PiPButton, PlayButton, usePlaybackRateOptions, PlaybackRateRadioGroup, Popover, useQualityOptions, QualityRadioGroup, SeekIndicator, Slider, StatusAnnouncer, StatusIndicator, Time, TimeSlider, Tooltip, VolumeIndicator, VolumeSlider } from '@videojs/react';
import { Video } from '@videojs/react/video';
import './player.css';
import { Player } from './player';

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

const CENTER_STATUS_ACTIONS = ['togglePaused'] as const;

function MenuChevron({ flipped = false }: { flipped?: boolean }): ReactNode {
  return <ChevronIcon className={`media-icon media-menu__chevron ${flipped ? 'media-icon--flipped' : undefined}`} />;
}

function SettingsMenu(): ReactNode {
  const t = useTranslator();
  const playbackRate = usePlaybackRateOptions();
  const quality = useQualityOptions();
  const audioTrack = useAudioTrackOptions();
  const captions = useCaptionsOptions();
  const hasPlaybackRate = playbackRate?.state.availability === 'available';
  const hasQuality = quality?.state.availability === 'available';
  const hasAudioTrack = audioTrack?.state.availability === 'available';
  const hasCaptions = captions?.state.availability === 'available';
  if (!hasPlaybackRate && !hasQuality && !hasAudioTrack && !hasCaptions) return null;

  return (
    <Menu.Root side="top" align="center">
      <Tooltip.Root side="top">
        <Tooltip.Trigger
          render={
            <Menu.Trigger aria-label={t(settingsText)} className="media-button--settings" render={<Button />}>
              <GearIcon className="media-icon media-icon--settings" />
            </Menu.Trigger>
          }
        />
        <Tooltip.Popup className="media-surface media-tooltip">
          <Tooltip.Label>{t(settingsText)}</Tooltip.Label>
        </Tooltip.Popup>
      </Tooltip.Root>
      <Menu.Popup className="media-surface media-popover media-menu media-menu--settings">
        <Menu.Content className="media-menu__content">
          {hasQuality ? (
            <Menu.Root>
              <Menu.Trigger
                className="media-menu__item media-menu__item--submenu"
                render={(props) => (
                  <div {...props}>
                    <QualityIcon className="media-icon" />
                    <span>{t(qualityText)}</span>
                    <span className="media-menu__hint">
                      <bdi dir="auto" className="media-menu__hint-label">
                        {quality.selectedLabel}
                      </bdi>
                      <MenuChevron />
                    </span>
                  </div>
                )}
              />
              <Menu.Content className="media-menu__panel">
                <Menu.Item className="media-menu__back">
                  <MenuChevron flipped />
                  {t(qualityText)}
                </Menu.Item>
                <Menu.Separator className="media-menu__separator" />
                <QualityRadioGroup
                  className="media-menu__group"
                  aria-label={t(qualityText)}
                  renderItem={(props, item) => (
                    <Menu.RadioItem {...props} className="media-menu__item">
                      <bdi dir="auto">
                        {item.label}
                        {item.tier ? <sup className="media-menu__tier">{item.tier}</sup> : null}
                      </bdi>
                      {item.badge ? <span className="media-badge">{item.badge}</span> : null}
                      <Menu.ItemIndicator checked={item.checked} forceMount className="media-menu__indicator">
                        <CheckIcon className="media-icon" />
                      </Menu.ItemIndicator>
                    </Menu.RadioItem>
                  )}
                />
              </Menu.Content>
            </Menu.Root>
          ) : null}

          {hasAudioTrack ? (
            <Menu.Root>
              <Menu.Trigger
                className="media-menu__item media-menu__item--submenu"
                render={(props) => (
                  <div {...props}>
                    <SpeechIcon className="media-icon" />
                    <span>{t(audioText)}</span>
                    <span className="media-menu__hint">
                      <bdi dir="auto" className="media-menu__hint-label">
                        {audioTrack.selectedLabel}
                      </bdi>
                      <MenuChevron />
                    </span>
                  </div>
                )}
              />
              <Menu.Content className="media-menu__panel">
                <Menu.Item className="media-menu__back">
                  <MenuChevron flipped />
                  {t(audioText)}
                </Menu.Item>
                <Menu.Separator className="media-menu__separator" />
                <AudioTrackRadioGroup
                  className="media-menu__group"
                  aria-label={t(audioText)}
                  renderItem={(props, item) => (
                    <Menu.RadioItem {...props} className="media-menu__item">
                      <bdi dir="auto">{item.label}</bdi>
                      <Menu.ItemIndicator checked={item.checked} forceMount className="media-menu__indicator">
                        <CheckIcon className="media-icon" />
                      </Menu.ItemIndicator>
                    </Menu.RadioItem>
                  )}
                />
              </Menu.Content>
            </Menu.Root>
          ) : null}

          {hasPlaybackRate ? (
            <Menu.Root>
              <Menu.Trigger
                className="media-menu__item media-menu__item--submenu"
                render={(props) => (
                  <div {...props}>
                    <SpeedIcon className="media-icon" />
                    <span>{t(speedText)}</span>
                    <span className="media-menu__hint">
                      <bdi dir="auto" className="media-menu__hint-label">
                        {playbackRate.selectedLabel}
                      </bdi>
                      <MenuChevron />
                    </span>
                  </div>
                )}
              />
              <Menu.Content className="media-menu__panel">
                <Menu.Item className="media-menu__back">
                  <MenuChevron flipped />
                  {t(speedText)}
                </Menu.Item>
                <Menu.Separator className="media-menu__separator" />
                <PlaybackRateRadioGroup
                  className="media-menu__group"
                  aria-label={t(playbackRateText)}
                  renderItem={(props, item) => (
                    <Menu.RadioItem {...props} className="media-menu__item">
                      <bdi dir="auto">{item.label}</bdi>
                      <Menu.ItemIndicator checked={item.checked} forceMount className="media-menu__indicator">
                        <CheckIcon className="media-icon" />
                      </Menu.ItemIndicator>
                    </Menu.RadioItem>
                  )}
                />
              </Menu.Content>
            </Menu.Root>
          ) : null}

          {hasCaptions ? (
            <Menu.Root>
              <Menu.Trigger
                className="media-menu__item media-menu__item--submenu"
                render={(props) => (
                  <div {...props}>
                    <CaptionsOffIcon className="media-icon" />
                    <span>{t(captionsText)}</span>
                    <span className="media-menu__hint">
                      <bdi dir="auto" className="media-menu__hint-label">
                        {captions.selectedLabel}
                      </bdi>
                      <MenuChevron />
                    </span>
                  </div>
                )}
              />
              <Menu.Content className="media-menu__panel">
                <Menu.Item className="media-menu__back">
                  <MenuChevron flipped />
                  {t(captionsText)}
                </Menu.Item>
                <Menu.Separator className="media-menu__separator" />
                <CaptionsRadioGroup
                  className="media-menu__group"
                  aria-label={t(captionsText)}
                  renderItem={(props, item) => (
                    <Menu.RadioItem {...props} className="media-menu__item">
                      <bdi dir="auto">{item.label}</bdi>
                      <Menu.ItemIndicator checked={item.checked} forceMount className="media-menu__indicator">
                        <CheckIcon className="media-icon" />
                      </Menu.ItemIndicator>
                    </Menu.RadioItem>
                  )}
                />
              </Menu.Content>
            </Menu.Root>
          ) : null}
        </Menu.Content>
      </Menu.Popup>
    </Menu.Root>
  );
}

function CastControl() {
  return (
    <Tooltip.Root side="top">
      <Tooltip.Trigger
        render={
          <CastButton className="media-button--cast" render={<Button />}>
            <CastEnterIcon className="media-icon media-icon--cast-enter" />
            <CastExitIcon className="media-icon media-icon--cast-exit" />
          </CastButton>
        }
      />
      <Tooltip.Popup className="media-surface media-tooltip">
        <Tooltip.Label />
        <Tooltip.Shortcut className="media-tooltip__kbd" />
      </Tooltip.Popup>
    </Tooltip.Root>
  );
}

function AirPlayControl() {
  return (
    <Tooltip.Root side="top">
      <Tooltip.Trigger
        render={
          <AirPlayButton className="media-button--airplay" render={<Button />}>
            <AirPlayEnterIcon className="media-icon media-icon--airplay-enter" />
            <AirPlayExitIcon className="media-icon media-icon--airplay-exit" />
          </AirPlayButton>
        }
      />
      <Tooltip.Popup className="media-surface media-tooltip">
        <Tooltip.Label />
        <Tooltip.Shortcut className="media-tooltip__kbd" />
      </Tooltip.Popup>
    </Tooltip.Root>
  );
}

function PiPControl() {
  return (
    <Tooltip.Root side="top">
      <Tooltip.Trigger
        render={
          <PiPButton className="media-button--pip" render={<Button />}>
            <PipEnterIcon className="media-icon media-icon--pip-enter" />
            <PipExitIcon className="media-icon media-icon--pip-exit" />
          </PiPButton>
        }
      />
      <Tooltip.Popup className="media-surface media-tooltip">
        <Tooltip.Label />
        <Tooltip.Shortcut className="media-tooltip__kbd" />
      </Tooltip.Popup>
    </Tooltip.Root>
  );
}

function FullscreenControl() {
  return (
    <Tooltip.Root side="top">
      <Tooltip.Trigger
        render={
          <FullscreenButton className="media-button--fullscreen" render={<Button />}>
            <FullscreenEnterIcon className="media-icon media-icon--fullscreen-enter" />
            <FullscreenExitIcon className="media-icon media-icon--fullscreen-exit" />
          </FullscreenButton>
        }
      />
      <Tooltip.Popup className="media-surface media-tooltip">
        <Tooltip.Label />
        <Tooltip.Shortcut className="media-tooltip__kbd" />
      </Tooltip.Popup>
    </Tooltip.Root>
  );
}

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

const SEEK_TIME = 10;

export interface VideoPlayerProps {
  src: string;
  poster?: string | undefined;
  style?: CSSProperties;
  className?: string;
  renderPoster?: Poster.Props['render'];
}

/**
 * @example
 * ```tsx
 * <VideoPlayer
 *   src="https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/highest.mp4"
 *   poster="https://image.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/thumbnail.webp"
 * />
 * ```
 */
export function VideoPlayer({ src, poster, className, renderPoster, style, ...rest }: VideoPlayerProps): ReactNode {
  return (
    <Player poster={poster}>
      <Container className={`media-default-skin media-default-skin--video ${className ?? ''}`} style={style} {...rest}>
        <Video src={src} playsInline />

        <Poster render={renderPoster} />

        <BufferingIndicator
          render={(props) => (
            <div {...props} className="media-buffering-indicator">
              <SpinnerIcon className="media-icon" />
            </div>
          )}
        />

        <ErrorDialog.Root>
          <ErrorDialog.Backdrop className="media-overlay media-error-backdrop" />
          <ErrorDialog.Popup className="media-error">
            <div className="media-error__dialog media-surface">
              <div className="media-error__content">
                <ErrorDialog.Title className="media-error__title"></ErrorDialog.Title>
                <ErrorDialog.Description className="media-error__description" />
              </div>
              <div className="media-error__actions">
                <ErrorDialog.Close className="media-button media-button--primary"></ErrorDialog.Close>
              </div>
            </div>
          </ErrorDialog.Popup>
        </ErrorDialog.Root>

        <Controls.Root>
          <Controls.Backdrop className="media-overlay" />
          <Controls.Content className="media-surface media-controls media-controls--root">
            <Tooltip.Provider>
              <div className="media-surface media-controls media-controls--primary">
                <div className="media-button-group">
                  <Tooltip.Root side="top">
                    <Tooltip.Trigger
                      render={
                        <PlayButton className="media-button--play" render={<Button />}>
                          <RestartIcon className="media-icon media-icon--restart" />
                          <PlayIcon className="media-icon media-icon--play" />
                          <PauseIcon className="media-icon media-icon--pause" />
                        </PlayButton>
                      }
                    />
                    <Tooltip.Popup className="media-surface media-tooltip">
                      <Tooltip.Label />
                      <Tooltip.Shortcut className="media-tooltip__kbd" />
                    </Tooltip.Popup>
                  </Tooltip.Root>

                  <VolumePopover />
                </div>

                <div className="media-time-controls">
                  <Time.Value type="current" className="media-time" />
                  <TimeSlider.Root className="media-slider">
                    <TimeSlider.Chapters
                      className="media-slider__chapters"
                      renderChapter={(props) => (
                        <div {...props} className={`${props.className} media-slider__chapter`}>
                          <TimeSlider.Track className="media-slider__track media-slider__chapter-track">
                            <TimeSlider.Buffer className="media-slider__buffer" />
                            <TimeSlider.Fill className="media-slider__fill" />
                          </TimeSlider.Track>
                        </div>
                      )}
                    />
                    <TimeSlider.Thumb className="media-slider__thumb" />

                    <TimeSlider.Preview overflow="visible" className="media-slider__preview">
                      <div className="media-surface media-thumbnail media-slider__thumbnail">
                        <Slider.Thumbnail className="media-thumbnail__image" />
                        <SpinnerIcon className="media-thumbnail__spinner media-icon" />
                      </div>
                      <div className="media-slider__value">
                        <TimeSlider.ChapterTitle className="media-slider__chapter-title" />
                        <TimeSlider.Value type="pointer" className="media-time" />
                      </div>
                    </TimeSlider.Preview>
                  </TimeSlider.Root>
                  <Time.Value toggle type="remaining" className="media-time" />
                </div>

                <div className="media-button-group">
                  <Tooltip.Root side="top">
                    <Tooltip.Trigger
                      render={
                        <CaptionsButton className="media-button--captions" render={<Button />}>
                          <CaptionsOffIcon className="media-icon media-icon--captions-off" />
                          <CaptionsOnIcon className="media-icon media-icon--captions-on" />
                        </CaptionsButton>
                      }
                    />
                    <Tooltip.Popup className="media-surface media-tooltip">
                      <Tooltip.Label />
                      <Tooltip.Shortcut className="media-tooltip__kbd" />
                    </Tooltip.Popup>
                  </Tooltip.Root>

                  <SettingsMenu />
                </div>
              </div>

              <div className="media-surface media-controls media-controls--secondary">
                <div className="media-button-group">
                  <CastControl />
                  <AirPlayControl />
                  <PiPControl />
                  <FullscreenControl />
                </div>
              </div>
            </Tooltip.Provider>
          </Controls.Content>
        </Controls.Root>

        {/* Hotkeys */}
        <Hotkey keys="Space" action="togglePaused" />
        <Hotkey keys="k" action="togglePaused" />
        <Hotkey keys="m" action="toggleMuted" />
        <Hotkey keys="f" action="toggleFullscreen" />
        <Hotkey keys="c" action="toggleSubtitles" />
        <Hotkey keys="i" action="togglePictureInPicture" />
        <Hotkey keys="ArrowRight" action="seekStep" value={SEEK_TIME / 2} />
        <Hotkey keys="ArrowLeft" action="seekStep" value={-(SEEK_TIME / 2)} />
        <Hotkey keys="l" action="seekStep" value={SEEK_TIME} />
        <Hotkey keys="j" action="seekStep" value={-SEEK_TIME} />
        <Hotkey keys="ArrowUp" action="volumeStep" value={0.05} />
        <Hotkey keys="ArrowDown" action="volumeStep" value={-0.05} />
        <Hotkey keys="0-9" action="seekToPercent" />
        <Hotkey keys="Home" action="seekToPercent" value={0} />
        <Hotkey keys="End" action="seekToPercent" value={100} />
        <Hotkey keys=">" action="speedUp" />
        <Hotkey keys="<" action="speedDown" />

        {/* Gestures */}
        <Gesture type="tap" action="togglePaused" pointer="mouse" region="center" />
        <Gesture type="tap" action="toggleControls" pointer="touch" />
        <Gesture type="doubletap" action="seekStep" value={-SEEK_TIME} region="left" />
        <Gesture type="doubletap" action="toggleFullscreen" region="center" />
        <Gesture type="doubletap" action="seekStep" value={SEEK_TIME} region="right" />

        {/* Input Indicators */}
        <StatusAnnouncer className="media-sr-only" />
        <div className="media-input-indicator-overlay">
          <VolumeIndicator.Root className="media-surface media-volume-indicator">
            <VolumeIndicator.Fill className="media-volume-indicator__content">
              <VolumeHighIcon className="media-icon media-icon--volume-high" />
              <VolumeLowIcon className="media-icon media-icon--volume-low" />
              <VolumeOffIcon className="media-icon media-icon--volume-off" />
              <VolumeIndicator.Value className="media-volume-indicator__value" />
            </VolumeIndicator.Fill>
          </VolumeIndicator.Root>

          <StatusIndicator.Root
            actions={TOP_STATUS_ACTIONS}
            className="media-surface media-status-indicator media-status-indicator--state"
          >
            <div className="media-status-indicator__content">
              <CaptionsOnIcon className="media-icon media-icon--captions-on" />
              <CaptionsOffIcon className="media-icon media-icon--captions-off" />
              <FullscreenEnterIcon className="media-icon media-icon--fullscreen-enter" />
              <FullscreenExitIcon className="media-icon media-icon--fullscreen-exit" />
              <PipEnterIcon className="media-icon media-icon--pip-enter" />
              <PipExitIcon className="media-icon media-icon--pip-exit" />
              <StatusIndicator.Value className="media-status-indicator__value" />
            </div>
          </StatusIndicator.Root>

          <SeekIndicator.Root className="media-seek-indicator">
            <ChevronIcon className="media-icon media-icon--seek" />
            <SeekIndicator.Value className="media-seek-indicator__value" />
          </SeekIndicator.Root>

          <StatusIndicator.Root
            actions={CENTER_STATUS_ACTIONS}
            className="media-status-indicator media-status-indicator--playback"
          >
            <PlayIcon className="media-icon media-icon--play" />
            <PauseIcon className="media-icon media-icon--pause" />
          </StatusIndicator.Root>
        </div>
      </Container>

    </Player>
  );
}

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

const Button = forwardRef<HTMLButtonElement, ComponentProps<'button'>>(function Button({ className, ...props }, ref) {
  return (
    <button
      ref={ref}
      type="button"
      className={`media-button media-button--subtle media-button--icon ${className ?? ''}`}
      {...props}
    />
  );
});

function VolumePopover(): ReactNode {
  const volumeUnavailable = usePlayer((s) => s.volumeAvailability !== 'available');

  const muteButton = (
    <MuteButton className="media-button--mute" render={<Button />}>
      <VolumeOffIcon className="media-icon media-icon--volume-off" />
      <VolumeLowIcon className="media-icon media-icon--volume-low" />
      <VolumeHighIcon className="media-icon media-icon--volume-high" />
    </MuteButton>
  );

  if (volumeUnavailable) return muteButton;

  return (
    <Popover.Root openOnHover delay={200} closeDelay={100} side="top">
      <Popover.Trigger render={muteButton} />
      <Popover.Popup className="media-surface media-popover media-popover--volume">
        <VolumeSlider.Root className="media-slider" orientation="vertical" thumbAlignment="edge">
          <VolumeSlider.Track className="media-slider__track">
            <VolumeSlider.Fill className="media-slider__fill" />
          </VolumeSlider.Track>
          <VolumeSlider.Thumb className="media-slider__thumb media-slider__thumb--persistent" />
        </VolumeSlider.Root>
      </Popover.Popup>
    </Popover.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 — same features as audio with a skin that omits duration / current-time displays.
@videojs/react/live-videoLive video player preset — same features as video with a skin that omits duration / current-time displays.

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.