# 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](./ui-components.md).
- **Rearrange or remove controls**: add the skin source to your project, then modify it. See [Customize skins](./customize-skins.md#style-skin-source).

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>`](../reference/components/player.md) to access state. Place it inside [`<media-container>`](../reference/components/player-container.md) 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:

```html
<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:

```ts
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.

**skip-intro-button.ts**

```ts
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);
```

**skip-intro-button.css**

```css
skip-intro-button {
  position: absolute;
  bottom: 5rem;
  right: 1rem;
  opacity: 0;
  pointer-events: none;
  transition: opacity 200ms;
}

skip-intro-button[data-visible] {
  opacity: 1;
  pointer-events: auto;
}
```

Your element needs to be inside [`<video-player>`](../reference/components/player.md) to access state. Place it inside [`<media-container>`](../reference/components/player-container.md) 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:

```html
<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](./features.md). Each feature exposes a set. Here are some features you might reach for first:

| State | Actions | Feature |
| --- | --- | --- |
| `paused`, `ended` | `play()`, `pause()` | [Playback](../reference/api/feature-playback.md) |
| `currentTime`, `duration` | `seek()` | [Time](../reference/api/feature-time.md) |
| `volume`, `muted` | `setVolume()`, `toggleMuted()` | [Volume](../reference/api/feature-volume.md) |
| `fullscreen` | `requestFullscreen()`, `exitFullscreen()` | [Fullscreen](../reference/api/feature-fullscreen.md) |

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

Extend `UIElement` from `@videojs/html` so [`PlayerController`](../reference/api/player-controller.md) can schedule DOM updates when state changes, and access state and actions with a feature selector:

```ts
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](#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`](../reference/api/html-create-player.md) returns — the shared `playerContext` types the store’s members as `unknown` — and guard the value, which stays `undefined` until a player provides the store:

```ts
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.

- [Learn more about accessibility in video players](./accessibility.md)

## 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](./features.md) 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](./ui-components.md).

### Rearrange or remove controls

Add the skin source to your project and modify it. See [Customize skins](./customize-skins.md#style-skin-source).

## 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.

## Related pages

### API

- [PlayerController](../reference/api/player-controller.md): Reactive controller for accessing player store state in HTML custom elements
- [createPlayer](../reference/api/html-create-player.md): Factory function that creates a typed player element and controller for HTML custom elements
- [media-container](../reference/components/player-container.md): The player's visual and interaction surface for layout, fullscreen, focus, and user activity.

### Guides

- [Customize skins](./customize-skins.md): Style a packaged Video.js skin or add its source to change controls, layout, styles, and interactions