10 KiB
Video.js Component Architecture
Video.js components use a three-layer architecture separating framework-agnostic logic from platform implementations.
Architecture Overview
┌─────────────────────────────────────────────────────────────┐
│ @videojs/core Framework-agnostic business logic │
│ @videojs/core/dom Shared DOM utilities │
└─────────────────────────────────────────────────────────────┘
│
┌─────────────────┴─────────────────┐
▼ ▼
@videojs/html @videojs/react
Web Components React Components
| Package | Responsibility |
|---|---|
@videojs/core |
Core classes with getState()/getAttrs()/actions |
@videojs/core/dom |
DOM utilities, selectors, button behavior |
@videojs/html |
Web Components consuming core via controllers |
@videojs/react |
React components consuming core via hooks |
Core Class Pattern
Every UI component has a *Core class in @videojs/core.
Props Interface
interface PlayButtonProps {
/** Custom label for the button. */
label?: string | undefined;
/** Whether the button is disabled. */
disabled?: boolean | undefined;
}
- All props optional with
| undefined(explicit optionality) - JSDoc for each prop
State Interface
// When keys match the feature state, use Pick (preserves JSDoc on IDE hover)
interface PlayButtonState extends Pick<PlaybackState, 'paused' | 'ended' | 'started'> {}
// When renaming keys, use Pick for matching keys and add JSDoc for renamed ones
interface FullscreenButtonState extends Pick<FullscreenState, 'fullscreen'> {
/** Whether fullscreen can be requested on this platform. */
availability: FullscreenState['fullscreenAvailability'];
}
- Primitives only — no methods
- Use
Pick<FeatureState, ...>to select relevant fields — preserves JSDoc on IDE hover - When a key is renamed for the button context, use
FeatureState['...']for the type and add a JSDoc description
Core Class
class PlayButtonCore {
static readonly defaultProps: NonNullableObject<Props>;
setProps(props: Props): void; // Merge with defaults
getState(media: MediaPlaybackState): State; // Project media → UI state
getLabel(state: PlayButtonState): string; // Computed label
getAttrs(state: PlayButtonState): { ... }; // ARIA only (inferred)
toggle(media: MediaPlaybackState): Promise<void>; // Action
}
namespace PlayButtonCore {
export type Props = PlayButtonProps;
export type State = PlayButtonState;
}
Method signatures — queries vs commands:
| Method | Accepts | Why |
|---|---|---|
getState(media) |
Raw media state | Projection boundary — only place that touches Media*State |
getLabel(state) |
Projected UI state | Pure query, needs only data fields |
getAttrs(state) |
Projected UI state | Pure query, return type inferred from object literal |
toggle(media) |
Raw media state | Command — needs action methods (play, pause, etc.) |
Rules:
static readonly defaultPropswithNonNullableObject<Props>typegetAttrs()returns ARIA attributes only (nodata-*), return type inferred (no explicit interface)getState()returns primitives only (no methods) — converted todata-*for CSStoggle()accepts raw media state (commands need action methods)getLabel()andgetAttrs()accept projected UI state (queries need only data)- Namespace exports
PropsandStatetypes
State vs Attrs Separation
| Method | Returns | Purpose |
|---|---|---|
getAttrs() |
ARIA attributes | Accessibility (aria-label, aria-disabled) |
getState() |
Primitives | CSS styling via data-* attributes |
Why separate:
- CSS targets
[data-paused],[data-ended]selectors - ARIA attrs remain semantically accurate
- Can update independently
Data Attribute Maps
Each component has a *DataAttrs constant that maps state keys to data-* attribute names.
The satisfies StateAttrMap<*State> constraint validates at compile-time that only keys from the
component's state type are mapped — preventing accidental serialization of unmapped keys.
import type { StateAttrMap } from '../types';
import type { PlayButtonState } from './play-button-core';
export const PlayButtonDataAttrs = {
/** Present when the media is paused. */
paused: 'data-paused',
/** Present when the media has ended. */
ended: 'data-ended',
/** Present when playback has started. */
started: 'data-started',
} as const satisfies StateAttrMap<PlayButtonState>;
StateAttrMap<State> is defined in core/ui/types.ts:
export type StateAttrMap<State> = {
[Key in keyof State]?: string;
};
- JSDoc comments generate API documentation
- Maps are partial — only mapped state keys become
data-*attributes getStateDataAttrsandapplyStateDataAttrsskip unmapped keys when a map is provided
Web Component
class PlayButtonElement extends MediaElement {
static readonly tagName = 'media-play-button';
static override properties = { label: { type: String }, disabled: { type: Boolean } };
readonly #core = new PlayButtonCore();
readonly #state = new PlayerController(this, playerContext, selectPlayback);
#disconnect: AbortController | null = null;
// Lifecycle: see flow below
}
Lifecycle flow:
connectedCallback— CreateAbortController, apply button props,__DEV__warning for missing featuredisconnectedCallback— Abort controller for cleanupwillUpdate— Sync component props to core viasetProps(this)update— Silent null guard, then project state and apply attrs
connectedCallback + update() pattern:
override connectedCallback(): void {
super.connectedCallback();
this.#disconnect = new AbortController();
const buttonProps = createButton({ ... });
applyElementProps(this, buttonProps, this.#disconnect.signal);
if (__DEV__ && !this.#state.value) {
logMissingFeature(PlayButtonElement.tagName, 'playback');
}
}
protected override update(changed: PropertyValues): void {
super.update(changed);
const media = this.#state.value;
if (!media) return;
const state = this.#core.getState(media);
applyElementProps(this, this.#core.getAttrs(state));
applyStateDataAttrs(this, state, PlayButtonDataAttrs);
}
Key utilities:
PlayerController(host, context, selector)— Store subscriptionapplyElementProps(el, props, signal?)— Apply attrs + events to DOMapplyStateDataAttrs(el, state, map)— State →data-*(map controls which keys are serialized)logMissingFeature(name, feature)— Deduped__DEV__-only warning (called inconnectedCallback)
React Component
const PlayButton = forwardRef(function PlayButton(props, ref) {
const playback = usePlayer(selectPlayback);
const [core] = useState(() => new PlayButtonCore());
const { getButtonProps, buttonRef } = useButton({
onActivate: () => core.toggle(playback!),
isDisabled,
});
const state = core.getState(playback);
return renderElement('button', { render, className, style }, {
state,
stateAttrMap: PlayButtonDataAttrs,
ref: [ref, buttonRef],
props: [core.getAttrs(state), elementProps, getButtonProps()],
});
});
Flow:
usePlayer(selector)— Subscribe to store sliceuseState(() => new Core())— Lazy init core classuseButton()— Get accessible button behaviorrenderElement()— Render with state→data-attrs, ref composition, props merge
Props type: UIComponentProps<Tag, State> allows className/style as functions of state.
Shared Utilities
@videojs/core/dom
| Utility | Purpose |
|---|---|
createButton(options) |
Accessible button (Enter/Space, click, disabled) |
applyElementProps(el, props, signal?) |
Apply attrs and events to DOM |
applyStateDataAttrs(el, state, map) |
State → data-* (map controls serialized keys) |
getStateDataAttrs(state, map) |
State → data-attrs object (React) |
logMissingFeature(name, feature) |
Deduped console.warn |
selectPlayback / selectVolume |
Store selectors |
@videojs/react/utils
| Utility | Purpose |
|---|---|
renderElement(tag, props, params) |
Render with state, refs, props merge |
mergeProps(...propSets) |
Chain events, concat className, merge style |
composeRefs(...refs) |
Compose refs (React 19 cleanup support) |
File Organization
packages/
├── core/src/
│ ├── core/
│ │ ├── ui/types.ts # StateAttrMap type
│ │ └── ui/{component}/
│ │ ├── {component}-core.ts # Core class
│ │ ├── {component}-data-attrs.ts # Data attr map (satisfies StateAttrMap)
│ │ └── tests/
│ │ └── {component}-core.test.ts # Core tests
│ └── dom/ui/ # createButton, utils
├── html/src/
│ ├── ui/{component}/ # Web Component
│ ├── define/ui/ # Side-effect registration
│ └── player/player-controller.ts # Store controller
└── react/src/
├── ui/{component}/ # React component
├── ui/hooks/ # Behavior hooks
└── utils/ # renderElement, mergeProps
No barrel exports for simple components — Don't create index.ts files for simple UI components. Export directly from individual files. Reserve index.ts barrels for compound components with multiple related exports that form a cohesive API.
Component Registration
// define/ui/play-button.ts
customElements.define(PlayButtonElement.tagName, PlayButtonElement);
declare global {
interface HTMLElementTagNameMap {
[PlayButtonElement.tagName]: PlayButtonElement;
}
}
- Tag name:
static readonly tagName = 'media-{name}' - Registration in
define/ui/directory - Augment
HTMLElementTagNameMapfor TypeScript