Skip to content

Progress Bar Video Player

max is the video length in seconds, so value is the playback position and buffer-value is how far the video has loaded. The buffer stays 30 seconds ahead of the fill. The time label carries data-value-text, so assistive technology hears "1:05 of 3:20" instead of a raw number.

trailer.mp4 0:00 of 3:20

Code

The following example assumes that you have imported the component and set up the theme.

Video Player
<div class="video-player" data-state="paused">
24 collapsed lines
<div class="video-screen" aria-hidden="true">
<svg viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
</div>
<div class="video-controls">
<button type="button" class="play-button" aria-label="Play">
<!-- Play -->
<svg class="play-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="M8 5v14l11-7z" />
</svg>
<!-- Pause -->
<svg class="pause-icon" aria-hidden="true" viewBox="0 0 24 24">
<path d="M6 5h4v14H6zm8 0h4v14h-4z" />
</svg>
</button>
<sv-progress-bar aria-label="Playback position" buffer-value="0" max="200" value="0">
<span>trailer.mp4</span>
<span class="video-time" data-value-text>0:00 of 3:20</span>
</sv-progress-bar>
</div>
</div>
<script type="application/x-typescript" module>
48 collapsed lines
const player = document.querySelector<HTMLElement>(".video-player")!;
const bar = player.querySelector<ProgressBar>("sv-progress-bar")!;
const time = player.querySelector(".video-time")!;
const playButton = player.querySelector(".play-button")!;
let intervalId: ReturnType<typeof setInterval> | undefined;
playButton.addEventListener("click", () => {
if (player.dataset.state === "playing") {
pause();
} else {
play();
}
});
function play() {
if (bar.value === bar.max) {
bar.value = 0;
}
player.dataset.state = "playing";
playButton.ariaLabel = "Pause";
intervalId = setInterval(tick, 250);
}
function pause() {
player.dataset.state = "paused";
playButton.ariaLabel = "Play";
clearInterval(intervalId);
}
function tick() {
const position = (bar.value ?? 0) + 1;
time.textContent = `${formatTime(position)} of ${formatTime(bar.max)}`;
bar.value = position;
bar.bufferValue = position + 30;
if (position === bar.max) {
pause();
}
}
function formatTime(seconds: number) {
const minutes = Math.floor(seconds / 60);
const remainingSeconds = String(Math.floor(seconds % 60)).padStart(2, "0");
return `${minutes}:${remainingSeconds}`;
}
</script>
<style>
83 collapsed lines
.video-player {
display: grid;
max-inline-size: 26rem;
margin: auto;
overflow: hidden;
background-color: var(--sv-gray-7);
border: 1px solid var(--sv-gray-5);
border-radius: var(--sv-bdr-rad-lg);
}
.video-screen {
display: grid;
place-items: center;
aspect-ratio: 16 / 9;
background-color: #000;
svg {
inline-size: 3rem;
opacity: 0.6;
fill: #fff;
transition: opacity 200ms ease;
}
}
.video-controls {
display: grid;
grid-template-columns: auto 1fr;
gap: 0.75rem;
align-items: center;
padding: 0.75rem 1rem;
sv-progress-bar {
--sv-progress-bar-gap: 0.35em;
font-size: 0.8rem;
}
.video-time {
font-family: monospace;
color: var(--sv-gray-3);
}
}
.play-button {
display: grid;
place-items: center;
inline-size: 2.25rem;
block-size: 2.25rem;
padding: 0;
color: var(--sv-accent-content);
cursor: pointer;
background-color: var(--sv-accent);
border: none;
border-radius: 50%;
svg {
inline-size: 1.25rem;
fill: currentcolor;
}
.pause-icon {
display: none;
}
&:focus-visible {
outline: 2px solid var(--sv-accent);
outline-offset: 2px;
}
}
.video-player[data-state="playing"] {
.video-screen svg {
opacity: 0;
}
.play-icon {
display: none;
}
.pause-icon {
display: block;
}
}
</style>