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:
- Change what a control renders: use the
renderprop on any built-in component. See UI components. - 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.
- 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
Need access to player state or actions? You’ll want to familiarize yourself with 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 |
Browse the full list in the Features section of the sidebar.
Features for platform-dependent capabilities also expose one or more availability properties ('available', 'unavailable', or 'unsupported') for hiding controls the platform does not support. See Features for details.
Access state and actions with usePlayer:
import { usePlayer } from '@videojs/react';
// Subscribe to state — re-renders only when selected values change
const paused = usePlayer((s) => s.paused);
const currentTime = usePlayer((s) => s.currentTime);
// Get the store for dispatching actions (does not subscribe)
const store = usePlayer();
await store.play();
store.setVolume(0.5);
store.seek(30);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 component needs to be inside <Player> to access state. Place it inside <Container> if it should also participate in fullscreen and respond to user activity:
import { Container } from '@videojs/react';
<Player>
<Container>
<VideoSkin>
<Video src="video.mp4" />
</VideoSkin>
<SkipIntroButton />
</Container>
</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, see Custom element lifecycle for how to clean it up when the element is removed from the page.
Make it accessible
Full example
A “skip intro” button that appears during the first 30 seconds of playback and seeks past the intro when clicked.
// The preset's usePlayer is typed for its feature bundle
import { usePlayer } from '@videojs/react/video';
export function SkipIntroButton() {
const store = usePlayer();
const currentTime = usePlayer((s) => s.currentTime);
const paused = usePlayer((s) => s.paused);
const visible = currentTime < 30 && !paused;
return (
<button
className="skip-intro-button"
onClick={() => store.seek(30)}
aria-label="Skip intro"
// `undefined` removes the attribute; `false` would render data-visible="false"
data-visible={visible || undefined}
tabIndex={visible ? 0 : -1}
>
Skip intro
</button>
);
}.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 component needs to be inside a player, such as <VideoPlayer>, to access state. Place it inside <Container> if it should also participate in fullscreen and respond to user activity:
import { Container } from '@videojs/react';
import { Video, VideoPlayer, VideoSkin } from '@videojs/react/video';
import '@videojs/react/video/skin.css';
import { SkipIntroButton } from './SkipIntroButton';
export default function App() {
return (
<VideoPlayer>
<Container>
<VideoSkin>
<Video src="video.mp4" />
</VideoSkin>
<SkipIntroButton />
</Container>
</VideoPlayer>
);
}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 {
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> 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 |
Browse the full list in the Features section of the sidebar.
Access state and actions with the usePlayer hook from your player’s preset. It knows which features that preset has, so state and actions are typed. (The standalone usePlayer export from @videojs/react returns an untyped store, so its values are unknown in TypeScript.) If you built the player with a custom feature set through createPlayer — the escape hatch for custom feature sets — use the usePlayer it returns instead.
import { usePlayer } from '@videojs/react/video';
// Subscribe to state — re-renders only when selected values change
const paused = usePlayer((s) => s.paused);
const currentTime = usePlayer((s) => s.currentTime);
// Get the store for dispatching actions (does not subscribe)
const store = usePlayer();
await store.play();
store.setVolume(0.5);
store.seek(30);Extend MediaElement 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 (in HTML, where there is no 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
undefinedfor a missing feature; guard the value before using it, as the example does. - Volume, fullscreen, picture-in-picture, and remote playback also expose an
*Availabilityproperty ('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.
Change what a built-in control renders
Use the render prop on any built-in component. See UI components.
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.
Troubleshooting
State and actions are typed as unknown
You’re using the standalone usePlayer export or the shared playerContext, which don’t know which features your player has. In React, import usePlayer from your player’s preset (for example @videojs/react/video), or use the hook returned by createPlayer if you built the player with a custom feature set. In HTML, use the typed context returned by createPlayer.
The component renders but never updates
In React, the component sits outside the player (such as <VideoPlayer>), so usePlayer has no store to subscribe to. In HTML, the element sits outside <video-player>, or the class doesn’t extend MediaElement, so PlayerController has nothing to schedule updates on.