Show timeline thumbnail previews
Preview frames along the timeline while the user scrubs, powered by a storyboard track.
Show preview frames along the timeline while the user scrubs, like YouTube’s seek previews.
Recommended approach
Add a kind="metadata" track labeled thumbnails whose cues reference storyboard images, then put a slider thumbnail inside the TimeSlider. Scrubbing shows the frame under the pointer; the thumbnail reads the pointer time from the slider, so it takes no props.
import { Container, createPlayer, Slider, TimeSlider } from '@videojs/react';
import { Video, videoFeatures } from '@videojs/react/video';
const { Player } = createPlayer({ features: videoFeatures });
export default function BasicUsage() {
return (
<Player>
<Container className="media-container">
<Video src="https://stream.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/highest.mp4" autoPlay muted playsInline loop crossOrigin="anonymous">
<track kind="metadata" label="thumbnails" src="https://image.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/storyboard.vtt" default />
</Video>
<TimeSlider.Root className="media-time-slider">
<TimeSlider.Track className="media-slider-track">
<TimeSlider.Fill className="media-slider-fill" />
</TimeSlider.Track>
<Slider.Thumbnail className="media-slider-thumbnail" />
</TimeSlider.Root>
</Container>
</Player>
);
}
.media-container {
position: relative;
}
.media-container video {
width: 100%;
}
.media-time-slider {
position: absolute;
right: 10px;
bottom: 6px;
left: 10px;
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-fill {
position: absolute;
top: 0;
left: 0;
width: var(--media-slider-fill);
height: 100%;
background: white;
border-radius: 9999px;
}
/* Reveal the preview over the pointer while it is on the slider. */
.media-slider-thumbnail {
position: absolute;
bottom: 100%;
left: var(--media-slider-pointer);
max-width: 160px;
margin-bottom: 8px;
pointer-events: none;
border-radius: 4px;
opacity: 0;
transform: translateX(-50%);
transition: opacity 150ms ease;
}
.media-time-slider[data-pointing] .media-slider-thumbnail {
opacity: 1;
}
.media-slider-thumbnail[data-hidden] {
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="metadata" label="thumbnails" src="https://image.mux.com/BV3YZtogl89mg9VcNBhhnHm02Y34zI1nlMuMQfAbl3dM/storyboard.vtt" default />
</video>
<media-time-slider class="media-time-slider">
<media-slider-track class="media-slider-track">
<media-slider-fill class="media-slider-fill"></media-slider-fill>
</media-slider-track>
<media-slider-thumbnail class="media-slider-thumbnail"></media-slider-thumbnail>
</media-time-slider>
</media-container>
</video-player>
.video-player {
position: relative;
display: block;
}
.video-player video {
width: 100%;
}
.media-time-slider {
position: absolute;
right: 10px;
bottom: 6px;
left: 10px;
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-fill {
position: absolute;
top: 0;
left: 0;
width: var(--media-slider-fill);
height: 100%;
background: white;
border-radius: 9999px;
}
/* Reveal the preview over the pointer while it is on the slider. */
.media-slider-thumbnail {
position: absolute;
bottom: 100%;
left: var(--media-slider-pointer);
max-width: 160px;
margin-bottom: 8px;
pointer-events: none;
border-radius: 4px;
opacity: 0;
transform: translateX(-50%);
transition: opacity 150ms ease;
}
.media-time-slider[data-pointing] .media-slider-thumbnail {
opacity: 1;
}
.media-slider-thumbnail[data-hidden] {
display: none;
}
import '@videojs/html/video/player';
import '@videojs/html/ui/time-slider';
How it works
- Previews come from a storyboard: a
kind="metadata"text track labeledthumbnailswhose WebVTT cues map time ranges to image URLs (optionally with sprite coordinates). The text track feature exposes these asthumbnailCuesandthumbnailTrackSrc. - The slider thumbnail renders the storyboard frame for the slider’s pointer position. While the pointer is over the slider, the slider sets
data-pointingand the--media-slider-pointerCSS variable; the demo’s CSS uses them to reveal the preview and position it over the pointer. - To render a storyboard frame outside the slider — a fixed preview for a chapter list, for example — use the standalone Thumbnail component with an explicit
time.
Each cue’s text is an image URL. An optional #xywh=x,y,w,h fragment crops one frame out of a sprite sheet:
WEBVTT
00:00:00.000 --> 00:00:05.000
storyboard.jpg#xywh=0,0,256,160
00:00:05.000 --> 00:00:10.000
storyboard.jpg#xywh=256,0,256,160Availability and constraints
- Thumbnails require a storyboard asset generated alongside the media; the player doesn’t extract frames from the video itself.
- Cross-origin storyboards need CORS headers, and
crossOriginon the media element for the track to load. - Cues load asynchronously. The track appears before its cues are parsed, so previews fill in when the track finishes loading.
- Storyboard images download on demand; oversized sprite sheets hurt seek-preview responsiveness more than they help quality.
Troubleshooting
Thumbnails don’t appear while scrubbing
Confirm the storyboard track is kind="metadata" with label="thumbnails" and has the default attribute — the browser leaves a non-default track disabled and never loads its cues. Then confirm the VTT loads (network panel) and cross-origin assets have CORS headers with crossOrigin set on the media element.
Thumbnails are blurry or slow
Regenerate the storyboard with a larger frame size, or split giant sprite sheets into smaller images.