# Design Decisions
Rationale behind slider component choices.
## Component Structure
### No Control Element
**Decision:** There is no separate Control element between Root and children. Root handles pointer events and provides context. Thumb carries `role="slider"`, keyboard focus, and ARIA attributes. All other children (Track, Fill, Buffer, Preview) are purely visual.
**Alternatives considered:**
- Separate Control element (Base UI approach) — adds a dedicated interactive area inside Root. Useful for general-purpose sliders where arbitrary content (marks, labels) might sit alongside the track. Control scopes the hit area so decorative elements don't trigger drag.
- Root as container + Control as interactive (our original design) — more flexibility, but adds an extra component and DOM node without a clear use case for media sliders.
**Rationale:** Media sliders have a constrained, known anatomy — track, fill, thumb, preview. There's no "arbitrary content next to the track" scenario that needs hit-area scoping. Preview is always absolutely positioned out of flow. Any decorative additions (ticks, markers) can use `pointer-events: none` to pass clicks through. Matches Radix and Vidstack. One fewer part to learn, one fewer DOM node.
### No Default Children
**Decision:** Domain sliders (`TimeSlider.Root`, `VolumeSlider.Root`) do not render default children. Users compose everything explicitly.
**Alternatives:**
- Bake in a default structure (track + fill + thumb) — easier to start, but breaks when users need different structures
- Provide a `Default` export alongside parts — extra API surface without clear benefit
**Rationale:** Compound components should not assume structure. Users always want different layouts (thumb vs no thumb, preview position, progress bar ordering). Forcing explicit composition avoids the "how do I remove the default thumb" problem.
### Compound Namespace Pattern
**Decision:** Use `Slider.*` and `TimeSlider.*` namespace exports, matching the `Time.*` pattern.
```tsx
import { TimeSlider } from '@videojs/react';
```
**Alternatives:**
- Flat exports (`SliderRoot`, `SliderThumb`, `TimeSliderRoot`) — verbose imports, no visual grouping
- Single namespace with all parts (`Slider.TimeRoot`) — conflates generic and domain-specific
**Rationale:** Namespaces make composition readable. Generic parts (`Slider.*`) are clearly reusable. Domain parts (`TimeSlider.*`) are clearly scoped. Matches the existing `Time.Value`, `Time.Group` pattern.
## Styling
### Compound Elements, Not Native ``
**Decision:** The slider is composed of separate DOM elements (Root, Track, Fill, Thumb, Preview) styled via CSS custom properties. It does not use a native `` under the hood.
Media Chrome takes the opposite approach — their `` wraps a native `` and styles it via browser pseudo-elements (`::-webkit-slider-runnable-track`, `::-webkit-slider-thumb`, `::-moz-range-progress`). Their CSS vars are **inputs** (theming tokens like `--media-range-track-height`), not outputs.
**Alternatives:**
- Native `` (Media Chrome approach) — browser handles fill/thumb positioning internally. No output CSS vars needed. But cross-browser styling is notoriously painful, pseudo-element APIs differ across browsers, and composing custom children (preview, progress, chapters) inside a native input is impossible.
**Rationale:** Compound composition gives full control over rendering, accessibility, and styling. Users compose exactly the parts they need. The tradeoff is that we need a mechanism to share continuous values (fill %, pointer %, buffer %) from Root to children — CSS custom properties fill that role. This is the same architecture as Radix and Vidstack.
### CSS Custom Properties as Output
**Decision:** Slider sets `--media-slider-fill`, `--media-slider-pointer`, and `--media-slider-buffer` as CSS custom properties on the Root element. Parts reference them with `var()`.
CSS vars communicate **continuous values** — percentages like `45.123%` — that CSS can't get any other way. Data attributes can carry discrete state (present/absent, enumerated strings), but `attr()` doesn't work for non-string CSS properties in any browser. So:
- **Data attributes** = discrete state (dragging yes/no, orientation horizontal/vertical)
- **CSS custom properties** = continuous values (fill %, pointer %, buffer %)
**Alternatives:**
- Inline styles on parts (e.g., `width: "45%"` on Fill) — opinionated. Assumes Fill uses `width`. Maybe someone wants `transform: scaleX()` instead. CSS vars let users decide.
- Data attributes with percentage values — CSS can't use attribute values for sizing (`attr()` only works for `content`)
- JavaScript-driven positioning via refs — unnecessary DOM coupling
- No output CSS vars (Media Chrome approach) — only works if you use a native `` under the hood. See "Compound Elements, Not Native ``" above.
**Rationale:** CSS custom properties are the established pattern for headless component output values. They cascade naturally, can be overridden, and work with any styling approach (CSS modules, Tailwind, styled-components). Vidstack uses the same pattern (`--slider-fill`, `--slider-pointer`).
### `--media-slider-*` Prefix
**Decision:** Use `--media-slider-fill`, `--media-slider-pointer`, `--media-slider-buffer` as CSS custom property names.
**Alternatives:**
- `--vjs-slider-*` — ties to Video.js brand, awkward if used outside VJS context
- `--slider-*` — too generic, high collision risk with user styles
- `--videojs-slider-*` — verbose
**Rationale:** `--media-` prefix is descriptive without being brand-specific. Matches the `media-*` custom element prefix. Clear that these are media-player slider values.
### CSSVars Constants Parallel to DataAttrs
**Decision:** CSS custom properties get their own `*-css-vars.ts` constant files, mirroring the `*-data-attrs.ts` pattern. Each property has a semantic key, a `--media-*` value, and a JSDoc description.
```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;
```
**Alternatives:**
- Inline strings in `getCSSVars()` — no single source of truth, descriptions lost, site builder can't extract them
- Combined `SliderAttrs` object with both data attrs and CSS vars — conflates two distinct concerns with different naming conventions
**Rationale:** Data attributes already have this pattern and the site docs builder already extracts them. CSS custom properties are equally important to document — they're the primary API for styling slider parts. Separate files keep concerns clean (`data-*` vs `--media-*`). The builder can be extended to discover `*-css-vars.ts` files with the same extraction logic.
### No Part Identification Attributes
**Decision:** No `data-part` or similar attributes. In HTML, tag names identify parts (``). In React, users apply their own classes.
**Alternatives:**
- `data-part="track"` on each part — enables `[data-part="track"]` selectors regardless of platform
- Component-scoped attributes like Base UI (`data-slider-control`) — more specific but verbose
**Rationale:** HTML custom elements are self-identifying by tag name — `media-slider-track {}` in CSS is natural. In React, users already compose their own elements and provide `className`. Adding `data-part` creates a parallel identification system that's redundant with both approaches. State attributes (`data-dragging`, `data-interactive`) are the only data attributes needed.
### Thumb Alignment: Center and Edge Modes
**Decision:** Support `thumbAlignment: 'center' | 'edge'` prop on Root. Default `'center'`.
- **Center:** Thumb center aligns with the track edge at min/max. At 0%, the thumb center sits on the left edge (half the thumb visually overflows). At 100%, thumb center on the right edge. `--media-slider-fill` maps 0–100% directly. This is the standard media player visual.
- **Edge:** Thumb stays fully within the track at min/max. At 0%, the thumb's left edge aligns with the track's left edge. At 100%, the thumb's right edge aligns with the track's right edge. Root uses `ResizeObserver` on the Thumb element to measure its size, then computes an adjusted `--media-slider-fill` that maps the value to the inset range.
Base UI's third variant `'edge-client-only'` (deferred to client render with a pre-hydration script) is omitted — media players are inherently client-rendered.
**Alternatives:**
- CSS-only approach — require users to set `--media-slider-thumb-offset` matching their thumb size, then use `calc()` in positioning. Simpler, no DOM measurement, but error-prone when thumb size changes (responsive, different skins) and puts burden on the user.
- Center only, no edge mode — some designs require the thumb pinned within track bounds. Without edge mode, users must manually compute inset positioning.
**Rationale:** DOM measurement via `ResizeObserver` is more robust than requiring a CSS var — it adapts automatically to any thumb styling, responsive size changes, and orientation changes. The adjustment is internal to Root's CSS var computation; users' CSS (`left: var(--media-slider-fill)`, `width: var(--media-slider-fill)`) works unchanged in both modes. See [architecture.md](architecture.md#thumb-alignment) for the computation details.
### Fill Uses CSS Custom Properties, Not Inline Styles
**Decision:** Fill/Indicator positioning is driven entirely by CSS custom properties (`--media-slider-fill`) set on Root. No inline `width` or `left` styles are applied to Fill or Thumb elements. Users style parts via `var()` references.
Base UI's `SliderIndicator` computes and applies inline styles (`width: "45%"`, `insetInlineStart: "0"`) directly on the element. This is necessary for their approach because they support range sliders where the indicator spans between two thumbs and must calculate both start and width.
**Alternatives:**
- Inline styles on Fill (Base UI approach) — opinionated about which CSS property to use. Assumes `width` for horizontal, `height` for vertical. Harder to override.
**Rationale:** CSS vars are our established output mechanism. They let users choose how to consume the value — `width`, `transform: scaleX()`, clip-path, or any other property. This matters because different slider designs use different visual techniques. Single-thumb media sliders always start at 0%, so `--media-slider-fill` as a width percentage covers the standard case without needing a separate start position.
## State
### State Attributes
**Decision:** Five state data attributes on Root: `data-dragging`, `data-pointing`, `data-interactive`, `data-orientation`, `data-disabled`. Time slider adds `data-seeking`.
**Alternatives:**
- `data-at-min` / `data-at-max` — considered but cut. Edge styling can use `--media-slider-fill: 0%` or `100%` in CSS
- `data-focused` — not needed. Focus is on Thumb, visible via `:focus-visible` on `media-slider-thumb`
- `data-hovering` — `data-pointing` is more precise (pointer is over element, not just CSS hover)
**Rationale:** These attributes cover the CSS use cases: show preview when interactive, enlarge thumb when dragging, dim when disabled, adjust layout for orientation. `data-interactive` is the compound state `(pointing || focused || dragging)` that's most useful for styling.
### Children Inherit All Data Attributes
**Decision:** All child parts (Track, Fill, Thumb, Preview, Value) receive the same state data attributes as Root. Every child gets `data-dragging`, `data-pointing`, `data-interactive`, `data-orientation`, `data-disabled` applied directly. For time sliders, children also get `data-seeking`.
Radix confirms this pattern — their Slider.Track, Slider.Range, and Slider.Thumb all receive `[data-disabled]` and `[data-orientation]`. Vidstack does the same with interaction attributes.
**Alternatives:**
- Root-only attributes — children use ancestor selectors like `[data-dragging] media-slider-fill { }`. Fewer DOM mutations, but forces verbose CSS.
- Selective inheritance (some children get some attrs) — inconsistent, requires memorizing which children get what.
**Rationale:**
- **Tailwind** — without inheritance, you need `group-data-[dragging]:` on every child. With it, you write `data-[dragging]:scale-120` directly. Much cleaner.
- **CSS specificity** — `media-slider-thumb[data-dragging]` is more specific and scoped than `[data-dragging] media-slider-thumb`.
- **Consistency** — all children get all attrs. No exceptions, no rules to remember.
- **Performance** — updating 3-5 elements on state change is negligible.
### `interactive` as Derived State
**Decision:** `interactive = pointing || focused || dragging`. Exposed as both a state property and `data-interactive` attribute.
**Alternatives:**
- Only expose primitives (`pointing`, `focused`, `dragging`) — forces users to combine them in CSS with `[data-pointing], [data-focused], [data-dragging]` selectors
- Use `data-active` — ambiguous, could mean dragging or just focused
**Rationale:** 90% of slider styling needs "is the user engaging with this slider?" as a single check. The preview container shows on interactive, the track enlarges on interactive, etc. Naming it `interactive` is clearer than `active` or `engaged`.
## Interaction
### createSlider Works in Percentages (0-100)
**Decision:** `createSlider()` operates in 0-100 percentages, not raw values. Consistent with `SliderState.fillPercent` and CSS var output. The caller converts using `SliderCore.valueFromPercent()`.
**Alternatives:**
- 0-1 normalized range — one fewer multiplication, but creates a unit boundary between `createSlider` (0-1) and everything else (0-100). Easy source of bugs.
- Pass min/max/step to `createSlider` so it returns raw values — couples DOM interaction to value domain
- Return both percent and raw value — redundant, caller can derive
**Rationale:** Separation of concerns. `createSlider` knows about pointer positions and element geometry — it produces a percentage. `SliderCore` knows about value ranges and steps — it converts percentages to values. Neither needs to know the other's domain. Using 0-100 avoids a conversion boundary — the same unit flows from `createSlider` through `SliderState` to CSS output.
### Keyboard Commits Immediately
**Decision:** Keyboard input calls both `onValueChange` and `onValueCommit` on each keypress. No "keyboard drag" concept.
**Alternatives:**
- Treat key hold as "dragging" with commit on key up — matches pointer behavior but feels wrong for discrete steps
- Only commit on focus leave — too delayed, user expects immediate feedback
**Rationale:** Keyboard steps are discrete and immediate. User presses arrow, slider moves one step, value is committed. This matches WAI-ARIA slider behavior and every existing slider implementation.
### Pointer Drag Uses Document Listeners
**Decision:** On `pointerdown`, add `pointermove` and `pointerup` listeners on `document` to capture drag even when pointer leaves the slider bounds. Also add a document-level `touchmove` listener with `{ passive: false }` that calls `preventDefault()` to block page scrolling during drag.
**Alternatives:**
- `setPointerCapture` — cleaner API, guarantees pointer events fire on the capturing element regardless of pointer position. Base UI uses both (capture + document listeners). But `setPointerCapture` changes the coordinate reference for `pointermove` events in some browsers, complicating position-relative-to-track calculations. Vidstack doesn't use it either.
- Listen on the element only — drag stops when pointer leaves bounds
**Implementation notes:**
- **Lost pointerup detection:** During `pointermove`, check `event.buttons === 0` and treat it as drag end. This handles the edge case where another element consumed the `pointerup` event (e.g., a dialog appearing mid-drag). Base UI implements this. Exception: for `event.pointerType === 'touch'`, `buttons` is unreliable — touch relies on `pointerup`/`pointercancel` instead.
- **`pointercancel` handling:** Listen for `pointercancel` on document alongside `pointerup`. Fires when the browser takes over the gesture (e.g., navigation swipe). Without it, the slider gets stuck in dragging state.
- **Document touchmove:** `touch-action: none` on the slider element prevents the browser from scrolling when the touch starts on the slider. But once the pointer moves outside the slider during drag (tracked via document listeners), the browser may still attempt to scroll. A document-level `touchmove` with `{ passive: false }` + `preventDefault()` blocks this. Vidstack uses this pattern. The listener is added on `pointerdown` and removed on `pointerup`/`pointercancel`/drag end — never left attached.
- **Passive pointermove:** The document-level `pointermove` listener can use `{ passive: true }` since it doesn't need to call `preventDefault()`.
**Rationale:** Document-level listeners are the standard approach for drag interactions. The pointer can move anywhere on screen and the slider still tracks position. Cleanup on `pointerup` prevents memory leaks. Both Vidstack and Base UI use document-level listeners.
### Pointer Events Only, No Separate Touch Path
**Decision:** The slider uses pointer events exclusively (`pointerdown`, `pointermove`, `pointerup`, `pointerenter`, `pointerleave`) for all interaction. No separate `touchstart`/`touchmove`/`touchend` handlers on the slider element.
**Alternatives:**
- Separate touch and pointer code paths (Base UI approach) — Base UI registers `touchstart` via raw `addEventListener` to set `{ passive: true }` (React's synthetic events don't support the passive option), and tracks `touchIdRef` to follow the correct finger across multiple thumbs. Touch events on document are also registered separately.
**Rationale:** `touch-action: none` on Root tells the browser not to interpret touches as scroll/zoom, so pointer events fire reliably for all touch input. Single-thumb sliders don't need multi-finger tracking. Pointer Events API has 98%+ browser support and covers all Video.js 10 targets. Vidstack uses the same approach — pointer events only on the slider, `touch-action: none` via CSS.
The one exception is the document-level `touchmove` `preventDefault()` during drag (see [Pointer Drag Uses Document Listeners](#pointer-drag-uses-document-listeners)) — that's a scroll-blocking measure, not touch interaction handling.
### Intentional Drag Threshold
**Decision:** On `pointerdown`, the value changes immediately (click-to-seek on the track area). But `dragging` state (`data-dragging`, `onDragStart` callback) is not activated until a small number of `pointermove` events have fired (threshold of 2), confirming intentional drag movement.
**Alternatives:**
- Set `dragging` immediately on `pointerdown` (Vidstack approach) — simpler, but causes a momentary `data-dragging` flash on simple click-to-seek interactions. Any CSS tied to `data-dragging` (thumb scale, track enlargement) flickers briefly.
- Wait for movement before any value change — too delayed, click-to-seek should be instant.
**Rationale:** Distinguishes click-to-seek from intentional drag. A simple click (pointerdown → pointerup with minimal movement) should update the value but not trigger drag UI. Base UI uses a threshold of 2 `pointermove` events before setting `dragging = true`, while still calling `onValueChange` immediately. This means: value feedback is instant, drag UI is intentional.
### RTL Keyboard Direction
**Decision:** `ArrowRight` decreases the value in RTL layouts, `ArrowLeft` increases. `ArrowUp`/`ArrowDown` are unaffected by direction. `createSlider` accepts an `isRTL` callback and flips the direction multiplier for horizontal arrow keys.
**Alternatives:**
- Ignore RTL — broken for RTL users. Arrow keys would move the opposite direction of visual expectation.
- Handle at the UI layer — possible, but since `createSlider` owns the keyboard handler it should handle this internally.
**Rationale:** Standard behavior per WAI-ARIA and every slider implementation. Both Base UI and Vidstack flip horizontal arrow direction for RTL. `createSlider` works in percentages, so the flip is a direction multiplier change at the keyboard handler level — `ArrowRight` in LTR adds `stepPercent`, in RTL subtracts it.
### Seek During Drag (Throttled)
**Decision:** Time slider seeks during drag for video preview feedback, throttled via `seekThrottle` prop (default 100ms, trailing-edge). `0` disables throttling. The throttle lives in `createSlider` (DOM layer), not in `TimeSliderCore`.
During drag: `onValueChange` fires on every pointermove (updates local visual state — fill bar, preview). `onValueCommit` fires through the throttle (triggers `time.seek()`). On drag end, a final unthrottled `onValueCommit` fires with the final value.
Volume slider does not use seek throttling — volume changes are instant and cheap.
**Alternatives:**
- Seek only on drag end — no visual video feedback during scrub. Poor UX for time slider.
- Seek unthrottled on every pointermove — floods the media element with seek requests during fast drags. Poor performance.
- Throttle in `TimeSliderCore` — Core would need timer state, breaking the stateless pattern. DOM layer already manages interaction timers (drag threshold).
**Rationale:** Vidstack uses a similar `seekingRequestThrottle` (100ms default). The DOM layer is the natural home since it already manages pointermove timing and drag state. Core stays pure computation.
### Focus Thumb on Track Click
**Decision:** `pointerdown` on Root programmatically focuses the Thumb element. This ensures `interactive` state is correct (includes `focused`), `:focus-visible` styling works on the Thumb, and screen readers track the active element during pointer interaction.
**Alternatives:**
- Don't focus on click — user must tab to Thumb separately for keyboard control after a click. `:focus-visible` won't show on the Thumb after clicking the track. Poor UX and a11y.
**Rationale:** The Thumb is the semantic slider element (`role="slider"`). When the user interacts with the slider via pointer, the Thumb should receive focus so keyboard follow-up works immediately. This matches Radix and Base UI behavior.
### CSS Var Formatting in DOM Layer
**Decision:** `SliderCore` returns raw percentages in `SliderState` (`fillPercent`, `pointerPercent`). `TimeSliderState` adds `bufferPercent`. CSS custom property formatting (`--media-slider-fill: "45.123%"`) is done by `getSliderCSSVars()` in `@videojs/core/dom`.
The `SliderCSSVars` constant (property names + JSDoc) stays in `@videojs/core` for documentation extraction.
**Alternatives:**
- Format in `SliderCore` (`getCSSVars()` returning CSS strings) — convenient, but leaks a DOM concern into the runtime-agnostic layer. React Native has no CSS custom properties.
- Format in each UI layer (React, HTML) — duplicates the same `toFixed(3)` logic.
**Rationale:** The math is framework-agnostic (Core computes percentages). The formatting is DOM-specific (CSS var strings). `core/dom` is the bridge layer — it already contains `createSlider()` which handles DOM interaction. Adding CSS var formatting here keeps Core pure and avoids duplicating logic across React and HTML UI layers.
### Muted Volume aria-valuetext
**Decision:** When muted, `VolumeSliderCore` sets `aria-valuetext` to `"{volume} percent, muted"` (e.g., `"75 percent, muted"`). `aria-valuenow` reflects the actual underlying volume (not 0). Fill visually shows 0%.
**Alternatives:**
- `"muted"` only — loses underlying volume information. User doesn't know what volume they'll hear when unmuting.
- `"0 percent, muted"` — misleading. The volume isn't 0, it's muted.
- `"0 percent"` (match visual) — no indication of muted state. Confusing if the user adjusts volume while muted.
**Rationale:** Communicates both the muted state and the underlying volume. Screen reader users need to know what volume level is set so they can adjust before unmuting. Matches the visual design where fill is 0% (communicating silence) while the actual value is preserved.
### Interaction State via `createState`
**Decision:** `createSlider()` manages interaction state (`SliderInteraction`) internally using `createState()` from `@videojs/store`. Pointer and keyboard handlers patch this state directly. The returned `interaction` is a read-only `State` that UI layers subscribe to.
```ts
const slider = createSlider(options);
slider.interaction.current; // { dragging, pointing, focused, pointerPercent, dragPercent }
slider.interaction.subscribe(cb); // notified on change
```
**Alternatives:**
- UI layer manages interaction state (React `useState`, Lit reactive properties) — requires each framework to independently maintain the same state shape from `createSlider` callbacks. Duplicates state management logic. The UI adapter becomes thicker.
- `createSlider` returns a plain object (not reactive) — frameworks must manually track changes via callbacks. No subscribable primitive.
- Signals/observables — heavier abstraction not present in the codebase.
**Rationale:** `createState` is already the reactive primitive used by the store system. It's lightweight (no slice/target ceremony), auto-batches multiple patches per microtask (good for high-frequency pointermove), and both React and Lit already know how to subscribe: React via `useSyncExternalStore` (the `.subscribe` + `.current` signature matches exactly), Lit via `.subscribe(() => this.requestUpdate())`. This eliminates state management boilerplate from both UI adapters — they just subscribe and read `.current`.
### Core Accepts Split (Interaction, Media) Inputs
**Decision:** `SliderCore.getState()` accepts `SliderInteraction` and a value separately. Domain cores (`TimeSliderCore`, `VolumeSliderCore`) accept `(media, interaction)` where media is the canonical type from `@videojs/core` (`MediaTimeState & MediaBufferState`, `MediaVolumeState`). Core owns the merge logic — e.g., the value swap (`dragging ? valueFromPercent(dragPercent) : currentTime`). Media is the first parameter since it's the primary input; interaction is secondary context.
**Alternatives:**
- Single flat params bag (`SliderStateParams` with both interaction and media fields) — conflates two sources, puts domain merge logic (value swap) in the UI layer, forces both frameworks to implement the same logic.
- Core only accepts the merged result — still puts merge logic in the UI layer.
**Rationale:** The two inputs have different provenance: interaction comes from `createSlider` (DOM events), media comes from the store. Keeping them separate makes the data flow clear and puts domain logic (value swap during drag, buffer percentage computation) in Core where both frameworks get it for free. UI adapters become thin pass-throughs: subscribe to interaction, subscribe to store, pass both to Core.
### Custom DOM Events (HTML)
**Decision:** HTML custom elements dispatch custom DOM events using **kebab-case** names. All events **bubble**. Events carrying data use `CustomEvent` with a typed `detail`.
| Event | Detail | Components |
| --- | --- | --- |
| `value-change` | `{ value: number }` | Generic `Slider.Root` only |
| `value-commit` | `{ value: number }` | Generic `Slider.Root` only |
| `drag-start` | — | All slider roots |
| `drag-end` | — | All slider roots |
**Event naming:** Kebab-case (`drag-start`, not `dragstart`). This avoids conflicts with native events (e.g., `dragstart` from HTML DnD) and is consistent with the kebab-case custom element naming convention.
**Bubbling:** All events bubble, consistent with native `input`/`change` events. Ancestor containers can listen without direct element references.
**Typed event maps:** HTML package exports `SliderEventMap` and `DomainSliderEventMap` interfaces. Elements get typed `addEventListener` overloads for TypeScript consumers.
**Alternatives:**
- No events, store-only — existing convention (buttons dispatch no events). But sliders need user-facing interaction callbacks that the store doesn't cover (drag lifecycle, value changes for generic slider).
- Property callbacks (`element.onValueChange = fn`) — non-standard for custom elements, only one listener per event.
- `media-*` prefixed events — unnecessary with kebab-case since there's no conflict.
- Non-bubbling events — surprising on the web platform. Would require direct element references.
**Rationale:** This is a new convention for `@videojs/html` — the first components with custom events. The slider is the first component that needs user-facing interaction callbacks beyond what the store provides. Custom DOM events are idiomatic web platform, work with `addEventListener`, and compose naturally with event delegation. Typed event maps give TypeScript consumers full type safety.
## Accessibility
### ARIA on Thumb
**Decision:** `role="slider"`, `tabindex="0"`, `aria-valuenow`, `aria-valuetext`, and all other ARIA attributes are on the **Thumb** element. Thumb is the focusable element that represents the slider to assistive technology. Root handles pointer events only.
This follows the [WAI-ARIA Slider Pattern](https://www.w3.org/WAI/ARIA/apg/patterns/slider/) and the [Media Seek Slider Example](https://www.w3.org/WAI/ARIA/apg/patterns/slider/examples/slider-seek/), where the thumb (the movable element) carries the slider role and keyboard focus.
**Alternatives:**
- ARIA on Root (our previous design, Media Chrome approach) — Root as the focusable `role="slider"` element. Simpler since Root already handles pointer events, but conflicts with the APG pattern where the thumb is the slider. Also creates an awkward focus target — the entire container receives focus rather than the specific handle element.
- Separate Control element with `role="slider"` (Base UI) — adds an extra element. See "No Control Element" above.
- Use a hidden `` — semantically correct but adds hidden elements and synchronization complexity.
**Rationale:** The APG spec and its seek slider example are clear: the thumb is the slider. The thumb is the element users interact with conceptually — it's the "handle" they grab. Placing `role="slider"` on Root conflates the hit area (the full track, which should be clickable) with the semantic control (the thumb). Splitting responsibility — Root owns the hit area and context, Thumb owns focus and ARIA — is cleaner and spec-compliant.
### Thumb Always Present
**Decision:** `Slider.Thumb` / `` is **always present** in the DOM. Users who want a "thumbless" visual slider hide it with CSS. The element remains focusable and announced by screen readers.
**Alternatives:**
- Thumb is optional, ARIA goes on Root when no Thumb — requires conditional ARIA placement, increases complexity, and the "no thumb" case still needs a focus target
- Thumb is optional, add a visually hidden focus element when no Thumb — even more complex, two different code paths for the same semantic purpose
**Rationale:** The thumb carries `role="slider"` and keyboard focus. Without it, there's no element to receive focus or announce the slider to assistive technology. Making it always present eliminates edge cases. The visual "thumbless" look is purely CSS — the semantic and interactive elements remain intact. This matches the APG pattern where the thumb is the slider.
### No Hidden ``
**Decision:** No visually hidden `` is rendered inside the slider. Radix and Base UI render hidden inputs per thumb for form integration — so the slider value can be submitted as form data and native form events (submit, reset) work correctly.
**Alternatives:**
- Hidden `` per thumb (Radix, Base UI) — enables form submission and native validation. Required when sliders live inside `