docs(site): add how-to guide for building custom components (#1008)

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Darius Cepulis
2026-04-27 13:22:34 -05:00
committed by GitHub
co-authored by Claude Opus 4.6
parent 466abb6d20
commit b830baee6b
3 changed files with 311 additions and 1 deletions
@@ -106,6 +106,7 @@ If you want more control than skins offer you, you can build your own UI from ou
To get started with UI components, you might consider <DocsLink slug="how-to/customize-skins">ejecting a skin</DocsLink> and using its pre-styled components as a foundation.
<DocsLinkCard slug="concepts/ui-components">Learn more about UI components</DocsLinkCard>
<DocsLinkCard slug="how-to/build-your-own-component">Build your own component</DocsLinkCard>
## 3. Media
@@ -0,0 +1,309 @@
---
title: Build your own UI component
description: Create custom player controls that read state, dispatch actions, and stay accessible.
---
import FrameworkCase from '@/components/docs/FrameworkCase.astro';
import DocsLink from '@/components/docs/DocsLink.astro';
import DocsLinkCard from '@/components/docs/DocsLinkCard.astro';
import { TabsRoot, TabsList, TabsPanel, Tab } from '@/components/Tabs.tsx';
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:
<FrameworkCase frameworks={["react"]}>
- **Change what a control renders**: use the `render` prop on any built-in component. See <DocsLink slug="concepts/ui-components">UI components</DocsLink>.
- **Restyle a control**: use CSS custom properties and data attributes. See <DocsLink slug="concepts/ui-components">UI components</DocsLink>.
- **Rearrange or remove controls**: eject a skin and modify it. See <DocsLink slug="how-to/customize-skins">Customize skins</DocsLink>.
</FrameworkCase>
<FrameworkCase frameworks={["html"]}>
- **Restyle a control**: use CSS custom properties and data attributes. See <DocsLink slug="concepts/ui-components">UI components</DocsLink>.
- **Rearrange or remove controls**: eject a skin and modify it. See <DocsLink slug="how-to/customize-skins">Customize skins</DocsLink>.
</FrameworkCase>
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 <DocsLink slug="concepts/features">features</DocsLink>. Each feature exposes a set. Here are some features you might reach for first:
| State | Actions | Feature |
|-------|---------|---------|
| `paused`, `ended` | `play()`, `pause()` | <DocsLink slug="reference/feature-playback">Playback</DocsLink> |
| `currentTime`, `duration` | `seek()` | <DocsLink slug="reference/feature-time">Time</DocsLink> |
| `volume`, `muted` | `setVolume()`, `toggleMuted()` | <DocsLink slug="reference/feature-volume">Volume</DocsLink> |
| `fullscreen` | `requestFullscreen()`, `exitFullscreen()` | <DocsLink slug="reference/feature-fullscreen">Fullscreen</DocsLink> |
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 <DocsLink slug="concepts/features">Features</DocsLink> for details.
<FrameworkCase frameworks={["react"]}>
Access state and actions with <DocsLink slug="reference/use-player">`usePlayer`</DocsLink>:
```tsx
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);
```
</FrameworkCase>
<FrameworkCase frameworks={["html"]}>
Access state and actions with <DocsLink slug="reference/player-controller">`PlayerController`</DocsLink> and 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:
```ts
#store = new PlayerController(this, playerContext);
// Call any action
this.#store.value.play();
this.#store.value.setVolume(0.5);
```
</FrameworkCase>
## Place your component in the player
<FrameworkCase frameworks={["react"]}>
Your component needs to be inside <DocsLink slug="reference/player-provider">`<Player.Provider>`</DocsLink> to access state. Place it inside <DocsLink slug="reference/player-container">`<Player.Container>`</DocsLink> if it should also participate in fullscreen and respond to user activity:
```tsx
<Player.Provider> // [!code focus]
<Player.Container> // [!code focus]
<VideoSkin>
<Video src="video.mp4" />
</VideoSkin>
<SkipIntroButton /> // [!code focus]
</Player.Container> // [!code focus]
</Player.Provider> // [!code focus]
```
</FrameworkCase>
<FrameworkCase frameworks={["html"]}>
Your element needs to be inside <DocsLink slug="reference/player-provider">`<video-player>`</DocsLink> to access state. Place it inside <DocsLink slug="reference/player-container">`<media-container>`</DocsLink> 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> <!-- [!code focus] -->
<video-skin> <!-- [!code focus] -->
<video slot="media" src="video.mp4"></video>
<skip-intro-button>Skip intro</skip-intro-button> <!-- [!code focus] -->
</video-skin> <!-- [!code focus] -->
</video-player> <!-- [!code focus] -->
```
Extend `MediaElement` from `@videojs/html` so `PlayerController` can schedule DOM updates when state changes:
```ts
import { MediaElement, PlayerController, playerContext, selectTime, type PropertyValues } from '@videojs/html';
class SkipIntroButtonElement extends MediaElement {
#player = new PlayerController(this, playerContext, selectTime);
update(changed: PropertyValues) {
super.update(changed);
const time = this.#player.value;
}
}
```
</FrameworkCase>
## Make it accessible
<DocsLinkCard slug="concepts/accessibility">Learn more about accessibility in video players</DocsLinkCard>
## Full example
A "skip intro" button that appears during the first 30 seconds of playback and seeks past the intro when clicked.
<FrameworkCase frameworks={["react"]}>
<TabsRoot client:idle>
<TabsList client:idle label="React skip intro example">
<Tab client:idle value="component" initial>SkipIntroButton.tsx</Tab>
<Tab client:idle value="css">SkipIntroButton.css</Tab>
</TabsList>
<TabsPanel client:idle value="component" initial>
```tsx
import { usePlayer } from '@videojs/react';
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>
);
}
```
</TabsPanel>
<TabsPanel client:idle value="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;
}
```
</TabsPanel>
</TabsRoot>
</FrameworkCase>
<FrameworkCase frameworks={["html"]}>
<TabsRoot client:idle>
<TabsList client:idle label="HTML skip intro example">
<Tab client:idle value="ts" initial>skip-intro-button.ts</Tab>
<Tab client:idle value="css">skip-intro-button.css</Tab>
</TabsList>
<TabsPanel client:idle value="ts" initial>
```ts
import {
MediaElement,
PlayerController,
playerContext,
selectTime,
selectPlayback,
type PropertyValues,
} from '@videojs/html';
class SkipIntroButtonElement extends MediaElement {
#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);
```
</TabsPanel>
<TabsPanel client:idle value="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;
}
```
</TabsPanel>
</TabsRoot>
</FrameworkCase>
+1 -1
View File
@@ -45,7 +45,7 @@ export const sidebar: Sidebar = [
sidebarLabel: 'How to',
llmsDescription:
'Task-oriented guides with step-by-step instructions to achieve a specific outcome by applying one or more concepts. Each guide may assume you already understand the relevant concepts.',
contents: [{ slug: 'how-to/customize-skins' }],
contents: [{ slug: 'how-to/customize-skins' }, { slug: 'how-to/build-your-own-component' }],
},
{
sidebarLabel: 'Components',