/* Modern CSS video player. The control bar answers to the player's own width with container queries, not the
viewport; :has() reacts to control state (muted, picture in picture, keyboard focus); and @starting-style fades
the bar and captions in when they appear. The script sets --p, --b, --v and toggles [data-playing] and [data-idle]. */
.m8{--m8-accent:#a3e635;--m8-ink:#fafafa;--p:0;--b:0;--v:1;
container:m8 / inline-size;position:relative;overflow:hidden;border-radius:22px;background:#18181b;color:var(--m8-ink);isolation:isolate;
box-shadow:0 20px 44px rgba(24,24,27,.22)}
.m8:focus{outline:none}
.m8 [hidden]{display:none !important}
.m8 video{display:block;width:100%;height:auto;aspect-ratio:16/9;object-fit:cover;background:#18181b}
.m8[data-fullscreen]{border-radius:0}
.m8[data-fullscreen] video{height:100%;object-fit:contain}
.m8 button{font:inherit;color:inherit;cursor:pointer}
.m8-btn{position:relative;flex:none;display:grid;place-items:center;width:38px;height:38px;padding:0;border:0;border-radius:12px;background:none}
.m8-btn:hover{background:rgba(255,255,255,.14)}
.m8-btn:focus-visible{outline:2px solid var(--m8-accent);outline-offset:1px}
.m8-btn svg{display:block;width:21px;height:21px}
.m8 .i-pause,.m8[data-playing] .i-play,.m8 .i-shrink,.m8[data-fullscreen] .i-expand,.m8 .i-muted,.m8 [aria-pressed="true"] > .i-vol{display:none}
.m8[data-playing] .i-pause,.m8[data-fullscreen] .i-shrink,.m8 [aria-pressed="true"] > .i-muted{display:block}
.m8 [aria-pressed="true"].m8-btn:not([data-mute]){color:#18181b;background:var(--m8-accent)}
.m8-speed{width:auto;min-width:46px;padding:0 9px;font-size:.82rem;font-weight:700;font-variant-numeric:tabular-nums}
.m8-time{padding:0 6px;font-size:.82rem;font-variant-numeric:tabular-nums;white-space:nowrap}
.m8-spacer{flex:1}
.m8-sr{position:absolute;width:1px;height:1px;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
.m8-title{position:absolute;inset:0 0 auto;z-index:2;display:none;padding:16px 20px 40px;background:linear-gradient(rgba(0,0,0,.6),transparent);
font-size:clamp(.95rem,2.4cqi,1.3rem);font-weight:700;pointer-events:none}
/* the bar fades in through @starting-style when the player is ready; while idle it only fades, so it stays in the tab order */
.m8-bar{position:absolute;inset:auto 0 0;z-index:2;display:block;padding:32px 12px 8px;background:linear-gradient(to top,rgba(9,9,11,.9),rgba(9,9,11,.5) 60%,transparent);
transition:opacity .3s,translate .3s,display .3s allow-discrete}
@starting-style{.m8-bar{opacity:0;translate:0 10px}}
.m8[data-idle] .m8-bar{opacity:0;translate:0 10px;pointer-events:none}
.m8:not([data-ready]) .m8-bar,.m8:not([data-ready]) .m8-title{display:none}
/* :has() keeps the bar on screen while a control inside it has keyboard focus */
.m8:has(.m8-bar :focus-visible) .m8-bar{opacity:1;translate:0 0;pointer-events:auto}
.m8[data-idle]:not(:has(.m8-bar :focus-visible)){cursor:none}
.m8-row{display:flex;align-items:center;gap:2px;margin-top:6px}
/* volume slider grows out of the mute button on hover or focus, and dims while muted */
.m8-volgroup{display:flex;align-items:center}
.m8 .m8-volgroup .m8-vol{width:0;opacity:0;transition:width .25s,opacity .25s}
.m8 .m8-volgroup:has(:hover,:focus-visible) .m8-vol{width:84px;opacity:1}
.m8:has([data-mute][aria-pressed="true"]) .m8-vol{filter:grayscale(1) brightness(.8)}
.m8 input[type="range"]{-webkit-appearance:none;appearance:none;display:block;width:100%;min-width:0;height:16px;margin:0;background:none;cursor:pointer;
--f:var(--p);--fb:var(--b);
--fill:linear-gradient(to right,var(--m8-accent) 0 calc(7px + (100% - 14px) * var(--f)),rgba(255,255,255,.6) 0 calc(7px + (100% - 14px) * var(--fb)),rgba(255,255,255,.35) 0)}
.m8 input[type="range"].m8-vol{--f:var(--v);--fb:0;flex:none}
.m8 input[type="range"]:focus-visible{outline:none}
.m8 input[type="range"]::-webkit-slider-runnable-track{height:4px;border-radius:99px;background:var(--fill)}
.m8 input[type="range"]::-moz-range-track{height:4px;border-radius:99px;background:var(--fill)}
.m8 input[type="range"]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:14px;height:14px;margin-top:-5px;border:0;border-radius:50%;background:#fff}
.m8 input[type="range"]::-moz-range-thumb{width:14px;height:14px;border:0;border-radius:50%;background:#fff}
.m8 input[type="range"]:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px #18181b,0 0 0 5px var(--m8-accent)}
.m8 input[type="range"]:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px #18181b,0 0 0 5px var(--m8-accent)}
.m8-cc{position:absolute;left:50%;bottom:96px;z-index:1;width:max-content;max-width:88%;margin:0;padding:.3em .75em;border-radius:8px;translate:-50% 0;
background:rgba(9,9,11,.85);color:#fff;font-size:clamp(.8rem,2.6cqi,1.1rem);line-height:1.4;text-align:center;white-space:pre-line;pointer-events:none;
transition:opacity .2s,bottom .3s}
@starting-style{.m8-cc{opacity:0}}
.m8[data-idle] .m8-cc{bottom:16px}
/* picture in picture: :has() swaps the empty frame for a note */
.m8:has([data-pip][aria-pressed="true"])::after{content:"Playing in picture in picture";position:absolute;inset:0;z-index:1;display:grid;place-items:center;
background:#18181b;color:#d4d4d8;font-size:.9rem}
/* container queries: the same markup sheds controls as the player gets narrower */
@container m8 (width >= 600px){.m8-title{display:block}.m8-btn{width:42px;height:42px}}
@container m8 (width < 520px){.m8-skip,.m8-volgroup .m8-vol{display:none}.m8-cc{bottom:88px}}
@container m8 (width < 400px){.m8-speed,.m8-dur{display:none}.m8-bar{padding:24px 8px 6px}.m8-btn{width:34px;height:34px}}
@container m8 (width < 300px){[data-pip],.m8-time{display:none}}
@media (prefers-reduced-motion:reduce){.m8-bar,.m8-cc,.m8-volgroup .m8-vol{transition:none}}
JavaScript
(() => {
// 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 stage = $('[data-stage]') || root; // the element that goes full screen
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.
// A player without a captions button shows captions when the root has data-captions.
const ccOn = () => (cc ? cc.getAttribute('aria-pressed') === 'true' : root.hasAttribute('data-captions'));
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() === stage) (document.exitFullscreen || document.webkitExitFullscreen).call(document);
else if (stage.requestFullscreen) stage.requestFullscreen().catch(() => {});
else if (stage.webkitRequestFullscreen) stage.webkitRequestFullscreen();
else if (video.webkitEnterFullscreen) video.webkitEnterFullscreen(); // iPhone: native player
};
const fsSync = () => {
const on = fsElement() === stage;
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 = !(stage.requestFullscreen || stage.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="mo"]').forEach(vpPlayer);
})();
/* Range inputs need vendor pseudo-elements, so they get one small utility. --f is the filled share
(defaults to --p, the played share) and --fb the buffered share; both are set by the script.
Everything else is utilities: @container/m8 with @max-[520px]/m8: variants, has-[...] for :has(),
starting: for @starting-style and transition-discrete for the display transition. */
@utility m8-range {
-webkit-appearance: none; appearance: none; display: block; min-width: 0; height: 16px; margin: 0; background: none; cursor: pointer;
--f: var(--m8-f, var(--p)); --fb: var(--m8-fb, var(--b));
--fill: linear-gradient(to right, #a3e635 0 calc(7px + (100% - 14px) * var(--f)), rgb(255 255 255 / .6) 0 calc(7px + (100% - 14px) * var(--fb)), rgb(255 255 255 / .35) 0);
&:focus-visible { outline: none; }
&::-webkit-slider-runnable-track { height: 4px; border-radius: 99px; background: var(--fill); }
&::-moz-range-track { height: 4px; border-radius: 99px; background: var(--fill); }
&::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 14px; height: 14px; margin-top: -5px; border: 0; border-radius: 50%; background: #fff; }
&::-moz-range-thumb { width: 14px; height: 14px; border: 0; border-radius: 50%; background: #fff; }
&:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 3px #18181b, 0 0 0 5px #a3e635; }
&:focus-visible::-moz-range-thumb { box-shadow: 0 0 0 3px #18181b, 0 0 0 5px #a3e635; }
}
JavaScript
(() => {
// 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 stage = $('[data-stage]') || root; // the element that goes full screen
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.
// A player without a captions button shows captions when the root has data-captions.
const ccOn = () => (cc ? cc.getAttribute('aria-pressed') === 'true' : root.hasAttribute('data-captions'));
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() === stage) (document.exitFullscreen || document.webkitExitFullscreen).call(document);
else if (stage.requestFullscreen) stage.requestFullscreen().catch(() => {});
else if (stage.webkitRequestFullscreen) stage.webkitRequestFullscreen();
else if (video.webkitEnterFullscreen) video.webkitEnterFullscreen(); // iPhone: native player
};
const fsSync = () => {
const on = fsElement() === stage;
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 = !(stage.requestFullscreen || stage.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="mo"]').forEach(vpPlayer);
})();
About this set
One player markup that does in CSS what most player libraries do in JavaScript, shown in a wide container, a 320 pixel column and a resizable box. The player is a size container, so its control bar answers to its own width rather than the viewport: below 520 pixels the skip buttons and volume slider drop out, below 400 pixels the speed button and total time go, and below 300 pixels the picture-in-picture button and clock go too, while above 600 pixels a title overlay appears and buttons grow. The :has() selector reacts to control state without extra classes: the bar stays visible while one of its controls holds keyboard focus, the volume slider grows out of the mute button on hover or focus and dims while muted, and the frame shows a note while the video plays in picture in picture. @starting-style fades the control bar in when the player is ready and fades captions in as each cue appears. A picture-in-picture button is shown only where the browser supports the API. A short script still connects the controls to the video element.
What’s included
Control bar layout driven by container queries on the player itself
:has() keeps controls visible on keyboard focus and reveals volume
@starting-style entry fades for the control bar and captions
Picture-in-picture button shown only where supported
Same markup in a wide, a narrow and a resizable container
Accessibility
Every control is a named button or range input; the captions and picture-in-picture buttons use aria-pressed and the seek slider announces text such as 0:05 of 0:16. At small widths the skip buttons and volume slider drop out, but the arrow keys still seek and M still mutes while the player has focus. Focus rings are lime against the dark bar, and all fades and the volume reveal are removed under prefers-reduced-motion.
Customise it
Change --m8-accent on .m8 for the seek fill, pressed buttons and focus rings, and the breakpoints in the @container m8 rules to decide which controls drop out. Bootstrap restyles .btn and .form-range in the same stylesheet; in Tailwind edit the @max-[520px]/m8: style variants and the m8-range utility.