43 KiB
Architecture
Internal structure of the slider component system.
Overview
┌──────────────────────────────┐
│ @videojs/core │
│ │
│ SliderCore ← generic logic │
│ ├─ TimeSliderCore │
│ └─ VolumeSliderCore │
│ SliderDataAttrs │
│ SliderCSSVars (constants) │
└──────────────┬───────────────┘
│
┌──────────────▼───────────────┐
│ @videojs/core/dom │
│ │
│ createSlider() ← interaction │
│ getSliderCSSVars() ← format │
└──────────────┬───────────────┘
│
┌────────────────────┼────────────────────┐
│ │
┌───────────▼───────────┐ ┌───────────────▼────────────┐
│ @videojs/react │ │ @videojs/html │
│ │ │ │
│ Slider.Root │ │ <media-time-slider> │
│ Slider.Track/Fill │ │ <media-volume-slider> │
│ Slider.Buffer │ │ <media-slider-track> │
│ Slider.Thumb │ │ <media-slider-fill> │
│ Slider.Preview │ │ <media-slider-buffer> │
│ Slider.Value │ │ <media-slider-thumb> │
│ TimeSlider.Root │ │ <media-slider-preview> │
│ VolumeSlider.Root │ │ <media-slider-value> │
└───────────────────────┘ └────────────────────────────┘
State flows top-down: Core computes state → DOM layer handles interaction → UI renders.
SliderCore
Runtime-agnostic class. Computes slider state and ARIA attributes from raw values. Returns raw percentages — CSS custom property formatting is handled by the DOM layer.
Interface
interface SliderProps {
min?: number; // default: 0
max?: number; // default: 100
step?: number; // default: 1
largeStep?: number; // default: 10
orientation?: 'horizontal' | 'vertical'; // default: 'horizontal'
disabled?: boolean; // default: false
thumbAlignment?: 'center' | 'edge'; // default: 'center'
}
interface SliderState {
value: number;
fillPercent: number;
pointerPercent: number;
dragging: boolean;
pointing: boolean;
interactive: boolean;
orientation: 'horizontal' | 'vertical';
disabled: boolean;
thumbAlignment: 'center' | 'edge';
}
Interaction State
SliderInteraction is the interaction state managed by createSlider via createState (from @videojs/store). The UI layer subscribes to it but never writes it directly:
interface SliderInteraction {
pointerPercent: number; // pointer position as 0-100
dragPercent: number; // percent where the drag is (from createSlider)
dragging: boolean;
pointing: boolean;
focused: boolean;
}
interactive is derived by Core: dragging || pointing || focused.
Methods
class SliderCore {
static readonly defaultProps: NonNullableObject<SliderProps>;
setProps(props: SliderProps): void;
getState(interaction: SliderInteraction, value: number): SliderState;
// Core owns the merge of interaction + value.
// For generic slider, `value` is the controlled/uncontrolled value.
// Domain cores override to accept media state and compute `value` internally.
getAttrs(state: SliderState);
// Returns: role, tabindex, autocomplete, aria-valuemin, aria-valuemax,
// aria-valuenow, aria-orientation, aria-disabled
// These go on the Thumb element (the focusable role="slider" element).
// Inlined return type (no explicit annotation — TS infers).
valueFromPercent(percent: number): number;
// Clamps to [min, max], snaps to step.
// For generic sliders, snaps to `step`. Domain cores can override
// snap precision (e.g., TimeSliderCore uses sub-second precision during drag).
percentFromValue(value: number): number;
// Value as percentage of range
}
ARIA Output
getAttrs() returns attributes for the Thumb element — the focusable role="slider" element per the WAI-ARIA Slider Pattern and the Media Seek Slider Example:
{
role: 'slider',
tabindex: 0,
autocomplete: 'off',
'aria-valuemin': 0,
'aria-valuemax': 100,
'aria-valuenow': 45,
'aria-orientation': 'horizontal',
'aria-disabled': undefined, // omitted when not disabled
}
aria-label and aria-valuetext are NOT set by SliderCore — they're domain-specific. TimeSliderCore and VolumeSliderCore add them via override getAttrs().
Root handles pointer events and provides context (data attrs, raw state for CSS vars) to children. Thumb carries role="slider", receives keyboard focus, and owns all ARIA attributes. There is no separate Control element. Children other than Thumb (Track, Fill, Buffer, Preview) are purely visual.
Value Snapping Precision
valueFromPercent() must handle floating-point precision carefully. Without rounding, values like 0.1 + 0.2 produce 0.30000000000000004.
roundToStep and clamp
Located in @videojs/utils/number — shared utilities used by SliderCore.valueFromPercent():
function clamp(value: number, min: number, max: number): number;
function roundToStep(value: number, step: number, min: number): number;
roundToStep rounds a value to the nearest step, anchored at min. For fractional steps, it derives decimal precision from the step's string representation to avoid floating-point drift:
function roundToStep(value: number, step: number, min: number): number {
const nearest = Math.round((value - min) / step) * step + min;
const dot = `${step}`.indexOf('.');
return dot === -1 ? nearest : Number(nearest.toFixed(`${step}`.length - dot - 1));
}
Integer steps skip toFixed entirely. Fractional steps (e.g., step = 0.1) get cleaned up using the step's own decimal count.
Where It's Used
SliderCore.valueFromPercent()— clamps to[min, max]then snaps to step precision after percent → value conversion.- Keyboard handler — rounds current value to nearest step before computing next value. Without this, a pointer drag that landed at 47.3 on a step-5 slider would produce unexpected keyboard steps (47.3 → 52.3 instead of 45 → 50).
- All percentage calculations for CSS vars use 3 decimal places (
45.123%) for smooth visual animation, separate from value-level step precision.
Documentation Constants
Data attributes and CSS custom properties each get a as const object with JSDoc descriptions. The site docs builder extracts these to generate API reference tables.
Data Attributes
Follows the existing pattern (PlayButtonDataAttrs). One file per component, named *-data-attrs.ts. Convention is *DataAttrs, not *DataAttributes:
// slider-data-attrs.ts
export const SliderDataAttrs = {
/** Present while the user is dragging. */
dragging: 'data-dragging',
/** Present while the pointer is over the slider. */
pointing: 'data-pointing',
/** Present while hovering, focused, or dragging. */
interactive: 'data-interactive',
/** Layout direction (`horizontal` or `vertical`). */
orientation: 'data-orientation',
/** Present when the slider is disabled. */
disabled: 'data-disabled',
} as const;
// Note: thumbAlignment is in SliderState but excluded from SliderDataAttrs.
// Pass SliderDataAttrs as the StateAttrMap to applyStateDataAttrs() to prevent
// auto-generation of a data-thumbalignment attribute.
// time-slider-data-attrs.ts
export const TimeSliderDataAttrs = {
...SliderDataAttrs,
/** Present while the media is seeking. */
seeking: 'data-seeking',
} as const;
CSS Custom Properties
New pattern — parallel to data attrs. One file per component, named *-css-vars.ts:
// slider-css-vars.ts
export const SliderCSSVars = {
/** Current value as percentage of range. */
fill: '--media-slider-fill',
/** Pointer position as percentage of track. */
pointer: '--media-slider-pointer',
/** Buffered range as percentage. Set by domain roots that have a buffer concept. */
buffer: '--media-slider-buffer',
} as const;
Both constants follow the same conventions:
- Property key is the semantic name (camelCase)
- Property value is the full attribute/property name (
data-*or--media-*) - JSDoc comment describes when/what
- Exported as
as constfor type narrowing SliderDataAttrsused at runtime byapplyStateDataAttrs()in the UI layerSliderCSSVarsused at runtime bygetSliderCSSVars()in@videojs/core/dom- Both extracted at build time by the site docs builder for API reference
TimeSliderCore
Extends SliderCore with media time concerns.
interface TimeSliderProps extends SliderProps {
label?: string; // default: 'Seek'
}
interface TimeSliderState extends SliderState, Pick<MediaTimeState, 'currentTime' | 'duration' | 'seeking'> {
bufferPercent: number;
}
class TimeSliderCore extends SliderCore {
getTimeState(media: MediaTimeState & MediaBufferState, interaction: SliderInteraction): TimeSliderState;
// Accepts canonical media state types directly — no wrapper interface.
// Core owns the value swap: dragging ? valueFromPercent(dragPercent) : currentTime.
// Computes bufferedEnd internally from media.buffered ranges.
// bufferPercent = (bufferedEnd / duration) * 100.
// Overrides min=0, max=duration on each call.
override getAttrs(state: TimeSliderState);
// Extends super.getAttrs() with: aria-label (from props.label, default "Seek"),
// aria-valuetext="2 minutes, 30 seconds of 10 minutes"
// Inlined return type.
}
Uses MediaTimeState & MediaBufferState from @videojs/core directly — no custom TimeMediaState wrapper. TimeSliderState uses Pick<> to select the specific fields it exposes (currentTime, duration, seeking), keeping the state interface focused.
Core owns the value swap: when not dragging, value = currentTime. When dragging, value = valueFromPercent(interaction.dragPercent). bufferedEnd is computed internally from media.buffered (the end of the last buffered range). This domain logic lives in Core so both frameworks get it for free.
Time Formatting
Uses formatTimeAsPhrase() from @videojs/utils/time for aria-valuetext. The value text follows the pattern "{current} of {duration}" using human-readable phrases. See decisions.md.
VolumeSliderCore
Extends SliderCore with volume concerns.
interface VolumeSliderProps extends SliderProps {
label?: string; // default: 'Volume'
}
interface VolumeSliderState extends SliderState, Pick<MediaVolumeState, 'volume' | 'muted'> {}
class VolumeSliderCore extends SliderCore {
getVolumeState(media: MediaVolumeState, interaction: SliderInteraction): VolumeSliderState;
// Accepts canonical MediaVolumeState directly — no wrapper interface.
// Core owns the value swap: dragging ? valueFromPercent(dragPercent) : volume * 100.
// When muted: `value` = actual volume * 100 (always reflects real level),
// `fillPercent` = muted ? 0 : base.fillPercent (visual silence).
// `aria-valuenow` uses `value` (actual volume), not `fillPercent`.
override getAttrs(state: VolumeSliderState);
// Extends super.getAttrs() with: aria-label (from props.label, default "Volume"),
// aria-valuetext="75 percent" (or "75 percent, muted" when muted)
// Inlined return type.
}
Uses MediaVolumeState from @videojs/core directly — no custom VolumeMediaState wrapper. VolumeSliderState uses Pick<> to select the specific fields it exposes (volume, muted).
Default props: min=0, max=100, step=1, largeStep=10, orientation='horizontal'.
Volume is stored as 0-1 in the store but displayed as 0-100 in the slider. VolumeSliderCore handles this conversion.
When muted, fill shows 0% but aria-valuenow reflects the actual volume level. aria-valuetext communicates both: "75 percent, muted". This lets screen reader users know the slider's underlying value while also understanding the muted state.
createSlider (DOM)
Factory function in @videojs/core/dom — parallel to createButton(). Manages interaction state via createState (from @videojs/store). Returns split event handler props and a subscribable interaction state.
Interface
interface SliderOptions {
getOrientation: () => 'horizontal' | 'vertical';
isRTL: () => boolean;
isDisabled: () => boolean;
getPercent: () => number; // current value as 0-100 (for keyboard stepping)
getStepPercent: () => number; // step as percentage of range (0-100)
getLargeStepPercent: () => number; // largeStep as percentage of range (0-100)
seekThrottle?: number; // trailing-edge throttle for onValueCommit during drag (ms, default 100, 0 disables)
onValueChange: (percent: number) => void; // percent is 0-100
onValueCommit: (percent: number) => void; // percent is 0-100
onDragStart?: () => void; // intentional drag begins (after threshold)
onDragEnd?: () => void; // drag ends
}
interface SliderRootProps {
onPointerDown: (event: UIPointerEvent) => void;
onPointerMove: (event: UIPointerEvent) => void;
onPointerLeave: (event: UIPointerEvent) => void;
}
interface SliderThumbProps {
onKeyDown: (event: UIKeyboardEvent) => void;
onFocus: () => void;
onBlur: () => void;
}
function createSlider(options: SliderOptions): {
interaction: State<SliderInteraction>; // read-only, subscribable
rootProps: SliderRootProps;
thumbProps: SliderThumbProps;
destroy: () => void; // cleanup throttle timers
};
createSlider internally creates a WritableState<SliderInteraction> via createState(). Pointer and keyboard handlers patch this state directly. The returned interaction is the read-only State<SliderInteraction> — consumers subscribe via .subscribe() and read via .current.
What createSlider manages internally:
interaction.dragging— settrueafter drag threshold,falseon pointerup/pointercancelinteraction.pointing— settrueon pointermove over Root,falseon pointerleaveinteraction.focused— settrue/falsevia Thumb'sonFocus/onBlurinteraction.pointerPercent— updated on pointermove over Root (for preview positioning)interaction.dragPercent— updated on pointermove during drag (for value computation)
What createSlider delegates to the caller (via callbacks):
onValueChange(percent)— caller converts to domain value and updates visual stateonValueCommit(percent)— caller converts to domain value and commits (seek, volume change)
The getPercent getter is still needed for keyboard stepping — createSlider calls it during keyboard events to know the current value percent (which depends on media state, not just interaction state).
All percentage values use 0-100 range, consistent with SliderState.fillPercent and CSS var output.
Root receives pointer props (click-to-seek, drag initiation). Thumb receives keyboard handler and focus tracking. This split reflects DOM responsibility: Root owns the hit area, Thumb owns focus and keyboard interaction.
pointerdown on Root programmatically focuses the Thumb element. This ensures interactive state is correct, :focus-visible styling works, and screen readers track the active element during pointer interaction.
Pointer Behavior
Uses pointer events exclusively — no separate touch event path. touch-action: none on Root ensures reliable pointer event delivery from touch input. See decisions.md.
- pointerdown on Root — focus the Thumb element. Call
onValueChange(percent)immediately (click-to-seek). Addpointermove,pointerup,pointercancel, andtouchmovelisteners ondocument. Track amoveCountfor drag threshold. Do NOT callonDragStart()yet. - pointermove on document — increment
moveCount. IfmoveCountexceeds the intentional drag threshold (2 events), callonDragStart()and setdragging = true. CallonValueChange(percent). WhenseekThrottle > 0and dragging, also callonValueCommit(percent)through a trailing-edge throttle. Safety check: ifevent.pointerType !== 'touch'andevent.buttons === 0, treat as lost pointerup — callonDragEnd()and clean up. (buttonsis unreliable for touch pointer events; touch relies onpointerup/pointercancelinstead.) - pointerup on document — end drag. Remove document listeners. Call
onValueCommit(percent)(unthrottled, final value),onDragEnd(). - pointercancel on document — treat as drag end. Remove document listeners. Call
onDragEnd(). Fires when the browser takes over the gesture (e.g., navigation swipe). Without handling it, the slider gets stuck in dragging state. - pointermove on Root (no drag) — call
onPointerMove(percent)for preview positioning. - pointerleave on Root — call
onPointerLeave(). - touchmove on document —
{ passive: false }, callsevent.preventDefault()to block page scroll. Does not compute any slider values. Added onpointerdown, removed onpointerup/pointercancel/drag end. Scoped to active drag only — never left attached.
Listener options: Document pointermove uses { passive: true }. Document touchmove uses { passive: false } (needs preventDefault()). Document pointerup and pointercancel use default options.
Percent calculation from pointer event:
function getPercentFromPointerEvent(
event: PointerEvent,
rect: DOMRect,
orientation: string,
direction: 'ltr' | 'rtl' = 'ltr'
): number {
let ratio: number;
if (orientation === 'vertical') {
ratio = 1 - (event.clientY - rect.top) / rect.height; // bottom = 0%, top = 100%
} else if (direction === 'rtl') {
ratio = (rect.right - event.clientX) / rect.width; // right = 0%, left = 100%
} else {
ratio = (event.clientX - rect.left) / rect.width; // left = 0%, right = 100%
}
return ratio * 100; // 0-100
}
Vertical + RTL: Vertical sliders are unaffected by text direction — bottom is always 0% (silent/start), top is always 100% (loud/end), regardless of RTL. Only horizontal sliders flip for RTL.
Keyboard Behavior
All via onKeyDown on the Thumb element (the focusable role="slider" element):
| Key | Action |
|---|---|
ArrowRight / ArrowUp |
onValueChange(current + stepPercent) then onValueCommit |
ArrowLeft / ArrowDown |
onValueChange(current - stepPercent) then onValueCommit |
Shift + Arrow |
onValueChange(current ± largeStepPercent) then onValueCommit |
PageUp |
onValueChange(current + largeStepPercent) then onValueCommit |
PageDown |
onValueChange(current - largeStepPercent) then onValueCommit |
Home |
onValueChange(0) then onValueCommit |
End |
onValueChange(100) then onValueCommit |
0–9 |
onValueChange(N * 10) then onValueCommit — jump to 0%–90% |
createSlider does NOT know about step values or min/max — it only works in percentages (0-100). The caller converts using SliderCore.valueFromPercent().
event.preventDefault() is called for all handled keys to prevent page scrolling and other default browser behaviors.
RTL direction: createSlider accepts an isRTL callback. When RTL, ArrowRight subtracts stepPercent (decreases) and ArrowLeft adds stepPercent (increases). ArrowUp/ArrowDown are unaffected. Both Base UI and Vidstack implement this.
Round before stepping: Before computing the next value from a keyboard step, the current value is rounded to the nearest step via roundToStep() (from @videojs/utils/number). This prevents drift when the current value isn't aligned to a step boundary (e.g., after a pointer drag landed between steps). See Value Snapping Precision.
Numeric keys match YouTube behavior (0 = start, 5 = midpoint, 9 = 90%). Only active when metaKey is not held.
Data Flow
React Time Slider
┌─── createSlider() ───┐
│ interaction (State) │ ← subscribable via useSyncExternalStore
│ rootProps │ → Root element (pointer events)
│ thumbProps │ → Thumb element (keyboard + focus)
└──────────┬───────────┘
│
┌──────────────────────────┼───────────────────────────┐
│ │ │
▼ ▼ ▼
interaction.current onValueChange(%) onValueCommit(%)
│ (visual update only) (throttled during drag)
│ │ │
│ │ TimeSliderCore
│ │ .valueFromPercent()
│ │ │
│ │ time.seek(seconds)
│ │ │
│ │ Store updates
│ │ │
▼ ▼ ▼
┌───────────────────────────────────────────────────────────────┐
│ TimeSlider.Root render │
│ │
│ const interaction = useSelector(slider.interaction, s => s); │
│ const media = usePlayer(selectTimeAndBuffer); │
│ const state = core.getTimeState(interaction, media); │
│ const cssVars = getTimeSliderCSSVars(state); │
│ const thumbAttrs = core.getAttrs(state); │
│ │
│ → CSS vars on Root │
│ → Data attrs on Root + children (via context) │
│ → ARIA on Thumb (via context) │
└───────────────────────────────────────────────────────────────┘
During drag: onValueChange fires on every pointermove. onValueCommit fires through a trailing-edge throttle (default 100ms) to seek without flooding the media element. On drag end, onValueCommit fires unthrottled with the final value.
On keyboard: both onValueChange and onValueCommit fire immediately on each step (no throttle).
interaction state updates are batched via queueMicrotask — multiple patches per pointermove (e.g., pointing + pointerPercent + dragPercent) collapse into one subscriber notification. Microtasks fire before the next animation frame, so rendering stays in sync.
HTML Time Slider
┌─── createSlider() ───┐
│ interaction (State) │ ← subscribe → requestUpdate()
│ rootProps │ → self (pointer events)
│ thumbProps │ → <media-slider-thumb>
└──────────┬───────────┘
│
┌──────────────────────────┼───────────────────────────┐
│ │ │
interaction.current onValueChange(%) onValueCommit(%)
│ │
│ time.seek(seconds)
│ │
▼ ▼
┌───────────────────────────────────────────────────────────────┐
│ TimeSliderElement.update() │
│ │
│ const interaction = this.#slider.interaction.current; │
│ const media = this.#playerController.state; │
│ const state = this.#core.getTimeState(interaction, media); │
│ const cssVars = getTimeSliderCSSVars(state); │
│ │
│ → CSS vars on self (style.setProperty) │
│ → Data attrs on self + children (applyStateDataAttrs) │
│ → ARIA on thumb (applyElementProps) │
│ → CustomEvent dispatch (drag-start, drag-end) │
└───────────────────────────────────────────────────────────────┘
CSS Custom Property Computation
CSS custom property formatting lives in @videojs/core/dom, not in SliderCore. Core returns raw percentages in SliderState (fillPercent, pointerPercent). TimeSliderState adds bufferPercent. The DOM layer formats these as CSS var strings:
// packages/core/src/dom/ui/slider-css-vars.ts
function getSliderCSSVars(state: SliderState): Record<string, string> {
return {
[SliderCSSVars.fill]: `${state.fillPercent.toFixed(3)}%`,
[SliderCSSVars.pointer]: `${state.pointerPercent.toFixed(3)}%`,
};
}
function getTimeSliderCSSVars(state: TimeSliderState): Record<string, string> {
return {
...getSliderCSSVars(state),
[SliderCSSVars.buffer]: `${state.bufferPercent.toFixed(3)}%`,
};
}
Implementation note: Return types shown as Record<string, string> for readability. In implementation, prefer a mapped type over SliderCSSVars keys for type safety — consumers can only access valid CSS var keys.
Volume slider has no buffer, so it uses getSliderCSSVars() directly — no getVolumeSliderCSSVars needed.
The SliderCSSVars constant stays in @videojs/core (alongside SliderDataAttrs) for documentation extraction — it defines the property names and JSDoc descriptions. The DOM layer uses it at runtime for the keys. This keeps Core runtime-agnostic (React Native has no CSS custom properties) while keeping the source of truth for property names in one place.
Callback Flow
createSlider() manages interaction state internally via createState and exposes two callbacks for the UI layer to handle domain logic:
createSlider concern |
How it surfaces | Notes |
|---|---|---|
dragging, pointing, focused, pointerPercent, dragPercent |
interaction: State<SliderInteraction> |
Managed internally. UI subscribes and reads .current. |
onValueChange(percent) |
React: props.onValueChange(value) / HTML: CustomEvent('value-change') |
Generic root exposes. Domain roots handle internally. |
onValueCommit(percent) |
React: props.onValueCommit(value) / HTML: CustomEvent('value-commit') |
Generic root exposes. Domain roots handle internally (time.seek(), volume.setVolume()). |
| Drag start/end | React: props.onDragStart() / HTML: CustomEvent('drag-start') |
All roots expose. Controls feature uses to pause auto-hide. |
User-Facing Surface by Component
Generic Slider.Root:
| React prop | HTML event | Fires when |
|---|---|---|
onValueChange |
value-change |
Every value change (drag, keyboard). detail: { value } |
onValueCommit |
value-commit |
Gesture complete (pointerup, keyboard step). detail: { value } |
onDragStart |
drag-start |
Intentional drag begins (after threshold). |
onDragEnd |
drag-end |
Drag ends. |
Domain roots (TimeSlider.Root, VolumeSlider.Root):
| React prop | HTML event | Fires when |
|---|---|---|
onDragStart |
drag-start |
Intentional drag begins. |
onDragEnd |
drag-end |
Drag ends. |
Domain roots do not expose onValueChange or onValueCommit — they manage value internally from the store.
Custom DOM Event Convention
HTML custom elements dispatch custom DOM events using kebab-case names. All events bubble (consistent with native input/change events). Events carrying data use CustomEvent with a typed detail:
this.dispatchEvent(new CustomEvent('value-change', {
bubbles: true,
detail: { value },
}));
this.dispatchEvent(new CustomEvent('drag-start', { bubbles: true }));
This is a new convention for @videojs/html — existing elements (buttons, time display) don't dispatch custom events. The slider is the first component to need user-facing interaction callbacks beyond what the store provides.
Event Interfaces (HTML)
Typed event maps for TypeScript consumers:
// packages/html/src/ui/slider/slider-events.ts
interface SliderValueEventDetail {
value: number;
}
interface SliderEventMap {
'value-change': CustomEvent<SliderValueEventDetail>;
'value-commit': CustomEvent<SliderValueEventDetail>;
'drag-start': CustomEvent<void>;
'drag-end': CustomEvent<void>;
}
// Domain roots only emit drag events
interface DomainSliderEventMap {
'drag-start': CustomEvent<void>;
'drag-end': CustomEvent<void>;
}
Elements get typed addEventListener overloads:
interface TimeSliderElement {
addEventListener<K extends keyof DomainSliderEventMap>(
type: K,
listener: (this: TimeSliderElement, ev: DomainSliderEventMap[K]) => void,
options?: boolean | AddEventListenerOptions
): void;
}
Usage:
const slider = document.querySelector('media-time-slider');
slider.addEventListener('drag-start', () => {
// typed, no detail
});
const generic = document.querySelector('media-slider');
generic.addEventListener('value-change', (e) => {
e.detail.value; // typed as number
});
File Structure
Core
packages/core/src/core/ui/slider/
├── slider-core.ts # SliderCore class
├── slider-data-attrs.ts # SliderDataAttrs constant
├── slider-css-vars.ts # SliderCSSVars constant (fill, pointer, buffer)
├── time-slider-core.ts # TimeSliderCore class
├── time-slider-data-attrs.ts # TimeSliderDataAttrs constant (extends SliderDataAttrs + seeking)
├── volume-slider-core.ts # VolumeSliderCore class
├── index.ts # barrel exports
└── tests/
├── slider-core.test.ts
├── time-slider-core.test.ts
└── volume-slider-core.test.ts
packages/core/src/dom/ui/
├── slider.ts # createSlider() factory
├── slider-css-vars.ts # getSliderCSSVars(), getTimeSliderCSSVars()
├── event.ts # (existing) UIEvent types, + UIPointerEvent
└── tests/
└── slider.test.ts
React
packages/react/src/ui/slider/
├── index.ts # export * as Slider from './index.parts'
├── index.parts.ts # export { Root, Track, Fill, Buffer, Thumb, Preview, Value }
├── slider-context.tsx # SliderContext (state, formatting, data attrs)
├── slider-root.tsx # Slider.Root
├── slider-track.tsx # Slider.Track
├── slider-fill.tsx # Slider.Fill
├── slider-buffer.tsx # Slider.Buffer
├── slider-thumb.tsx # Slider.Thumb
├── slider-preview.tsx # Slider.Preview
├── slider-value.tsx # Slider.Value
└── tests/
packages/react/src/ui/time-slider/
├── index.ts # export * as TimeSlider from './index.parts'
├── index.parts.ts # export { Root } + re-export all generic parts
├── time-slider-root.tsx # Only domain-specific part
└── tests/
packages/react/src/ui/volume-slider/
├── index.ts # export * as VolumeSlider from './index.parts'
├── index.parts.ts # export { Root } + re-export all generic parts
├── volume-slider-root.tsx # Only domain-specific part
└── tests/
HTML
packages/html/src/ui/slider/
├── slider-element.ts # <media-slider> (generic root, for custom slider types)
├── slider-events.ts # SliderEventMap, DomainSliderEventMap, detail types
├── slider-track-element.ts # <media-slider-track>
├── slider-fill-element.ts # <media-slider-fill>
├── slider-buffer-element.ts # <media-slider-buffer>
├── slider-thumb-element.ts # <media-slider-thumb>
├── slider-preview-element.ts # <media-slider-preview>
├── slider-value-element.ts # <media-slider-value>
└── tests/
packages/html/src/ui/time-slider/
├── time-slider-element.ts # <media-time-slider> (only domain-specific element)
└── tests/
packages/html/src/ui/volume-slider/
├── volume-slider-element.ts # <media-volume-slider> (only domain-specific element)
└── tests/
HTML Elements
Domain root elements (TimeSliderElement, VolumeSliderElement) handle pointer events via createSlider().rootProps, CSS custom properties, and data attributes. They provide context that aligns children — formatting for <media-slider-value>, ARIA attrs for <media-slider-thumb>, keyboard step values, and domain-specific data attributes.
SliderThumbElement carries role="slider", tabindex="0", all ARIA attributes, and the keyboard handler from createSlider().thumbProps. It is always present in the DOM — users hide it visually with CSS for a "thumbless" look.
Other structural elements (SliderTrackElement, SliderFillElement, SliderBufferElement) are pure MediaElement subclasses with only a tagName. SliderValueElement reads formatted text from context. SliderPreviewElement is registered separately (see Registration below).
Data Attribute Inheritance
All state data attributes are applied to both the root and every child element. When Root updates data-dragging, Track, Fill, Buffer, Thumb, Preview, and Value also get data-dragging updated. This enables element-level CSS selectors (media-slider-thumb[data-dragging]) and direct Tailwind attributes (data-[dragging]:scale-120) without ancestor selectors.
The root element drives the updates — it computes state, then propagates data attributes to all registered children. In HTML, this happens via DOM attribute setting on child elements. In React, each child reads from slider context and applies attrs to its own element.
CSS Custom Properties
CSS custom properties (--media-slider-fill, --media-slider-pointer, --media-slider-buffer) are set on the Root element only. They cascade naturally to children via CSS inheritance — no explicit propagation needed. Children reference them with var().
This is different from data attributes (which are explicitly set on each child) because CSS inheritance handles cascading automatically, while data attributes don't cascade.
Registration
Registration files live in src/define/ui/ (following the existing convention), exported as @videojs/html/ui/* via package.json exports. Importing a domain slider auto-registers basic structural parts but NOT heavy optional parts like Preview.
// @videojs/html/ui/time-slider
// Registers: media-time-slider + basic parts
// (media-slider-track, media-slider-fill, media-slider-buffer,
// media-slider-thumb, media-slider-value)
// @videojs/html/ui/volume-slider
// Registers: media-volume-slider + basic parts
// @videojs/html/ui/slider-preview
// Registers: media-slider-preview (separate, opt-in)
This keeps the default bundle lean — preview involves positioning logic and is not needed for minimal slider usage.
Performance
Visibility optimization: HTML slider elements should use IntersectionObserver to skip reactive updates when not visible or not intersecting the viewport. When controls are auto-hidden, a hidden slider shouldn't trigger layout or paint. Important for battery life on mobile.
CSS containment: Slider elements should use contain: layout style for paint isolation. Prevents slider updates from triggering layout recalculations in parent elements.
CSS on Root: touch-action: none (prevent browser touch gestures during drag), user-select: none (prevent text selection during drag).
autocomplete="off" on Thumb (the focusable element) to prevent browser autocomplete from interfering with keyboard interaction.
Controls Integration
The slider exposes drag start/end events — React callback props, HTML custom DOM events (drag-start/drag-end). The controls feature (which manages auto-hide) should listen for these to pause auto-hide during drag — prevents the control bar from disappearing while the user is actively scrubbing. The slider has no knowledge of controls; it just exposes the interaction lifecycle.
Thumb Alignment
thumbAlignment prop on Root controls how the thumb relates to the track at min/max values. Default: 'center'.
Center Mode (Default)
Thumb center aligns with track edges. --media-slider-fill = valuePercent% directly.
Track: |════════════════════════════|
Thumb: ◯ (at 0%)
↑ center on left edge, half overflows left
Track: |════════════════════════════|
Thumb: ◯ (at 100%)
↑ center on right edge, half overflows right
No DOM measurement needed. Simple percentage mapping.
Edge Mode
Thumb stays fully within track bounds. At 0%, the thumb's leading edge aligns with the track's leading edge. At 100%, the thumb's trailing edge aligns with the track's trailing edge.
Track: |════════════════════════════|
Thumb: [◯] (at 0%, thumb pinned inside left edge)
Track: |════════════════════════════|
Thumb: [◯] (at 100%, thumb pinned inside right edge)
This requires adjusting --media-slider-fill so the percentage accounts for the thumb's physical size.
Edge Mode Computation
Root uses ResizeObserver on the Thumb and track elements to measure their sizes. The adjusted fill percentage maps the value to the inset travel range:
// thumbRatio = thumbSize / trackSize (e.g., 16px / 300px ≈ 0.053)
// thumbOffset = thumbRatio / 2
// adjustedPercent = thumbOffset + valuePercent * (1 - thumbRatio)
// At value 0%: adjustedPercent = thumbOffset (≈ 2.67%)
// At value 50%: adjustedPercent = thumbOffset + 0.5 * (1-ratio) (≈ 50%)
// At value 100%: adjustedPercent = 1 - thumbOffset (≈ 97.33%)
The adjustment is internal to Root — --media-slider-fill is set to the adjusted percentage. Users' CSS (left: var(--media-slider-fill), width: var(--media-slider-fill)) works unchanged. Fill and Thumb both consume the same adjusted value.
Implementation
In SliderCore: A new method adjustPercentForAlignment() takes the raw value percent and thumb/track dimensions, returns the adjusted percent. This keeps the computation runtime-agnostic (takes dimensions as numbers, doesn't read DOM).
In HTML elements: The domain root element (<media-time-slider>, <media-volume-slider>) creates a ResizeObserver in connectedCallback() observing both the Thumb and the Root (as track proxy). On resize, it reads borderBoxSize and stores the thumb/track dimensions. These are passed to SliderCore.adjustPercentForAlignment() during CSS var computation. Observer is disconnected in disconnectedCallback().
In React: The domain Root component uses a ResizeObserver via a ref callback on the rendered Thumb element. When dimensions change, it re-renders with updated fill values.
Vertical orientation: Uses height instead of width for both thumb and track measurements.
Constraints
SliderCoremust not import any DOM APIs — returns raw percentages, not CSS stringscreateSlidermust not reference React or Lit — usescreateStatefrom@videojs/storefor interaction state- Generic slider parts (Track, Fill, Buffer, Thumb, Preview, Value) must not know about time or volume
- Domain sliders only customize Root — all other parts are generic
Slider.*re-exports - Domain roots must not bake in child elements — the user composes everything
- Root handles pointer events and provides context. Thumb carries
role="slider", keyboard, and ARIA. No separate Control element. - Thumb is always present in the DOM — hide visually with CSS for a "thumbless" look
- Root provides context that aligns children: CSS vars, data attributes, value formatting, ARIA attrs and step values for Thumb
- CSS custom properties are outputs only — no component reads them back
- All percentages use 0-100 range throughout the stack. CSS var output uses 3 decimal places for smooth animation (
45.123%) - Registration from
@videojs/html/ui/*— domain sliders auto-register basic parts, preview is separate