Skip to content

GuideGetting Started

Build your own skin

Create a skin project, preview it locally, and compose your own player interface from Video.js UI components

A skin is the player interface: markup that arranges Video.js UI components inside the player, plus the styles that give it a look. The components handle playback state, keyboard support, and accessibility, so building your own skin is mostly a layout and design job. Skins explains how skins fit into Video.js.

If a packaged skin is already close to what you want, Customize skins is less work.

Create a skin project

The quickest way to start is a standalone skin project:

pnpm dlx @videojs/cli skin create

The command asks for a name, a framework (React or HTML), a styling approach (Tailwind or CSS), a starting skin, and a preset (video, audio, live video, or live audio). The starting skin defaults to Scaffold: every control wired up, with minimal styling and no transitions, so there’s little to undo.

The project goes in a folder named after the skin, so a skin called “My Skin” goes in my-skin. Open it and start the preview:

cd my-skin
pnpm dev

The project has two parts:

  • src/ holds the skin and nothing else. Edit it however you like, including its markup, class names, theme tokens, and stylesheets.
  • apps/preview/ is a workbench that plays your skin. Use it to try different player sizes and aspect ratios, light and dark color schemes, right-to-left text, every built-in language, and playback settings such as autoplay and loop.

When you want to share it, deploy the workbench to Vercel, Netlify, or Cloudflare Pages:

pnpm run deploy

Add the host’s name to skip the question, as in npm run deploy vercel. With pnpm, keep the run, since pnpm deploy is a built-in pnpm command. When the skin is done, move src/ into your app.

Build it with a coding agent

If you work with a coding agent, such as Claude Code, Codex, or Cursor, it can do all of this for you. Install the Video.js skills, which include a skill for building skins:

npx @videojs/cli agents skills

Then ask your agent something like “Help me design my own Video.js skin for our podcast player.” It asks for the same choices as the command, creates the project, starts the workbench, and agrees a design with you before styling. It can also match an existing player’s look from a screenshot or a link, then checks its work against the reference and for accessibility before handing back. Build with AI covers installing the skills in each agent.

Other starting points

  • Inside an existing app. Add a skin’s source with Install with Shadcn and pick Scaffold as the skin.
  • From nothing. Put a Container inside your player and add the components you want, as in the example below.

No path requires Scaffold’s structure, its theme tokens, or any particular CSS approach.

A minimal skin

This example shows how a skin fits together without Scaffold’s extra parts. It has a play button, a timeline, the current time, mute, fullscreen, keyboard shortcuts, screen reader announcements, and an error dialog. The player provides state, the Container is the skin’s root, and the media element goes inside the Container next to the controls.

import {
  Container,
  Controls,
  ErrorDialog,
  FullscreenButton,
  Hotkey,
  MuteButton,
  PlayButton,
  StatusAnnouncer,
  Time,
  TimeSlider,
} from '@videojs/react';
import type { ReactNode } from 'react';
import './video-skin.css';

export function VideoSkin({ children }: { children: ReactNode }) {
  return (
    <Container className="video-skin">
      {children}

      <Controls.Root>
        <Controls.Content className="video-skin-controls">
          <TimeSlider.Root className="video-skin-time-slider">
            <TimeSlider.Track className="video-skin-track">
              <TimeSlider.Buffer className="video-skin-buffer" />
              <TimeSlider.Fill className="video-skin-fill" />
            </TimeSlider.Track>
            <TimeSlider.Thumb className="video-skin-thumb" />
          </TimeSlider.Root>

          <Controls.Group className="video-skin-controls-group" aria-label="Playback controls">
            <PlayButton
              className="video-skin-play"
              render={(props, state) => <button {...props}>{state.paused ? 'Play' : 'Pause'}</button>}
            />
            <Time.Value type="current" />
            <MuteButton
              className="video-skin-mute"
              render={(props, state) => <button {...props}>{state.muted ? 'Unmute' : 'Mute'}</button>}
            />
            <FullscreenButton
              className="video-skin-fullscreen"
              render={(props, state) => (
                <button {...props}>{state.fullscreen ? 'Exit fullscreen' : 'Fullscreen'}</button>
              )}
            />
          </Controls.Group>
        </Controls.Content>
      </Controls.Root>

      <Hotkey keys="Space" action="togglePaused" />
      <Hotkey keys="m" action="toggleMuted" />
      <Hotkey keys="f" action="toggleFullscreen" />
      <Hotkey keys="ArrowLeft" action="seekStep" value={-5} />
      <Hotkey keys="ArrowRight" action="seekStep" value={5} />

      <StatusAnnouncer />

      <ErrorDialog.Root>
        <ErrorDialog.Popup className="video-skin-error">
          <ErrorDialog.Title />
          <ErrorDialog.Description />
          <ErrorDialog.Close />
        </ErrorDialog.Popup>
      </ErrorDialog.Root>
    </Container>
  );
}

Use it the same way as a packaged skin, with the media as its child:

import { createPlayer } from '@videojs/react';
import { Video, videoFeatures } from '@videojs/react/video';
import { VideoSkin } from './VideoSkin';

const { Player } = createPlayer({ features: videoFeatures });

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

videoFeatures gives the player the state these controls need. Some controls, such as Cast or Quality, depend on features that not every preset includes. Each control’s reference page names the feature it needs.

This CSS places the controls over the video, hides them when the viewer is idle, draws the timeline, and keeps the error dialog hidden until playback fails:

.video-skin {
  position: relative;
  display: block;
}

.video-skin video {
  display: block;
  width: 100%;
}

.video-skin-controls {
  position: absolute;
  inset: auto 0 0;
  padding: 12px;
  background: linear-gradient(to top, rgb(0 0 0 / 0.7), transparent);
  color: white;
  transition: opacity 0.25s;
}

/* Keep controls visible while something inside them has keyboard focus. */
.video-skin-controls:not([data-visible]):not(:has(:focus-visible)) {
  opacity: 0;
}

.video-skin-controls-group {
  display: flex;
  gap: 8px;
  align-items: center;
}

/* The slider sets its progress as CSS custom properties. */
.video-skin-time-slider {
  position: relative;
  display: flex;
  align-items: center;
  height: 20px;
  cursor: pointer;
}

.video-skin-track {
  position: relative;
  width: 100%;
  height: 4px;
  background: rgb(255 255 255 / 0.3);
  border-radius: 9999px;
}

.video-skin-buffer,
.video-skin-fill {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  border-radius: inherit;
}

.video-skin-buffer {
  width: var(--media-slider-buffer);
  background: rgb(255 255 255 / 0.4);
}

.video-skin-fill {
  width: var(--media-slider-fill);
  background: white;
}

.video-skin-thumb {
  position: absolute;
  left: var(--media-slider-fill);
  width: 12px;
  height: 12px;
  background: white;
  border-radius: 50%;
  translate: -50% 0;
}

.video-skin-error {
  position: absolute;
  inset: 0;
  display: grid;
  gap: 12px;
  place-content: center;
  padding: 24px;
  background: rgb(0 0 0 / 0.85);
  color: white;
  text-align: center;
}

.video-skin-error:not([data-open]) {
  display: none;
}

From here, add the components your design needs.

Pick the components

Each skin is made of the same building blocks. Use the ones your design needs; their reference pages cover props, state, data attributes, and examples.

Purpose Components
Player surface, fullscreen and activity target Container
Show and hide controls with user activity Controls
Playback buttons Play button, Seek button, Mute button, Playback rate button, Live button
Screen and device buttons Fullscreen button, Picture-in-picture button, AirPlay button, Cast button, Captions button
Timeline and volume Time slider, Volume slider, Volume popover, Thumbnail, Time
Menus and popups Menu, Popover, Tooltip, and the radio groups for captions, quality, playback rate, and audio tracks
Media details and loading Title, Poster, Buffering indicator
Feedback after an action Seek indicator, Volume indicator, Status indicator
Errors Error dialog
Keyboard, gestures, and announcements Hotkey, Gesture, Status announcer
Icons Icons

Some of these are easy to leave out by accident because they have little or no visible UI. Include the error dialog, keyboard shortcuts, and status announcer unless you replace them with your own.

To build a control Video.js doesn’t provide, follow Build your own UI component.

Style it your way

The components bring no visual styles. You add the icons, text, and CSS. They describe their state with data-* attributes, such as data-paused on the play button, so you can style them with plain CSS, Tailwind, or anything else. UI components explains how. Automatically show and hide controls covers the controls’ visibility states.

Lay out controls in source order that matches their visual order, so keyboard focus follows the layout.

A player’s controls sit over a moving picture, in a frame whose size you don’t control, so a few design questions come up in every skin:

Design inspiration behind the Video.js v10 skins covers the thinking behind our own skins in full.

Support the public CSS properties

If the skin is only for your own site, you can skip this.

If you want to offer it as a Video.js skin, support the same CSS custom properties as the packaged skins, such as --media-accent-color and --media-border-radius. People can then restyle your skin the same way they restyle ours. Set the skin’s styles lists them. We’re still working out how to accept community skins into Video.js.

Test it

Check the finished skin with real media, at the narrowest player size you support:

  • Play, pause, seek, change volume, open every menu, and enter fullscreen.
  • Use only a keyboard, then only touch. Focus stays visible and follows the layout, and controls hide when idle.
  • Check loading, errors, ended playback, captions, and controls the browser doesn’t support.
  • Check text and icon contrast over bright and dark footage, with forced colors, and with reduced motion.

Accessibility covers these checks in more detail.

Guides