Custom Video Player

Build your own controls with the media API

Background

This example extends what you learned in Tutorial 06: Controlling Media with JavaScript. There, individual demos covered play/pause toggling, seeking with currentTime, and the timeupdate event. Here, all of those pieces are combined into a single coherent player whose control bar is built entirely from standard HTML form elements — no external libraries needed.

Removing the browser's native controls attribute hands complete responsibility for the player interface to your code. That means you also take on accessibility responsibility: every control must be keyboard-operable and carry a descriptive aria-label.

Live demo

The player below has no browser controls — the custom bar is the only UI. Use the keyboard: Tab to move between controls, Space or Enter to activate buttons, and arrow keys to adjust sliders.

0:00 / 0:00

Control-bar HTML

Each control is a standard form element — <button>, <input type="range">, and <output> — so the browser handles keyboard interaction natively. The role="group" on the wrapper and the aria-label on each control give screen-reader users a meaningful description of every element.

<video id="cp-video" poster="../assets/poster.jpg" width="480" height="320"> <source src="../assets/sample-video.mp4" type="video/mp4"> <source src="../assets/sample-video.webm" type="video/webm"> </video> <div class="control-bar" role="group" aria-label="Video controls"> <button id="cp-play" data-playing="false" aria-label="Play">Play</button> <input id="cp-seek" type="range" min="0" max="100" step="0.1" value="0" aria-label="Seek"> <output id="cp-time">0:00 / 0:00</output> <div class="cp-vol-row"> <button id="cp-mute" aria-label="Mute">Mute</button> <input id="cp-vol" type="range" min="0" max="1" step="0.01" value="1" aria-label="Volume"> </div> </div>

Key JavaScript

The script is wrapped in an IIFE so its variables do not pollute the global scope. Four patterns cover the core functionality:

Play/pause toggle — reflecting state through events

Rather than managing a boolean flag yourself, listen to the play and pause events. The button label and data-playing attribute are always driven by what the media element actually reports, not what you think should be happening.

(function () { var video = document.getElementById('cp-video'); var btnPlay = document.getElementById('cp-play'); video.addEventListener('play', function () { btnPlay.textContent = 'Pause'; btnPlay.setAttribute('aria-label', 'Pause'); btnPlay.dataset.playing = 'true'; }); video.addEventListener('pause', function () { btnPlay.textContent = 'Play'; btnPlay.setAttribute('aria-label', 'Play'); btnPlay.dataset.playing = 'false'; }); btnPlay.addEventListener('click', function () { if (video.paused) { video.play().catch(function (err) { console.warn('Playback prevented:', err.message); }); } else { video.pause(); } }); }());

Seek bar — timeupdate and currentTime

duration is NaN until loadedmetadata fires, so the seek bar's max attribute is set only after that event. The timeupdate handler moves the thumb; the input handler on the range writes currentTime back. Both NaN guards prevent errors if the user interacts before metadata loads.

var seek = document.getElementById('cp-seek'); var timeOut = document.getElementById('cp-time'); function fmt(s) { if (isNaN(s) || !isFinite(s)) return '0:00'; var m = Math.floor(s / 60); var sec = Math.floor(s % 60); return m + ':' + (sec < 10 ? '0' : '') + sec; } video.addEventListener('loadedmetadata', function () { seek.max = video.duration; timeOut.textContent = fmt(0) + ' / ' + fmt(video.duration); }); video.addEventListener('timeupdate', function () { if (!isNaN(video.duration) && video.duration > 0) { seek.value = video.currentTime; } timeOut.textContent = fmt(video.currentTime) + ' / ' + fmt(video.duration); }); seek.addEventListener('input', function () { var t = parseFloat(seek.value); if (!isNaN(video.duration) && video.duration > 0) { video.currentTime = Math.max(0, Math.min(t, video.duration)); } });

Mute and volume

muted and volume are independent: setting muted = true silences audio without changing the stored volume level. The volumechange event (fired for both) is the right place to update the mute button label — not the click handler — because the state can change from sources other than your button.

var btnMute = document.getElementById('cp-mute'); var vol = document.getElementById('cp-vol'); video.addEventListener('volumechange', function () { btnMute.textContent = video.muted ? 'Unmute' : 'Mute'; btnMute.setAttribute('aria-label', video.muted ? 'Unmute' : 'Mute'); vol.value = video.muted ? 0 : video.volume; }); btnMute.addEventListener('click', function () { video.muted = !video.muted; }); vol.addEventListener('input', function () { video.volume = parseFloat(vol.value); video.muted = (video.volume === 0); });

Why no native controls? Omitting the attribute forces the custom bar to be the one and only interface — there is no risk of two sets of controls fighting for the same events. If JavaScript fails to load, the user will still see the poster image but cannot play the video; for production you might add a <noscript> fallback that re-adds controls.