FrameworkStyle

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, just 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 — eject a skin and modify it. See Customize skins.

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

Use player state and actions

Each feature exposes state properties and actions. Common features:

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

Browse the full list in the Features section of the sidebar.

Each feature also has an availability property ('available', 'unavailable', or 'unsupported') for hiding controls the platform does not support. See Features for details.

Access state and actions with PlayerController and 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:

#store = new PlayerController(this, playerContext);

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

Place your component in the player

Your element needs to be inside <video-player> to access state:

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

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

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

// Extend MediaElement for reactive updates and lifecycle cleanup
class SkipIntroButtonElement extends MediaElement {
  // Subscribe to a feature — calls update() when its state changes
  #player = new PlayerController(this, playerContext, selectTime);

  update() {
    super.update();
    // Read state and update the DOM here
    const time = this.#player.value;
  }
}

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

Make it accessible

Use semantic HTML elements (<button>, not <div>), add ARIA attributes where needed, and support keyboard interaction.

Full example

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

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

class SkipIntroButtonElement extends MediaElement {
  static tagName = 'skip-intro-button';

  #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() {
    super.update();
    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);