(() => {
// Video player core. Each [data-vp] wraps one native <video>; its controls are found by data attributes
// (data-play, data-seek, data-skip, data-mute, data-vol, data-cc, data-speed, data-fs, data-pip, data-cur, data-dur),
// so a player only needs the controls it shows. Without JavaScript the video keeps its native controls.
// Shortcuts work while focus is inside the player: Space or K play/pause, Left/Right seek 5 s, M mute,
// F full screen, C captions.
const vpFmt = s => { s = Math.max(0, Math.floor(s || 0)); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
function vpPlayer(root) {
const $ = s => root.querySelector(s);
const video = $('video'), plays = [...root.querySelectorAll('[data-play]')];
const seek = $('[data-seek]'), vol = $('[data-vol]'), mute = $('[data-mute]'), cc = $('[data-cc]'), ccBox = $('[data-cc-box]');
const speed = $('[data-speed]'), fs = $('[data-fs]'), pip = $('[data-pip]'), cur = $('[data-cur]'), dur = $('[data-dur]');
const RATES = [1, 1.25, 1.5, 2, 0.5, 0.75], listeners = [];
let raf = 0, idle = 0, track = null, pointer = 'mouse';
video.controls = false;
root.setAttribute('data-ready', '');
const length = () => (isFinite(video.duration) && video.duration) || (seek ? +seek.max : 0);
const sync = () => {
const t = video.currentTime, d = length();
root.style.setProperty('--p', d ? Math.min(t / d, 1) : 0);
if (cur) cur.textContent = vpFmt(t);
if (seek) {
seek.value = t;
const text = vpFmt(t) + ' of ' + vpFmt(d);
if (seek.getAttribute('aria-valuetext') !== text) seek.setAttribute('aria-valuetext', text);
}
listeners.forEach(fn => fn(t, d));
};
const buffered = () => {
const b = video.buffered, d = length();
let end = 0;
for (let i = 0; i < b.length; i++) if (b.start(i) <= video.currentTime + 0.5) end = Math.max(end, b.end(i));
root.style.setProperty('--b', d ? Math.min(end / d, 1) : 0);
};
const tick = () => { sync(); raf = requestAnimationFrame(tick); };
const meta = () => { if (seek) seek.max = length(); if (dur) dur.textContent = vpFmt(length()); sync(); buffered(); };
const jump = t => { video.currentTime = Math.min(Math.max(t, 0), length()); sync(); wake(); };
const start = () => {
// one player at a time; muted background loops (data-bg) keep running
document.querySelectorAll('video, audio').forEach(m => { if (m !== video && !m.hasAttribute('data-bg')) m.pause(); });
return video.play().catch(() => {});
};
const toggle = () => (video.paused || video.ended ? start() : video.pause());
const name = () => root.dataset.title || 'video';
const state = () => {
const playing = !video.paused;
root.toggleAttribute('data-playing', playing);
plays.forEach(b => b.setAttribute('aria-label', (playing ? 'Pause ' : 'Play ') + name()));
// if the focused play button was just hidden (a big centre button), hand focus to a visible one
const a = document.activeElement;
if (plays.includes(a) && !a.checkVisibility?.()) plays.find(b => b.checkVisibility?.())?.focus();
};
// Captions: the track stays in "hidden" mode and the script prints the active cue into [data-cc-box],
// so the text sits above the control bar. Players without a box fall back to native rendering.
const ccOn = () => !!cc && cc.getAttribute('aria-pressed') === 'true';
const render = () => {
if (!ccBox) return;
const cues = ccOn() && track && track.activeCues ? [...track.activeCues] : [];
const text = cues.map(c => c.text.replace(/<[^>]+>/g, '')).join('\n');
ccBox.textContent = text;
ccBox.hidden = !text;
};
const captions = () => {
track = [...video.textTracks].find(t => t.kind === 'captions' || t.kind === 'subtitles') || null;
if (cc) cc.hidden = !track;
if (!track) return render();
track.mode = ccBox || !ccOn() ? 'hidden' : 'showing';
track.oncuechange = render;
render();
};
// Controls fade out after 2.5 s of playback without pointer movement, unless keyboard focus is inside.
const keyboardFocus = () => { const a = document.activeElement; return !!a && a !== root && root.contains(a) && a.matches(':focus-visible'); };
function wake() {
root.removeAttribute('data-idle');
clearTimeout(idle);
if (!root.hasAttribute('data-autohide') || video.paused) return;
idle = setTimeout(() => { if (!video.paused && !keyboardFocus()) root.setAttribute('data-idle', ''); }, 2500);
}
const fsElement = () => document.fullscreenElement || document.webkitFullscreenElement;
const fullscreen = () => {
if (fsElement() === root) (document.exitFullscreen || document.webkitExitFullscreen).call(document);
else if (root.requestFullscreen) root.requestFullscreen().catch(() => {});
else if (root.webkitRequestFullscreen) root.webkitRequestFullscreen();
else if (video.webkitEnterFullscreen) video.webkitEnterFullscreen(); // iPhone: native player
};
const fsSync = () => {
const on = fsElement() === root;
root.toggleAttribute('data-fullscreen', on);
if (fs) fs.setAttribute('aria-label', on ? 'Exit full screen' : 'Full screen');
};
plays.forEach(b => b.addEventListener('click', toggle));
root.querySelectorAll('[data-skip]').forEach(b => b.addEventListener('click', () => jump(video.currentTime + +b.dataset.skip)));
root.addEventListener('pointerdown', e => { pointer = e.pointerType; }, true);
video.addEventListener('click', () => {
// a tap on hidden controls only reveals them; a click toggles playback and gives the player focus for shortcuts
if (pointer !== 'mouse' && root.hasAttribute('data-idle')) return wake();
toggle();
if (!root.contains(document.activeElement)) root.focus({ preventScroll: true });
});
video.addEventListener('play', () => { state(); tick(); wake(); });
video.addEventListener('pause', () => { state(); cancelAnimationFrame(raf); sync(); wake(); });
video.addEventListener('loadedmetadata', () => { meta(); captions(); });
video.addEventListener('progress', buffered);
video.addEventListener('seeked', () => { sync(); buffered(); });
video.addEventListener('volumechange', () => {
const v = video.muted ? 0 : video.volume;
root.style.setProperty('--v', v);
if (vol) { vol.value = v; vol.setAttribute('aria-valuetext', Math.round(v * 100) + '%'); }
if (mute) mute.setAttribute('aria-pressed', String(v === 0));
});
video.addEventListener('ratechange', () => {
if (speed) (speed.querySelector('[data-speed-val]') || speed).textContent = video.playbackRate + '×';
});
video.textTracks.addEventListener('addtrack', captions);
video.querySelectorAll('track').forEach(t => t.addEventListener('load', captions));
if (seek) seek.addEventListener('input', () => jump(+seek.value));
if (vol) vol.addEventListener('input', () => { video.volume = +vol.value; video.muted = +vol.value === 0; });
if (mute) mute.addEventListener('click', () => {
if (video.muted || video.volume === 0) { video.muted = false; if (video.volume === 0) video.volume = 0.6; }
else video.muted = true;
});
if (cc) cc.addEventListener('click', () => {
cc.setAttribute('aria-pressed', String(!ccOn()));
if (track && !ccBox) track.mode = ccOn() ? 'showing' : 'hidden';
render();
});
if (speed) speed.addEventListener('click', () => {
const rate = RATES[(RATES.indexOf(video.playbackRate) + 1) % RATES.length];
video.playbackRate = video.defaultPlaybackRate = rate;
});
if (fs) {
fs.hidden = !(root.requestFullscreen || root.webkitRequestFullscreen || video.webkitEnterFullscreen);
fs.addEventListener('click', fullscreen);
document.addEventListener('fullscreenchange', fsSync);
document.addEventListener('webkitfullscreenchange', fsSync);
}
if (pip) {
pip.hidden = !(document.pictureInPictureEnabled && !video.disablePictureInPicture);
pip.addEventListener('click', () => (document.pictureInPictureElement === video
? document.exitPictureInPicture() : video.requestPictureInPicture()).catch(() => {}));
video.addEventListener('enterpictureinpicture', () => pip.setAttribute('aria-pressed', 'true'));
video.addEventListener('leavepictureinpicture', () => pip.setAttribute('aria-pressed', 'false'));
}
['pointermove', 'focusin', 'focusout'].forEach(t => root.addEventListener(t, wake));
root.addEventListener('pointerleave', () => {
if (!root.hasAttribute('data-autohide') || video.paused || keyboardFocus()) return;
clearTimeout(idle);
idle = setTimeout(() => root.setAttribute('data-idle', ''), 600);
});
root.addEventListener('keydown', e => {
if (e.altKey || e.ctrlKey || e.metaKey || e.defaultPrevented) return;
const t = e.target, k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
const control = t.matches('button, a[href], select, textarea, input:not([type="range"])');
wake();
if (k === 'k' || (k === ' ' && !control)) toggle();
else if (k === 'm') video.muted = !video.muted;
else if (k === 'f' && fs && !fs.hidden) fullscreen();
else if (k === 'c' && cc && !cc.hidden) cc.click();
else if ((k === 'ArrowLeft' || k === 'ArrowRight') && (t === seek || !t.matches('input[type="range"]')))
jump(video.currentTime + (k === 'ArrowLeft' ? -5 : 5));
else return;
e.preventDefault();
});
// load() swaps the source, poster and captions file (playlists and lightboxes use it)
const load = ({ src, poster, captions: vtt, title, duration }) => {
video.pause();
video.querySelectorAll('track').forEach(t => t.remove());
if (title) root.dataset.title = title;
video.poster = poster || '';
video.src = src;
if (vtt) {
const t = Object.assign(document.createElement('track'), { kind: 'captions', srclang: 'en', label: 'English', src: vtt });
t.addEventListener('load', captions);
video.append(t);
}
if (seek && duration) seek.max = duration;
if (dur && duration) dur.textContent = vpFmt(duration);
video.load();
state(); sync(); captions();
};
if (video.readyState > 0) meta();
captions(); state(); fsSync();
video.dispatchEvent(new Event('volumechange'));
return { root, video, jump, start, toggle, load, onTime: fn => listeners.push(fn) };
}
document.querySelectorAll('[data-vp="cb"]').forEach(vpPlayer);
})();
About this set
Two complete custom control bars for a native video element. The overlay control bar sits on a dark gradient over the picture with a seek slider across the top and a row of controls below it: play and pause, back and forward 10 seconds, mute with a volume slider, elapsed and total time, a playback speed button that steps through 1, 1.25, 1.5, 2, 0.5 and 0.75 times, a captions toggle and full screen. During playback the bar fades out after two and a half seconds without pointer movement and comes back on any movement or keyboard focus. The control panel below the picture puts a round play button, the seek row, mute, speed, captions and full screen on a white strip under the video, which suits product pages and documentation where controls should never cover the image. The video keeps its controls attribute in the markup and the script removes it, so the browser’s own controls remain if JavaScript fails. Captions come from a WebVTT track: the script reads the active cue and prints it above the bar, so the text never sits under the controls. The seek slider shows the buffered range in a lighter shade.
What’s included
Overlay bar that fades out during playback and returns on pointer movement or focus
Control panel variant that keeps every control below the picture
Seek slider with buffered range and aria-valuetext such as 0:05 of 0:16
Playback speed, captions toggle, mute, volume and full screen
Keyboard shortcuts while the player has focus: Space or K, arrows, M, F, C
Native controls stay available when JavaScript does not run
Accessibility
Every control is a button or range input with an accessible name; the play button reads Play Fractal Tide or Pause Fractal Tide, toggles use aria-pressed and the seek slider announces text such as 0:05 of 0:16. Keyboard focus keeps the overlay bar visible and every control shows an outline or a ring on its thumb. Captions are on by default and the fade of the bar is removed under prefers-reduced-motion.
Customise it
Change --vp-accent, --vp-rail, --vp-buf and --vp-ring on .vp in the plain CSS version; the Bootstrap version restyles .btn through --bs-btn-* variables and .form-range through the same custom properties. In Tailwind edit the arbitrary [--vp-accent:...] values on the player and the vp-range utility.