diff --git a/site/src/content/docs/concepts/overview.mdx b/site/src/content/docs/concepts/overview.mdx
index e9d4caba8..8251323c7 100644
--- a/site/src/content/docs/concepts/overview.mdx
+++ b/site/src/content/docs/concepts/overview.mdx
@@ -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 ejecting a skin and using its pre-styled components as a foundation.
Learn more about UI components
+Build your own component
## 3. Media
diff --git a/site/src/content/docs/how-to/build-your-own-component.mdx b/site/src/content/docs/how-to/build-your-own-component.mdx
new file mode 100644
index 000000000..e67efe2e8
--- /dev/null
+++ b/site/src/content/docs/how-to/build-your-own-component.mdx
@@ -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:
+
+
+- **Change what a 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.
+
+
+
+- **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.
+
+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 `usePlayer`:
+
+```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);
+```
+
+
+
+
+
+Access state and actions with `PlayerController` 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);
+```
+
+
+
+## Place your component in the player
+
+
+
+Your component needs to be inside `` to access state. Place it inside `` if it should also participate in fullscreen and respond to user activity:
+
+```tsx
+ // [!code focus]
+ // [!code focus]
+
+
+
+ // [!code focus]
+ // [!code focus]
+ // [!code focus]
+```
+
+
+
+
+
+Your element needs to be inside `` to access state. Place it inside `` if it should also participate in fullscreen and respond to user activity. `` slots its children into ``, so a child of the skin works:
+
+```html
+
+
+
+ Skip intro
+
+
+```
+
+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;
+ }
+}
+```
+
+
+
+## Make it accessible
+
+Learn more about accessibility in video players
+
+## Full example
+
+A "skip intro" button that appears during the first 30 seconds of playback and seeks past the intro when clicked.
+
+
+
+
+
+ SkipIntroButton.tsx
+ SkipIntroButton.css
+
+
+ ```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 (
+
+ );
+ }
+ ```
+
+
+ ```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;
+ }
+ ```
+
+
+
+
+
+
+
+
+
+ skip-intro-button.ts
+ skip-intro-button.css
+
+
+ ```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);
+ ```
+
+
+ ```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;
+ }
+ ```
+
+
+
+
diff --git a/site/src/docs.config.ts b/site/src/docs.config.ts
index 9009c2498..7457dc83d 100644
--- a/site/src/docs.config.ts
+++ b/site/src/docs.config.ts
@@ -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',