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.
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.
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.
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.