Skip to content

GuideGetting Started

Build your own UI component

Create custom player controls that read state, dispatch actions, and stay accessible.

Custom components subscribe to player state and dispatch actions, like built-in controls.

You might not need a custom component

Before building from scratch, check if an existing approach covers your use case:

  • Restyle a control: use CSS custom properties and data attributes. See UI components.
  • Rearrange or remove controls: add the skin source to your project, then modify it. See Customize skins.

Build a custom component when you need new behavior, a new state display, or integration with an external system.

Place your component in the player

Your element needs to be inside <video-player> to access state. Place it inside <media-container> if it should also participate in fullscreen and respond to user activity. <video-skin> slots its children into <media-container>, so a child of the skin works:

<video-player>
  <video-skin>
    <video slot="media" src="video.mp4"></video>
    <skip-intro-button>Skip intro</skip-intro-button>
  </video-skin>
</video-player>

Extend UIElement from @videojs/html so PlayerController can schedule DOM updates when state changes:

import { UIElement, PlayerController, playerContext, selectTime, type PropertyValues } from '@videojs/html';

class SkipIntroButtonElement extends UIElement {
  #player = new PlayerController(this, playerContext, selectTime);

  update(changed: PropertyValues) {
    super.update(changed);
    const time = this.#player.value;
  }
}

If your element starts listeners, observers, or other work, stop it in disconnectedCallback() and call the superclass lifecycle methods so inherited cleanup runs too. The browser can connect and disconnect the same element many times.

Full example

A “skip intro” button that appears during the first 30 seconds of playback and seeks past the intro when clicked.

import {
  UIElement,
  PlayerController,
  playerContext,
  selectTime,
  selectPlayback,
  type PropertyValues,
} from '@videojs/html';

class SkipIntroButtonElement extends UIElement {
  #time = new PlayerController(this, playerContext, selectTime);
  #playback = new PlayerController(this, playerContext, selectPlayback);
  #disconnect: AbortController | null = null;

  connectedCallback() {
    super.connectedCallback();
    this.#disconnect?.abort();
    this.#disconnect = new AbortController();
    const { signal } = this.#disconnect;

    this.setAttribute('role', 'button');
    this.setAttribute('aria-label', 'Skip intro');
    this.setAttribute('tabindex', '0');
    this.addEventListener('click', this.#handleActivate, { signal });
    this.addEventListener('keydown', this.#handleKeydown, { signal });
    this.addEventListener('keyup', this.#handleKeyup, { signal });
  }

  disconnectedCallback() {
    super.disconnectedCallback();
    // Removes all listeners registered with this signal
    this.#disconnect?.abort();
    this.#disconnect = null;
  }

  update(changed: PropertyValues) {
    super.update(changed);
    const time = this.#time.value;
    const playback = this.#playback.value;

    // Features are configured per-player, so a feature may not be available
    if (!time || !playback) return;

    const visible = time.currentTime < 30 && !playback.paused;
    this.toggleAttribute('data-visible', visible);
    this.setAttribute('tabindex', visible ? '0' : '-1');
  }

  #handleActivate = () => {
    this.#time.value?.seek(30);
  };

  #handleKeydown = (event: KeyboardEvent) => {
    if (event.key === 'Enter') {
      event.preventDefault();
      this.#handleActivate();
    } else if (event.key === ' ') {
      // Prevent Space from scrolling the page
      event.preventDefault();
    }
  };

  // ARIA button pattern: Space activates on keyup, not keydown
  #handleKeyup = (event: KeyboardEvent) => {
    if (event.key === ' ') {
      this.#handleActivate();
    }
  };
}

customElements.define('skip-intro-button', SkipIntroButtonElement);

Your element needs to be inside <video-player> to access state. Place it inside <media-container> if it should also participate in fullscreen and respond to user activity. <video-skin> slots its children into <media-container>, so a child of the skin works:

<video-player>
  <video-skin>
    <video src="video.mp4"></video>
    <skip-intro-button>Skip intro</skip-intro-button>
  </video-skin>
</video-player>
<script type="module">
  import '@videojs/html/video/skin';
  import './skip-intro-button.js';
</script>

How it works

Custom components read player state and dispatch actions through features. Each feature exposes a set. Here are some features you might reach for first:

State Actions Feature
paused, ended play(), pause() Playback
currentTime, duration seek() Time
volume, muted setVolume(), toggleMuted() Volume
fullscreen requestFullscreen(), exitFullscreen() Fullscreen

The API reference lists every feature with the state and actions it adds.

Extend UIElement from @videojs/html so PlayerController can schedule DOM updates when state changes, and access state and actions with a feature selector:

import { PlayerController, playerContext, selectPlayback } from '@videojs/html';

// Subscribe to a feature — triggers update() when its state changes
#playback = new PlayerController(this, playerContext, selectPlayback);

// In update():
const playback = this.#playback.value;
if (playback?.paused) {
  playback.play();
}

Each selector returns both state and actions for that feature. Use separate controllers when you need multiple features (the full example demonstrates this).

Without a selector, PlayerController returns the full store without subscribing to changes. Create the controller with the typed context that createPlayer returns — the shared playerContext types the store’s members as unknown — and guard the value, which stays undefined until a player provides the store:

import { createPlayer, PlayerController } from '@videojs/html';
import { videoFeatures } from '@videojs/html/video';

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

#store = new PlayerController(this, context);

// Call any action
this.#store.value?.play();
this.#store.value?.setVolume(0.5);

Custom controls also need real button semantics — the examples above set the accessible name, keyboard focus, and (because a custom element is not a native <button>) the ARIA button pattern by hand.

Availability and constraints

  • Features are configured per player, so a feature your component asks for may not be present. Selectors return undefined for a missing feature; guard the value before using it, as the example does.
  • Volume, fullscreen, picture-in-picture, and remote playback also expose an *Availability property ('available', 'unavailable', or 'unsupported') for hiding controls the platform does not support. See Features for details.

Common variations

Before building from scratch, check if an existing approach covers your use case. Build a custom component when you need new behavior, a new state display, or integration with an external system.

Restyle a control

Use CSS custom properties and data attributes. See UI components.

Rearrange or remove controls

Add the skin source to your project and modify it. See Customize skins.

Troubleshooting

State and actions are typed as unknown

You’re using the shared playerContext, which doesn’t know which features your player has. Use the typed context returned by createPlayer.

The component renders but never updates

The element sits outside <video-player>, or the class doesn’t extend UIElement, so PlayerController has nothing to schedule updates on.

API

Guides