media-time-slider
A slider component for seeking through media playback time
Anatomy
<TimeSlider.Root>
<TimeSlider.Track>
<TimeSlider.Buffer />
<TimeSlider.Fill />
</TimeSlider.Track>
<TimeSlider.Thumb />
<TimeSlider.Preview>
<TimeSlider.Value type="pointer" />
</TimeSlider.Preview>
</TimeSlider.Root><media-time-slider>
<media-slider-track>
<media-slider-buffer></media-slider-buffer>
<media-slider-fill></media-slider-fill>
</media-slider-track>
<media-slider-thumb></media-slider-thumb>
<media-slider-preview>
<media-slider-value type="pointer"></media-slider-value>
</media-slider-preview>
</media-time-slider>Add Chapters to progressively divide the track when a default kind="chapters" text track is available.
<TimeSlider.Chapters
renderChapter={(props, state) => (
<div {...props}>
<TimeSlider.Track>
<TimeSlider.Buffer />
<TimeSlider.Fill />
</TimeSlider.Track>
</div>
)}
>
<TimeSlider.Track>
<TimeSlider.Buffer />
<TimeSlider.Fill />
</TimeSlider.Track>
</TimeSlider.Chapters>children render as the regular slider track until authored chapters are available. renderChapter receives the attributes, styles, and state for each normalized chapter.
<media-time-slider-chapters>
<template>
<div>
<media-slider-track>
<media-slider-buffer></media-slider-buffer>
<media-slider-fill></media-slider-fill>
</media-slider-track>
</div>
</template>
</media-time-slider-chapters>The template must contain exactly one HTML root element. It is cloned for each normalized chapter, including a full-duration range while chapter cues are unavailable. If the template is missing or invalid, non-template fallback content remains in place.
Behavior
Displays and controls the current playback position. Dragging the slider seeks the media. The fill level reflects currentTime / duration as a percentage, and the buffer level shows how much media has been buffered.
Value changes during drag are throttled via the changeThrottle prop (default 100ms) using a leading+trailing throttle to keep the UI responsive without overwhelming the media element.
Chapter cues are normalized into contiguous ranges. Any uncovered time becomes a gap range, so track geometry still spans the full duration. Gap ranges receive the same geometry styles but cannot be highlighted and have no chapter title.
Styling
Use CSS custom properties to style the fill, pointer, and buffer levels:
media-time-slider::before {
width: var(--media-slider-fill);
}React renders a <div> element. Add a className to style it:
.time-slider::before {
width: var(--media-slider-fill);
}Use data-seeking to style during active seek operations:
media-time-slider[data-seeking] {
opacity: 0.8;
}.time-slider[data-seeking] {
opacity: 0.8;
}Each rendered chapter receives data-active while playback is inside it and data-highlighted while the pointer is inside an authored chapter. Its geometry and local progress are exposed through:
--media-slider-chapter-start--media-slider-chapter-end--media-slider-chapter-width--media-slider-chapter-fill--media-slider-chapter-buffer
The generated fill and buffer parts also inherit the slider-level --media-slider-fill and --media-slider-buffer values. This allows every chapter to render full-width progress planes and use its chapter bounds as a clip, keeping progress transitions continuous across segments.
Accessibility
Renders with role="slider" and an automatic aria-label that resolves to “Seek” from the active locale. Override with the label prop. Keyboard controls:
- Arrow Left / Arrow Right: step by
stepincrement - Page Up / Page Down: step by
largeStepincrement - Home: seek to start
- End: seek to end
ChapterTitle follows the pointer during pointer interaction. While the slider has keyboard focus, it follows the current playback position and announces chapter changes.
Examples
Nest sub-components for full control over the slider’s DOM structure. This example includes a track, fill bar, buffer indicator, draggable thumb, and a tooltip that shows the pointed-at time.
import { createPlayer, TimeSlider } from '@videojs/react';
import { Video, videoFeatures } from '@videojs/react/video';
const Player = createPlayer({ features: videoFeatures });
export default function WithParts() {
return (
<Player.Provider>
<Player.Container className="media-container">
<Video src="https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/highest.mp4" autoPlay muted playsInline loop />
<TimeSlider.Root className="media-time-slider">
<TimeSlider.Track className="media-slider-track">
<TimeSlider.Buffer className="media-slider-buffer" />
<TimeSlider.Fill className="media-slider-fill" />
</TimeSlider.Track>
<TimeSlider.Thumb className="media-slider-thumb" />
<TimeSlider.Value type="pointer" className="media-slider-value" />
</TimeSlider.Root>
</Player.Container>
</Player.Provider>
);
}
.media-container {
position: relative;
}
.media-container video {
width: 100%;
}
.media-time-slider {
position: absolute;
right: 0;
bottom: 0;
left: 0;
display: flex;
align-items: center;
height: 20px;
cursor: pointer;
}
.media-slider-track {
position: absolute;
right: 0;
left: 0;
height: 4px;
background: rgba(255, 255, 255, 0.3);
border-radius: 9999px;
transition: height 150ms ease;
}
.media-time-slider[data-interactive] .media-slider-track {
height: 6px;
}
.media-slider-buffer {
position: absolute;
top: 0;
left: 0;
width: var(--media-slider-buffer);
height: 100%;
background: rgba(255, 255, 255, 0.4);
border-radius: 9999px;
}
.media-slider-fill {
position: absolute;
top: 0;
left: 0;
width: var(--media-slider-fill);
height: 100%;
background: white;
border-radius: 9999px;
}
.media-slider-thumb {
position: absolute;
left: var(--media-slider-fill);
width: 14px;
height: 14px;
background: white;
border-radius: 50%;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
transform: translateX(-50%) scale(0);
transition: transform 150ms ease;
}
.media-time-slider[data-interactive] .media-slider-thumb {
transform: translateX(-50%) scale(1);
}
.media-time-slider[data-dragging] .media-slider-thumb {
left: var(--media-slider-pointer);
transform: translateX(-50%) scale(1.1);
}
.media-time-slider[data-dragging] .media-slider-fill {
width: var(--media-slider-pointer);
}
.media-slider-value {
position: absolute;
bottom: 100%;
left: var(--media-slider-pointer);
padding: 2px 6px;
margin-bottom: 6px;
font-size: 12px;
color: white;
white-space: nowrap;
pointer-events: none;
background: rgba(0, 0, 0, 0.8);
border-radius: 4px;
opacity: 0;
transform: translateX(-50%);
transition: opacity 150ms ease;
}
.media-time-slider[data-pointing] .media-slider-value {
opacity: 1;
}
<video-player class="video-player">
<media-container>
<video
src="https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/highest.mp4"
autoplay
muted
playsinline
loop
></video>
<media-time-slider class="media-time-slider">
<media-slider-track class="media-slider-track">
<media-slider-buffer class="media-slider-buffer"></media-slider-buffer>
<media-slider-fill class="media-slider-fill"></media-slider-fill>
</media-slider-track>
<media-slider-thumb class="media-slider-thumb"></media-slider-thumb>
<media-slider-value type="pointer" class="media-slider-value"></media-slider-value>
</media-time-slider>
</media-container>
</video-player>
.video-player,
.video-player media-container {
position: relative;
display: block;
}
.video-player video {
width: 100%;
}
.media-time-slider {
position: absolute;
right: 0;
bottom: 0;
left: 0;
display: flex;
align-items: center;
height: 20px;
cursor: pointer;
}
.media-slider-track {
position: absolute;
right: 0;
left: 0;
height: 4px;
background: rgba(255, 255, 255, 0.3);
border-radius: 9999px;
transition: height 150ms ease;
}
.media-time-slider[data-interactive] .media-slider-track {
height: 6px;
}
.media-slider-buffer {
position: absolute;
top: 0;
left: 0;
width: var(--media-slider-buffer);
height: 100%;
background: rgba(255, 255, 255, 0.4);
border-radius: 9999px;
}
.media-slider-fill {
position: absolute;
top: 0;
left: 0;
width: var(--media-slider-fill);
height: 100%;
background: white;
border-radius: 9999px;
}
.media-slider-thumb {
position: absolute;
left: var(--media-slider-fill);
width: 14px;
height: 14px;
background: white;
border-radius: 50%;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
transform: translateX(-50%) scale(0);
transition: transform 150ms ease;
}
.media-time-slider[data-interactive] .media-slider-thumb {
transform: translateX(-50%) scale(1);
}
.media-time-slider[data-dragging] .media-slider-thumb {
left: var(--media-slider-pointer);
transform: translateX(-50%) scale(1.1);
}
.media-time-slider[data-dragging] .media-slider-fill {
width: var(--media-slider-pointer);
}
.media-slider-value {
position: absolute;
bottom: 100%;
left: var(--media-slider-pointer);
padding: 2px 6px;
margin-bottom: 6px;
font-size: 12px;
color: white;
white-space: nowrap;
pointer-events: none;
background: rgba(0, 0, 0, 0.8);
border-radius: 4px;
opacity: 0;
transform: translateX(-50%);
transition: opacity 150ms ease;
}
.media-time-slider[data-pointing] .media-slider-value {
opacity: 1;
}
import '@videojs/html/video/player';
import '@videojs/html/ui/time-slider';
With chapters
Add a default chapters track, then provide the markup used for each chapter. The deliberate one-minute gap in this example remains visible as an unlabelled, non-highlightable segment.
import { createPlayer, TimeSlider } from '@videojs/react';
import { Video, videoFeatures } from '@videojs/react/video';
const Player = createPlayer({ features: videoFeatures });
export default function WithChapters() {
return (
<Player.Provider>
<Player.Container className="media-container">
<Video src="https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/highest.mp4" autoPlay muted playsInline loop crossOrigin="anonymous">
<track kind="chapters" src="/docs/demos/time-slider/chapters.vtt" srcLang="en" default />
</Video>
<TimeSlider.Root className="media-time-slider">
<TimeSlider.Chapters
className="media-slider-chapters"
renderChapter={(props) => (
<div {...props} className="media-slider-chapter">
<TimeSlider.Track className="media-slider-track">
<TimeSlider.Buffer className="media-slider-buffer" />
<TimeSlider.Fill className="media-slider-fill" />
</TimeSlider.Track>
</div>
)}
>
<TimeSlider.Track className="media-slider-track">
<TimeSlider.Buffer className="media-slider-buffer" />
<TimeSlider.Fill className="media-slider-fill" />
</TimeSlider.Track>
</TimeSlider.Chapters>
<TimeSlider.Thumb className="media-slider-thumb" />
<TimeSlider.Preview className="media-slider-preview">
<TimeSlider.ChapterTitle className="media-slider-chapter-title" />
<TimeSlider.Value type="pointer" />
</TimeSlider.Preview>
</TimeSlider.Root>
</Player.Container>
</Player.Provider>
);
}
.media-container {
position: relative;
}
.media-container video {
width: 100%;
}
.media-time-slider {
position: absolute;
right: 12px;
bottom: 8px;
left: 12px;
display: flex;
align-items: center;
height: 20px;
color: white;
cursor: pointer;
}
.media-slider-chapters {
position: relative;
width: 100%;
height: 100%;
}
.media-slider-chapter {
position: absolute;
inset: 0;
display: flex;
align-items: center;
clip-path: inset(0 calc(100% - var(--media-slider-chapter-end)) 0 var(--media-slider-chapter-start));
}
.media-slider-track {
position: absolute;
right: 0;
left: 0;
height: 4px;
overflow: hidden;
background: rgb(255 255 255 / 30%);
border-radius: 9999px;
transition: height 150ms ease;
}
.media-slider-chapter .media-slider-track {
clip-path: inset(
0 calc(100% - var(--media-slider-chapter-end) + 2px) 0 calc(var(--media-slider-chapter-start) + 2px) round 9999px
);
}
.media-slider-chapter[data-highlighted] .media-slider-track {
height: 7px;
}
.media-slider-buffer,
.media-slider-fill {
position: absolute;
inset-block: 0;
left: 0;
border-radius: inherit;
transition: width 200ms linear;
}
.media-slider-buffer {
width: var(--media-slider-buffer);
background: rgb(255 255 255 / 30%);
}
.media-slider-fill {
width: var(--media-slider-fill);
background: white;
}
.media-time-slider[data-dragging] .media-slider-fill {
width: var(--media-slider-pointer);
transition-duration: 0ms;
}
.media-slider-thumb {
position: absolute;
top: 50%;
left: var(--media-slider-fill);
width: 12px;
height: 12px;
background: white;
border-radius: 50%;
translate: -50% -50%;
}
.media-time-slider[data-dragging] .media-slider-thumb {
left: var(--media-slider-pointer);
}
.media-slider-preview {
position: absolute;
bottom: 100%;
left: var(--media-slider-pointer);
display: flex;
gap: 6px;
padding: 3px 7px;
color: white;
white-space: nowrap;
pointer-events: none;
background: rgb(0 0 0 / 80%);
border-radius: 4px;
opacity: 0;
translate: -50% -4px;
}
.media-time-slider[data-pointing] .media-slider-preview,
.media-time-slider[data-interactive]:not([data-pointing]) .media-slider-preview {
opacity: 1;
}
.media-slider-chapter-title:empty {
display: none;
}
<video-player class="video-player">
<media-container>
<video
src="https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/highest.mp4"
autoplay
muted
playsinline
loop
crossorigin="anonymous"
>
<track
kind="chapters"
src="/docs/demos/time-slider/chapters.vtt"
srclang="en"
default
/>
</video>
<media-time-slider class="media-time-slider">
<media-time-slider-chapters class="media-slider-chapters">
<template>
<div class="media-slider-chapter">
<media-slider-track class="media-slider-track">
<media-slider-buffer class="media-slider-buffer"></media-slider-buffer>
<media-slider-fill class="media-slider-fill"></media-slider-fill>
</media-slider-track>
</div>
</template>
</media-time-slider-chapters>
<media-slider-thumb class="media-slider-thumb"></media-slider-thumb>
<media-slider-preview class="media-slider-preview">
<media-time-slider-chapter-title class="media-slider-chapter-title"></media-time-slider-chapter-title>
<media-slider-value type="pointer"></media-slider-value>
</media-slider-preview>
</media-time-slider>
</media-container>
</video-player>
.video-player,
.video-player media-container {
position: relative;
display: block;
}
.video-player video {
width: 100%;
}
.media-time-slider {
position: absolute;
right: 12px;
bottom: 8px;
left: 12px;
display: flex;
align-items: center;
height: 20px;
color: white;
cursor: pointer;
}
.media-slider-chapters {
position: relative;
display: block;
width: 100%;
height: 100%;
}
.media-slider-chapter {
position: absolute;
inset: 0;
display: flex;
align-items: center;
clip-path: inset(0 calc(100% - var(--media-slider-chapter-end)) 0 var(--media-slider-chapter-start));
}
.media-slider-track {
position: absolute;
right: 0;
left: 0;
display: block;
height: 4px;
overflow: hidden;
background: rgb(255 255 255 / 30%);
border-radius: 9999px;
transition: height 150ms ease;
}
.media-slider-chapter .media-slider-track {
clip-path: inset(
0 calc(100% - var(--media-slider-chapter-end) + 2px) 0 calc(var(--media-slider-chapter-start) + 2px) round 9999px
);
}
.media-slider-chapter[data-highlighted] .media-slider-track {
height: 7px;
}
.media-slider-buffer,
.media-slider-fill {
position: absolute;
inset-block: 0;
left: 0;
display: block;
border-radius: inherit;
transition: width 200ms linear;
}
.media-slider-buffer {
width: var(--media-slider-buffer);
background: rgb(255 255 255 / 30%);
}
.media-slider-fill {
width: var(--media-slider-fill);
background: white;
}
.media-time-slider[data-dragging] .media-slider-fill {
width: var(--media-slider-pointer);
transition-duration: 0ms;
}
.media-slider-thumb {
position: absolute;
top: 50%;
left: var(--media-slider-fill);
display: block;
width: 12px;
height: 12px;
background: white;
border-radius: 50%;
translate: -50% -50%;
}
.media-time-slider[data-dragging] .media-slider-thumb {
left: var(--media-slider-pointer);
}
.media-slider-preview {
position: absolute;
bottom: 100%;
left: var(--media-slider-pointer);
display: flex;
gap: 6px;
padding: 3px 7px;
color: white;
white-space: nowrap;
pointer-events: none;
background: rgb(0 0 0 / 80%);
border-radius: 4px;
opacity: 0;
translate: -50% -4px;
}
.media-time-slider[data-pointing] .media-slider-preview,
.media-time-slider[data-interactive]:not([data-pointing]) .media-slider-preview {
opacity: 1;
}
.media-slider-chapter-title:empty {
display: none;
}
import '@videojs/html/video/player';
import '@videojs/html/ui/time-slider';
import '@videojs/html/ui/time-slider-chapters';
API Reference
Rootmedia-time-slider
Props
| Prop | Type | Default | Details |
|---|---|---|---|
changeThrottle | number | 100 | |
| |||
disabled | boolean | — | |
| |||
label | Text | string | Text) | function | '' | |
| |||
largeStep | number | — | |
| |||
max | number | — | |
| |||
min | number | — | |
| |||
orientation | 'horizontal' | 'vertical' | — | |
| |||
pauseOnDrag | boolean | false | |
| |||
step | number | — | |
| |||
thumbAlignment | 'center' | 'edge' | — | |
| |||
value | number | — | |
| |||
State
render, className, and style props.| Property | Type | Details |
|---|---|---|
bufferPercent | number | |
| ||
value | number | |
| ||
fillPercent | number | |
| ||
pointerPercent | number | |
| ||
dragging | boolean | |
| ||
pointing | boolean | |
| ||
interactive | boolean | |
| ||
orientation | 'horizontal' | 'vertical' | |
| ||
disabled | boolean | |
| ||
thumbAlignment | 'center' | 'edge' | |
| ||
currentTime | number | |
| ||
seeking | boolean | |
| ||
duration | number | |
| ||
Data attributes
| Attribute | Type | Details |
|---|---|---|
data-seeking | ||
| ||
CSS custom properties
| Variable | Details |
|---|---|
--media-slider-fill | |
| |
--media-slider-pointer | |
| |
--media-slider-buffer | |
| |
ChapterTitlemedia-time-slider-chapter-title
Displays the chapter title at the current pointer or keyboard position.
Chaptersmedia-time-slider-chapters
Renders normalized chapter ranges as consumer-owned light DOM.
Props
| Prop | Type | Default | Details |
|---|---|---|---|
renderChapter | (props: Omit<HTMLProps<HTMLElement>, "ref">, state: TimeSliderChapterState) => ReactElement<unknown, string | JSXElementConstructor<any>> | — | |
| |||
Buffermedia-slider-buffer
Displays the buffered range on the slider track.
Fillmedia-slider-fill
Displays the filled portion from start to the current value.
Previewmedia-slider-preview
Positioning container for preview content that tracks the pointer along the slider.
Props
| Prop | Type | Default | Details |
|---|---|---|---|
overflow | SliderPreviewOverflow | — | |
| |||
Data attributes
| Attribute | Type | Details |
|---|---|---|
data-dragging | ||
| ||
data-pointing | ||
| ||
data-interactive | ||
| ||
data-orientation | 'horizontal' | 'vertical' | |
| ||
data-disabled | ||
| ||
Thumbmedia-slider-thumb
Draggable handle for setting the slider value. Receives focus and handles keyboard interaction.
Data attributes
| Attribute | Type | Details |
|---|---|---|
data-dragging | ||
| ||
data-pointing | ||
| ||
data-interactive | ||
| ||
data-orientation | 'horizontal' | 'vertical' | |
| ||
data-disabled | ||
| ||
Trackmedia-slider-track
Contains the slider's visual track and interactive hit zone.
Valuemedia-slider-value
Displays a formatted text representation of the slider value. Renders an <output> element.
Props
| Prop | Type | Default | Details |
|---|---|---|---|
format | ((value: number) => string) | — | |
| |||
type | "current" | "pointer" | — | |
| |||
Data attributes
| Attribute | Type | Details |
|---|---|---|
data-dragging | ||
| ||
data-pointing | ||
| ||
data-interactive | ||
| ||
data-orientation | 'horizontal' | 'vertical' | |
| ||
data-disabled | ||
| ||