Open demo

CSS code

HTML
<div><span class="lbl">Overlay control bar</span>
<div class="vp vp-overlay" data-vp="cb" data-title="Fractal Tide" data-autohide role="group" aria-label="Fractal Tide video player" tabindex="-1">
  <video src="../media/clip-1.mp4" poster="../media/clip-1-poster.jpg" preload="metadata" playsinline controls>
    <track kind="captions" src="../media/clip-1-captions.vtt" srclang="en" label="English" default>
  </video>
  <p class="vp-cc-box" data-cc-box hidden></p>
  <div class="vp-controls">
    <input type="range" class="vp-seek" data-seek min="0" max="16" step="0.1" value="0" aria-label="Seek" aria-valuetext="0:00 of 0:16">
    <div class="vp-row">
      <button type="button" class="vp-btn" data-play aria-label="Play Fractal Tide"><svg class="i-play" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="currentColor"><path d="M8 5.5v13a1 1 0 0 0 1.5.86l10.5-6.5a1 1 0 0 0 0-1.72L9.5 4.64A1 1 0 0 0 8 5.5z"/></svg><svg class="i-pause" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="currentColor"><rect x="6.5" y="5" width="4" height="14" rx="1.2"/><rect x="13.5" y="5" width="4" height="14" rx="1.2"/></svg></button>
      <button type="button" class="vp-btn vp-skip" data-skip="-10" aria-label="Back 10 seconds"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 12a8.5 8.5 0 1 0 2.6-6.1"/><path d="M3.5 4v4.5H8"/><text x="12.6" y="15.4" fill="currentColor" stroke="none" font-size="7" font-weight="700" text-anchor="middle" font-family="system-ui,sans-serif">10</text></svg></button>
      <button type="button" class="vp-btn vp-skip" data-skip="10" aria-label="Forward 10 seconds"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.5 12a8.5 8.5 0 1 1-2.6-6.1"/><path d="M20.5 4v4.5H16"/><text x="11.4" y="15.4" fill="currentColor" stroke="none" font-size="7" font-weight="700" text-anchor="middle" font-family="system-ui,sans-serif">10</text></svg></button>
      <button type="button" class="vp-btn vp-mute" data-mute aria-label="Mute" aria-pressed="false"><svg class="i-vol" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H3v6h3l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13"/></svg><svg class="i-muted" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H3v6h3l5 4z"/><path d="m16 9 6 6m0-6-6 6"/></svg></button>
      <input type="range" class="vp-vol" data-vol min="0" max="1" step="0.05" value="1" aria-label="Volume" aria-valuetext="100%">
      <span class="vp-time"><span data-cur>0:00</span> / <span data-dur>0:16</span></span>
      <span class="vp-spacer"></span>
      <button type="button" class="vp-btn vp-speed" data-speed><span class="vp-sr">Playback speed </span><span data-speed-val>1×</span></button>
      <button type="button" class="vp-btn vp-cc" data-cc aria-label="Captions" aria-pressed="true"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="3"/><path d="M10.5 10.2a2.3 2.3 0 1 0 0 3.6M16.5 10.2a2.3 2.3 0 1 0 0 3.6"/></svg></button>
      <button type="button" class="vp-btn vp-fs" data-fs aria-label="Full screen"><svg class="i-expand" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg><svg class="i-shrink" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"/></svg></button>
    </div>
  </div>
</div>
<p class="vp-keys">Keys while the player has focus: <kbd>Space</kbd> or <kbd>K</kbd> play, <kbd>←</kbd> <kbd>→</kbd> 5 seconds, <kbd>M</kbd> mute, <kbd>C</kbd> captions, <kbd>F</kbd> full screen</p>
</div>

<div><span class="lbl">Control panel below the picture</span>
<div class="vp vp-panel" data-vp="cb" data-title="Life in Colour" role="group" aria-label="Life in Colour video player" tabindex="-1">
  <div class="vp-screen">
    <video src="../media/clip-3.mp4" poster="../media/clip-3-poster.jpg" preload="metadata" playsinline controls>
      <track kind="captions" src="../media/clip-3-captions.vtt" srclang="en" label="English" default>
    </video>
    <p class="vp-cc-box" data-cc-box hidden></p>
  </div>
  <div class="vp-controls">
    <button type="button" class="vp-btn vp-play" data-play aria-label="Play Life in Colour"><svg class="i-play" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="currentColor"><path d="M8 5.5v13a1 1 0 0 0 1.5.86l10.5-6.5a1 1 0 0 0 0-1.72L9.5 4.64A1 1 0 0 0 8 5.5z"/></svg><svg class="i-pause" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="currentColor"><rect x="6.5" y="5" width="4" height="14" rx="1.2"/><rect x="13.5" y="5" width="4" height="14" rx="1.2"/></svg></button>
    <span class="vp-time" data-cur>0:00</span>
    <input type="range" class="vp-seek" data-seek min="0" max="14" step="0.1" value="0" aria-label="Seek" aria-valuetext="0:00 of 0:14">
    <span class="vp-time" data-dur>0:14</span>
    <button type="button" class="vp-btn vp-mute" data-mute aria-label="Mute" aria-pressed="false"><svg class="i-vol" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H3v6h3l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13"/></svg><svg class="i-muted" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H3v6h3l5 4z"/><path d="m16 9 6 6m0-6-6 6"/></svg></button>
    <button type="button" class="vp-btn vp-speed" data-speed><span class="vp-sr">Playback speed </span><span data-speed-val>1×</span></button>
    <button type="button" class="vp-btn vp-cc" data-cc aria-label="Captions" aria-pressed="true"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="3"/><path d="M10.5 10.2a2.3 2.3 0 1 0 0 3.6M16.5 10.2a2.3 2.3 0 1 0 0 3.6"/></svg></button>
    <button type="button" class="vp-btn vp-fs" data-fs aria-label="Full screen"><svg class="i-expand" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg><svg class="i-shrink" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"/></svg></button>
  </div>
</div></div>
CSS
/* Custom video control bars over a native <video>. The script sets --p (played share), --b (buffered share)
   and --v (volume) on .vp and toggles [data-playing], [data-idle] and [data-fullscreen]. */
.vp{--vp-accent:#6366f1;--vp-ink:#fff;--vp-rail:rgba(255,255,255,.45);--vp-buf:rgba(255,255,255,.75);--vp-ring:#fff;
  --vp-thumb:14px;--vp-h:4px;--p:0;--b:0;--v:1;
  position:relative;overflow:hidden;border-radius:18px;background:#0b1020;color:var(--vp-ink);isolation:isolate;
  box-shadow:0 18px 40px rgba(15,23,42,.18)}
.vp:focus{outline:none}
.vp [hidden]{display:none !important}
.vp video{display:block;width:100%;height:auto;aspect-ratio:16/9;object-fit:cover;background:#0b1020}
.vp[data-fullscreen]{border-radius:0}
.vp[data-fullscreen] video{height:100%;object-fit:contain}
.vp:not([data-ready]) .vp-controls{display:none}
.vp[data-idle]{cursor:none}
/* buttons */
.vp-btn{position:relative;flex:none;display:grid;place-items:center;width:38px;height:38px;padding:0;border:0;border-radius:10px;
  background:none;color:inherit;font:inherit;cursor:pointer}
.vp-btn:hover{background:rgba(255,255,255,.16)}
.vp-btn:focus-visible{outline:2px solid var(--vp-ring);outline-offset:1px}
.vp-btn svg{display:block;width:22px;height:22px}
.vp .i-pause,.vp[data-playing] .i-play,.vp-mute .i-muted,.vp-mute[aria-pressed="true"] .i-vol,.vp-fs .i-shrink,.vp[data-fullscreen] .vp-fs .i-expand{display:none}
.vp[data-playing] .i-pause,.vp-mute[aria-pressed="true"] .i-muted,.vp[data-fullscreen] .vp-fs .i-shrink{display:block}
.vp-cc[aria-pressed="true"]::after{content:"";position:absolute;left:9px;right:9px;bottom:4px;height:2px;border-radius:2px;background:var(--vp-accent)}
.vp-speed{width:auto;min-width:46px;padding:0 8px;font-size:.82rem;font-weight:700;font-variant-numeric:tabular-nums}
.vp-time{padding:0 8px;font-size:.82rem;font-variant-numeric:tabular-nums;white-space:nowrap}
.vp-spacer{flex:1}
.vp-sr{position:absolute;width:1px;height:1px;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
/* sliders: --f is the filled share, --fb the buffered share */
.vp input[type="range"]{-webkit-appearance:none;appearance:none;display:block;width:100%;min-width:0;height:var(--vp-thumb);margin:0;
  background:none;cursor:pointer;--f:var(--p);--fb:var(--b);
  --fill:linear-gradient(to right,var(--vp-accent) 0 calc(var(--vp-thumb) / 2 + (100% - var(--vp-thumb)) * var(--f)),
    var(--vp-buf) 0 calc(var(--vp-thumb) / 2 + (100% - var(--vp-thumb)) * var(--fb)),var(--vp-rail) 0)}
.vp input[type="range"].vp-vol{--f:var(--v);--fb:0;flex:none;width:76px}
.vp input[type="range"]:focus-visible{outline:none}
.vp input[type="range"]::-webkit-slider-runnable-track{height:var(--vp-h);border-radius:99px;background:var(--fill)}
.vp input[type="range"]::-moz-range-track{height:var(--vp-h);border-radius:99px;background:var(--fill)}
.vp input[type="range"]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:var(--vp-thumb);height:var(--vp-thumb);
  margin-top:calc((var(--vp-h) - var(--vp-thumb)) / 2);border:0;border-radius:50%;background:#fff;box-shadow:0 1px 4px rgba(0,0,0,.4)}
.vp input[type="range"]::-moz-range-thumb{width:var(--vp-thumb);height:var(--vp-thumb);border:0;border-radius:50%;background:#fff;box-shadow:0 1px 4px rgba(0,0,0,.4)}
.vp input[type="range"]:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px var(--vp-accent),0 0 0 5px var(--vp-ring)}
.vp input[type="range"]:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px var(--vp-accent),0 0 0 5px var(--vp-ring)}
/* captions printed by the script, kept above the control bar */
.vp-cc-box{position:absolute;left:50%;bottom:92px;z-index:1;transform:translateX(-50%);width:max-content;max-width:88%;margin:0;
  padding:.3em .75em;border-radius:8px;background:rgba(8,10,24,.84);color:#fff;font-size:clamp(.82rem,2.3vw,1.05rem);
  line-height:1.4;text-align:center;white-space:pre-line;pointer-events:none;transition:bottom .25s}
/* 1. overlay bar: fades out while playing, comes back on pointer movement or keyboard focus */
.vp-overlay .vp-controls{position:absolute;inset:auto 0 0;z-index:2;padding:34px 14px 10px;
  background:linear-gradient(to top,rgba(8,10,24,.9),rgba(8,10,24,.6) 55%,transparent);transition:opacity .25s,transform .25s}
.vp-overlay .vp-row{display:flex;align-items:center;gap:2px;margin-top:6px}
.vp-overlay[data-idle] .vp-controls{opacity:0;transform:translateY(10px);pointer-events:none}
.vp-overlay[data-idle] .vp-cc-box{bottom:18px}
.vp-keys{margin-top:12px;font-size:.8rem;color:#475569}
.vp-keys kbd{display:inline-block;min-width:1.7em;padding:1px 6px;margin:0 1px;border:1px solid #c3ccd8;border-bottom-width:2px;
  border-radius:6px;background:#fff;font:600 .74rem/1.5 ui-monospace,SFMono-Regular,Menlo,monospace;color:#1e293b;text-align:center}
/* 2. controls on a panel below the picture */
.vp-panel{--vp-ink:#1e293b;--vp-rail:#8a97ab;--vp-buf:#c7cfdb;--vp-ring:#4f46e5;--vp-accent:#4f46e5;
  background:#fff;border:1px solid #dfe5ee}
.vp-panel .vp-screen{position:relative}
.vp-panel .vp-cc-box{bottom:16px}
.vp-panel .vp-controls{display:flex;align-items:center;gap:4px;padding:10px 12px}
.vp-panel .vp-btn:hover{background:#eef0fb}
.vp-panel .vp-play{width:44px;height:44px;border-radius:50%;background:var(--vp-accent);color:#fff;box-shadow:0 4px 12px rgba(79,70,229,.35)}
.vp-panel .vp-play:hover{background:#4338ca}
.vp-panel .vp-play:focus-visible{outline-color:var(--vp-accent);outline-offset:2px}
.vp-panel .vp-seek{flex:1}
.vp-panel input[type="range"]::-webkit-slider-thumb{background:var(--vp-accent)}
.vp-panel input[type="range"]::-moz-range-thumb{background:var(--vp-accent)}
.vp-panel input[type="range"]:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px #fff,0 0 0 5px var(--vp-accent)}
.vp-panel input[type="range"]:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px #fff,0 0 0 5px var(--vp-accent)}
.vp-panel .vp-time{padding:0 6px;color:#475569}
.vp-panel[data-fullscreen]{display:flex;flex-direction:column}
.vp-panel[data-fullscreen] .vp-screen{flex:1;min-height:0;background:#0b1020}
@media (max-width:560px){
  .vp-overlay .vp-skip,.vp input[type="range"].vp-vol{display:none}
  .vp-panel .vp-controls{flex-wrap:wrap;gap:0}
  .vp-panel .vp-time{padding:0 4px}
  .vp-panel .vp-seek{order:-1;flex:1 0 100%;margin:2px 4px 6px}
  .vp-panel .vp-mute{margin-left:auto}
  .vp-cc-box{bottom:84px}
}
@media (prefers-reduced-motion:reduce){.vp-overlay .vp-controls,.vp-cc-box{transition:none}.vp-overlay[data-idle] .vp-controls{transform: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 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);
})();

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<div><span class="lbl">Overlay control bar</span>
<div class="vp vp-overlay position-relative overflow-hidden rounded-4 text-white" data-vp="cb" data-title="Fractal Tide" data-autohide role="group" aria-label="Fractal Tide video player" tabindex="-1">
  <div class="ratio ratio-16x9">
    <video src="../media/clip-1.mp4" poster="../media/clip-1-poster.jpg" preload="metadata" playsinline controls>
      <track kind="captions" src="../media/clip-1-captions.vtt" srclang="en" label="English" default>
    </video>
  </div>
  <p class="vp-cc-box m-0 rounded-2 text-center" data-cc-box hidden></p>
  <div class="vp-controls position-absolute bottom-0 start-0 end-0">
    <input type="range" class="form-range d-block" data-seek min="0" max="16" step="0.1" value="0" aria-label="Seek" aria-valuetext="0:00 of 0:16">
    <div class="d-flex align-items-center gap-1 mt-1">
      <button type="button" class="btn" data-play aria-label="Play Fractal Tide"><svg class="i-play" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="currentColor"><path d="M8 5.5v13a1 1 0 0 0 1.5.86l10.5-6.5a1 1 0 0 0 0-1.72L9.5 4.64A1 1 0 0 0 8 5.5z"/></svg><svg class="i-pause" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="currentColor"><rect x="6.5" y="5" width="4" height="14" rx="1.2"/><rect x="13.5" y="5" width="4" height="14" rx="1.2"/></svg></button>
      <button type="button" class="btn vp-skip" data-skip="-10" aria-label="Back 10 seconds"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 12a8.5 8.5 0 1 0 2.6-6.1"/><path d="M3.5 4v4.5H8"/><text x="12.6" y="15.4" fill="currentColor" stroke="none" font-size="7" font-weight="700" text-anchor="middle" font-family="system-ui,sans-serif">10</text></svg></button>
      <button type="button" class="btn vp-skip" data-skip="10" aria-label="Forward 10 seconds"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.5 12a8.5 8.5 0 1 1-2.6-6.1"/><path d="M20.5 4v4.5H16"/><text x="11.4" y="15.4" fill="currentColor" stroke="none" font-size="7" font-weight="700" text-anchor="middle" font-family="system-ui,sans-serif">10</text></svg></button>
      <button type="button" class="btn vp-mute" data-mute aria-label="Mute" aria-pressed="false"><svg class="i-vol" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H3v6h3l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13"/></svg><svg class="i-muted" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H3v6h3l5 4z"/><path d="m16 9 6 6m0-6-6 6"/></svg></button>
      <input type="range" class="form-range vp-vol flex-shrink-0" data-vol min="0" max="1" step="0.05" value="1" aria-label="Volume" aria-valuetext="100%">
      <span class="vp-time px-2 text-nowrap"><span data-cur>0:00</span> / <span data-dur>0:16</span></span>
      <button type="button" class="btn vp-speed ms-auto" data-speed><span class="visually-hidden">Playback speed </span><span data-speed-val>1×</span></button>
      <button type="button" class="btn vp-cc" data-cc aria-label="Captions" aria-pressed="true"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="3"/><path d="M10.5 10.2a2.3 2.3 0 1 0 0 3.6M16.5 10.2a2.3 2.3 0 1 0 0 3.6"/></svg></button>
      <button type="button" class="btn vp-fs" data-fs aria-label="Full screen"><svg class="i-expand" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg><svg class="i-shrink" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"/></svg></button>
    </div>
  </div>
</div>
<p class="vp-keys small text-secondary-emphasis mt-2 mb-0">Keys while the player has focus: <kbd>Space</kbd> or <kbd>K</kbd> play, <kbd>←</kbd> <kbd>→</kbd> 5 seconds, <kbd>M</kbd> mute, <kbd>C</kbd> captions, <kbd>F</kbd> full screen</p>
</div>

<div><span class="lbl">Control panel below the picture</span>
<div class="vp vp-panel card overflow-hidden rounded-4 border" data-vp="cb" data-title="Life in Colour" role="group" aria-label="Life in Colour video player" tabindex="-1">
  <div class="vp-screen position-relative">
    <div class="ratio ratio-16x9 h-100">
      <video src="../media/clip-3.mp4" poster="../media/clip-3-poster.jpg" preload="metadata" playsinline controls>
        <track kind="captions" src="../media/clip-3-captions.vtt" srclang="en" label="English" default>
      </video>
    </div>
    <p class="vp-cc-box m-0 rounded-2 text-center text-white" data-cc-box hidden></p>
  </div>
  <div class="vp-controls d-flex align-items-center gap-1 px-3 py-2 bg-white">
    <button type="button" class="btn vp-play" data-play aria-label="Play Life in Colour"><svg class="i-play" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="currentColor"><path d="M8 5.5v13a1 1 0 0 0 1.5.86l10.5-6.5a1 1 0 0 0 0-1.72L9.5 4.64A1 1 0 0 0 8 5.5z"/></svg><svg class="i-pause" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="currentColor"><rect x="6.5" y="5" width="4" height="14" rx="1.2"/><rect x="13.5" y="5" width="4" height="14" rx="1.2"/></svg></button>
    <span class="vp-time px-1 text-secondary-emphasis" data-cur>0:00</span>
    <input type="range" class="form-range vp-seek flex-grow-1 w-auto" data-seek min="0" max="14" step="0.1" value="0" aria-label="Seek" aria-valuetext="0:00 of 0:14">
    <span class="vp-time px-1 text-secondary-emphasis" data-dur>0:14</span>
    <button type="button" class="btn vp-mute" data-mute aria-label="Mute" aria-pressed="false"><svg class="i-vol" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H3v6h3l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13"/></svg><svg class="i-muted" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H3v6h3l5 4z"/><path d="m16 9 6 6m0-6-6 6"/></svg></button>
    <button type="button" class="btn vp-speed" data-speed><span class="visually-hidden">Playback speed </span><span data-speed-val>1×</span></button>
    <button type="button" class="btn vp-cc" data-cc aria-label="Captions" aria-pressed="true"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="3"/><path d="M10.5 10.2a2.3 2.3 0 1 0 0 3.6M16.5 10.2a2.3 2.3 0 1 0 0 3.6"/></svg></button>
    <button type="button" class="btn vp-fs" data-fs aria-label="Full screen"><svg class="i-expand" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg><svg class="i-shrink" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"/></svg></button>
  </div>
</div></div>
CSS
/* Custom video control bars on Bootstrap 5: .btn for every button, .form-range for the sliders, .ratio for the frame.
   The script sets --p (played), --b (buffered) and --v (volume) on .vp and toggles [data-playing], [data-idle], [data-fullscreen]. */
.vp{--vp-accent:#6366f1;--vp-rail:rgba(255,255,255,.45);--vp-buf:rgba(255,255,255,.75);--vp-ring:#fff;--p:0;--b:0;--v:1;
  background:#0b1020;isolation:isolate;box-shadow:0 18px 40px rgba(15,23,42,.18)}
.vp:focus{outline:none}
.vp video{object-fit:cover;background:#0b1020}
.vp[data-fullscreen]{border-radius:0 !important}
.vp[data-fullscreen] video{object-fit:contain}
.vp[data-fullscreen] .ratio{height:100%}
.vp[data-fullscreen] .ratio::before{display:none}
.vp:not([data-ready]) .vp-controls{display:none !important}
.vp[data-idle]{cursor:none}
.vp .btn{--bs-btn-color:#fff;--bs-btn-hover-color:#fff;--bs-btn-active-color:#fff;--bs-btn-hover-bg:rgba(255,255,255,.16);
  --bs-btn-active-bg:rgba(255,255,255,.22);--bs-btn-border-width:0;--bs-btn-padding-x:0;--bs-btn-padding-y:0;--bs-btn-border-radius:10px;
  --bs-btn-font-size:.82rem;--bs-btn-font-weight:700;--bs-btn-focus-box-shadow:none;
  position:relative;display:grid;place-items:center;flex:none;width:38px;height:38px}
.vp .btn:focus-visible{outline:2px solid var(--vp-ring);outline-offset:1px}
.vp .btn svg{display:block;width:22px;height:22px}
.vp .btn .i-pause,.vp[data-playing] .btn .i-play,.vp .vp-mute .i-muted,.vp .vp-mute[aria-pressed="true"] .i-vol,.vp .vp-fs .i-shrink,.vp[data-fullscreen] .vp-fs .i-expand{display:none}
.vp[data-playing] .btn .i-pause,.vp .vp-mute[aria-pressed="true"] .i-muted,.vp[data-fullscreen] .vp-fs .i-shrink{display:block}
.vp-cc[aria-pressed="true"]::after{content:"";position:absolute;left:9px;right:9px;bottom:4px;height:2px;border-radius:2px;background:var(--vp-accent)}
.vp .vp-speed{width:auto;min-width:46px;--bs-btn-padding-x:8px;font-variant-numeric:tabular-nums}
.vp-time{font-size:.82rem;font-variant-numeric:tabular-nums}
/* .form-range restyled: --f is the filled share, --fb the buffered share */
.vp .form-range{height:14px;--f:var(--p);--fb:var(--b);
  --fill:linear-gradient(to right,var(--vp-accent) 0 calc(7px + (100% - 14px) * var(--f)),var(--vp-buf) 0 calc(7px + (100% - 14px) * var(--fb)),var(--vp-rail) 0)}
.vp .form-range.vp-vol{--f:var(--v);--fb:0;width:76px}
.vp .form-range::-webkit-slider-runnable-track{height:4px;border-radius:99px;background:var(--fill)}
.vp .form-range::-moz-range-track{height:4px;border-radius:99px;background:var(--fill)}
.vp .form-range::-webkit-slider-thumb{width:14px;height:14px;margin-top:-5px;background:#fff;box-shadow:0 1px 4px rgba(0,0,0,.4);transition:none}
.vp .form-range::-moz-range-thumb{width:14px;height:14px;border:0;background:#fff;box-shadow:0 1px 4px rgba(0,0,0,.4);transition:none}
.vp .form-range:focus::-webkit-slider-thumb{box-shadow:0 1px 4px rgba(0,0,0,.4)}
.vp .form-range:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px var(--vp-accent),0 0 0 5px var(--vp-ring)}
.vp .form-range:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px var(--vp-accent),0 0 0 5px var(--vp-ring)}
.vp-cc-box{position:absolute;left:50%;bottom:92px;z-index:1;transform:translateX(-50%);width:max-content;max-width:88%;
  padding:.3em .75em;background:rgba(8,10,24,.84);font-size:clamp(.82rem,2.3vw,1.05rem);line-height:1.4;white-space:pre-line;
  pointer-events:none;transition:bottom .25s}
/* 1. overlay bar */
.vp-overlay .vp-controls{z-index:2;padding:34px 14px 10px;background:linear-gradient(to top,rgba(8,10,24,.9),rgba(8,10,24,.6) 55%,transparent);
  transition:opacity .25s,transform .25s}
.vp-overlay[data-idle] .vp-controls{opacity:0;transform:translateY(10px);pointer-events:none}
.vp-overlay[data-idle] .vp-cc-box{bottom:18px}
.vp-keys kbd{display:inline-block;min-width:1.7em;padding:1px 6px;margin:0 1px;border:1px solid #c3ccd8;border-bottom-width:2px;border-radius:6px;
  background:#fff;color:#1e293b;font-size:.74rem;font-weight:600;text-align:center}
/* 2. control panel below the picture */
.vp-panel{--vp-rail:#8a97ab;--vp-buf:#c7cfdb;--vp-ring:#4f46e5;--vp-accent:#4f46e5}
.vp-panel .vp-cc-box{bottom:16px}
.vp-panel .btn{--bs-btn-color:#1e293b;--bs-btn-hover-color:#1e293b;--bs-btn-active-color:#1e293b;--bs-btn-hover-bg:#eef0fb;--bs-btn-active-bg:#e0e3f8}
.vp-panel .btn.vp-play{--bs-btn-bg:var(--vp-accent);--bs-btn-color:#fff;--bs-btn-hover-color:#fff;--bs-btn-active-color:#fff;--bs-btn-hover-bg:#4338ca;
  --bs-btn-active-bg:#3730a3;--bs-btn-border-radius:50%;width:44px;height:44px;box-shadow:0 4px 12px rgba(79,70,229,.35)}
.vp-panel .btn:focus-visible{outline-color:var(--vp-accent);outline-offset:2px}
.vp-panel .form-range::-webkit-slider-thumb{background:var(--vp-accent)}
.vp-panel .form-range::-moz-range-thumb{background:var(--vp-accent)}
.vp-panel .form-range:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px #fff,0 0 0 5px var(--vp-accent)}
.vp-panel .form-range:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px #fff,0 0 0 5px var(--vp-accent)}
.vp-panel[data-fullscreen]{display:flex;flex-direction:column}
.vp-panel[data-fullscreen] .vp-screen{flex:1;min-height:0}
@media (max-width:560px){
  .vp-overlay .vp-skip,.vp .form-range.vp-vol{display:none}
  .vp-panel .vp-controls{flex-wrap:wrap;gap:0 !important}
  .vp-panel .vp-time{padding:0 4px !important}
  .vp-panel .vp-seek{order:-1;flex:1 0 100% !important;margin:2px 4px 6px}
  .vp-panel .vp-mute{margin-left:auto}
  .vp-cc-box{bottom:84px}
}
@media (prefers-reduced-motion:reduce){.vp-overlay .vp-controls,.vp-cc-box{transition:none}.vp-overlay[data-idle] .vp-controls{transform: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 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);
})();

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div><span class="mb-3.5 block text-[.72rem] tracking-[.12em] text-[#56657a] uppercase">Overlay control bar</span>
<div class="group/vp relative isolate overflow-hidden rounded-[18px] bg-[#0b1020] text-white shadow-[0_18px_40px_rgba(15,23,42,.18)] [--b:0] [--p:0] [--v:1] [--vp-accent:#6366f1] [--vp-buf:rgba(255,255,255,.75)] [--vp-rail:rgba(255,255,255,.45)] [--vp-ring:#fff] focus:outline-none data-fullscreen:rounded-none data-idle:cursor-none" data-vp="cb" data-title="Fractal Tide" data-autohide role="group" aria-label="Fractal Tide video player" tabindex="-1">
  <video class="block aspect-video h-auto w-full bg-[#0b1020] object-cover group-data-fullscreen/vp:h-full group-data-fullscreen/vp:object-contain" src="../media/clip-1.mp4" poster="../media/clip-1-poster.jpg" preload="metadata" playsinline controls>
    <track kind="captions" src="../media/clip-1-captions.vtt" srclang="en" label="English" default>
  </video>
  <p class="pointer-events-none absolute bottom-[92px] left-1/2 z-[1] m-0 w-max max-w-[88%] -translate-x-1/2 rounded-lg bg-[rgba(8,10,24,.84)] px-[.75em] py-[.3em] text-center text-[clamp(.82rem,2.3vw,1.05rem)] leading-[1.4] whitespace-pre-line text-white transition-[bottom] duration-250 group-data-idle/vp:bottom-[18px] motion-reduce:transition-none max-[560px]:bottom-[84px] max-[560px]:group-data-idle/vp:bottom-[18px]" data-cc-box hidden></p>
  <div class="absolute inset-x-0 bottom-0 z-[2] hidden bg-[linear-gradient(to_top,rgba(8,10,24,.9),rgba(8,10,24,.6)_55%,transparent)] px-3.5 pt-[34px] pb-2.5 transition-[opacity,transform] duration-250 group-data-ready/vp:block group-data-idle/vp:pointer-events-none group-data-idle/vp:translate-y-2.5 group-data-idle/vp:opacity-0 motion-reduce:transition-none motion-reduce:group-data-idle/vp:translate-y-0">
    <input type="range" class="vp-range w-full" data-seek min="0" max="16" step="0.1" value="0" aria-label="Seek" aria-valuetext="0:00 of 0:16">
    <div class="mt-1.5 flex items-center gap-0.5">
      <button type="button" class="grid size-[38px] flex-none cursor-pointer place-items-center rounded-[10px] hover:bg-white/16 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white" data-play aria-label="Play Fractal Tide"><svg class="block size-[22px] group-data-playing/vp:hidden" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="currentColor"><path d="M8 5.5v13a1 1 0 0 0 1.5.86l10.5-6.5a1 1 0 0 0 0-1.72L9.5 4.64A1 1 0 0 0 8 5.5z"/></svg><svg class="hidden size-[22px] group-data-playing/vp:block" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="currentColor"><rect x="6.5" y="5" width="4" height="14" rx="1.2"/><rect x="13.5" y="5" width="4" height="14" rx="1.2"/></svg></button>
      <button type="button" class="grid size-[38px] flex-none cursor-pointer place-items-center rounded-[10px] hover:bg-white/16 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white max-[560px]:hidden" data-skip="-10" aria-label="Back 10 seconds"><svg class="block size-[22px]" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 12a8.5 8.5 0 1 0 2.6-6.1"/><path d="M3.5 4v4.5H8"/><text x="12.6" y="15.4" fill="currentColor" stroke="none" font-size="7" font-weight="700" text-anchor="middle" font-family="system-ui,sans-serif">10</text></svg></button>
      <button type="button" class="grid size-[38px] flex-none cursor-pointer place-items-center rounded-[10px] hover:bg-white/16 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white max-[560px]:hidden" data-skip="10" aria-label="Forward 10 seconds"><svg class="block size-[22px]" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.5 12a8.5 8.5 0 1 1-2.6-6.1"/><path d="M20.5 4v4.5H16"/><text x="11.4" y="15.4" fill="currentColor" stroke="none" font-size="7" font-weight="700" text-anchor="middle" font-family="system-ui,sans-serif">10</text></svg></button>
      <button type="button" class="group/m grid size-[38px] flex-none cursor-pointer place-items-center rounded-[10px] hover:bg-white/16 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white" data-mute aria-label="Mute" aria-pressed="false"><svg class="block size-[22px] group-aria-pressed/m:hidden" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H3v6h3l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13"/></svg><svg class="hidden size-[22px] group-aria-pressed/m:block" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H3v6h3l5 4z"/><path d="m16 9 6 6m0-6-6 6"/></svg></button>
      <input type="range" class="vp-range w-[76px] flex-none [--vp-f:var(--v)] [--vp-fb:0] max-[560px]:hidden" data-vol min="0" max="1" step="0.05" value="1" aria-label="Volume" aria-valuetext="100%">
      <span class="px-2 text-[.82rem] whitespace-nowrap tabular-nums"><span data-cur>0:00</span> / <span data-dur>0:16</span></span>
      <button type="button" class="ml-auto grid h-[38px] min-w-[46px] flex-none cursor-pointer place-items-center rounded-[10px] px-2 text-[.82rem] font-bold tabular-nums hover:bg-white/16 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white" data-speed><span class="sr-only">Playback speed </span><span data-speed-val>1×</span></button>
      <button type="button" class="relative grid size-[38px] flex-none cursor-pointer place-items-center rounded-[10px] hover:bg-white/16 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white aria-pressed:after:absolute aria-pressed:after:inset-x-[9px] aria-pressed:after:bottom-1 aria-pressed:after:h-0.5 aria-pressed:after:rounded-sm aria-pressed:after:bg-[#6366f1]" data-cc aria-label="Captions" aria-pressed="true"><svg class="block size-[22px]" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="3"/><path d="M10.5 10.2a2.3 2.3 0 1 0 0 3.6M16.5 10.2a2.3 2.3 0 1 0 0 3.6"/></svg></button>
      <button type="button" class="grid size-[38px] flex-none cursor-pointer place-items-center rounded-[10px] hover:bg-white/16 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white" data-fs aria-label="Full screen"><svg class="block size-[22px] group-data-fullscreen/vp:hidden" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg><svg class="hidden size-[22px] group-data-fullscreen/vp:block" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"/></svg></button>
    </div>
  </div>
</div>
<p class="mt-3 text-[.8rem] text-[#475569] [&_kbd]:mx-px [&_kbd]:inline-block [&_kbd]:min-w-[1.7em] [&_kbd]:rounded-md [&_kbd]:border [&_kbd]:border-b-2 [&_kbd]:border-[#c3ccd8] [&_kbd]:bg-white [&_kbd]:px-1.5 [&_kbd]:py-px [&_kbd]:text-center [&_kbd]:font-mono [&_kbd]:text-[.74rem] [&_kbd]:font-semibold [&_kbd]:leading-[1.5] [&_kbd]:text-[#1e293b]">Keys while the player has focus: <kbd>Space</kbd> or <kbd>K</kbd> play, <kbd>←</kbd> <kbd>→</kbd> 5 seconds, <kbd>M</kbd> mute, <kbd>C</kbd> captions, <kbd>F</kbd> full screen</p>
</div>

<div><span class="mb-3.5 block text-[.72rem] tracking-[.12em] text-[#56657a] uppercase">Control panel below the picture</span>
<div class="group/vp relative isolate overflow-hidden rounded-[18px] border border-[#dfe5ee] bg-white text-[#1e293b] shadow-[0_18px_40px_rgba(15,23,42,.18)] [--b:0] [--p:0] [--v:1] [--vp-accent:#4f46e5] [--vp-buf:#c7cfdb] [--vp-halo:#fff] [--vp-rail:#8a97ab] [--vp-ring:#4f46e5] [--vp-thumb:#4f46e5] focus:outline-none data-fullscreen:flex data-fullscreen:flex-col data-fullscreen:rounded-none" data-vp="cb" data-title="Life in Colour" role="group" aria-label="Life in Colour video player" tabindex="-1">
  <div class="relative bg-[#0b1020] group-data-fullscreen/vp:min-h-0 group-data-fullscreen/vp:flex-1">
    <video class="block aspect-video h-auto w-full object-cover group-data-fullscreen/vp:h-full group-data-fullscreen/vp:object-contain" src="../media/clip-3.mp4" poster="../media/clip-3-poster.jpg" preload="metadata" playsinline controls>
      <track kind="captions" src="../media/clip-3-captions.vtt" srclang="en" label="English" default>
    </video>
    <p class="pointer-events-none absolute bottom-4 left-1/2 z-[1] m-0 w-max max-w-[88%] -translate-x-1/2 rounded-lg bg-[rgba(8,10,24,.84)] px-[.75em] py-[.3em] text-center text-[clamp(.82rem,2.3vw,1.05rem)] leading-[1.4] whitespace-pre-line text-white" data-cc-box hidden></p>
  </div>
  <div class="hidden items-center gap-1 px-3 py-2.5 group-data-ready/vp:flex max-[560px]:flex-wrap max-[560px]:gap-0">
    <button type="button" class="grid size-11 flex-none cursor-pointer place-items-center rounded-full bg-[#4f46e5] text-white shadow-[0_4px_12px_rgba(79,70,229,.35)] hover:bg-[#4338ca] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#4f46e5]" data-play aria-label="Play Life in Colour"><svg class="block size-[22px] group-data-playing/vp:hidden" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="currentColor"><path d="M8 5.5v13a1 1 0 0 0 1.5.86l10.5-6.5a1 1 0 0 0 0-1.72L9.5 4.64A1 1 0 0 0 8 5.5z"/></svg><svg class="hidden size-[22px] group-data-playing/vp:block" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="currentColor"><rect x="6.5" y="5" width="4" height="14" rx="1.2"/><rect x="13.5" y="5" width="4" height="14" rx="1.2"/></svg></button>
    <span class="px-1.5 text-[.82rem] whitespace-nowrap text-[#475569] tabular-nums max-[560px]:px-1" data-cur>0:00</span>
    <input type="range" class="vp-range flex-1 max-[560px]:order-first max-[560px]:mx-1 max-[560px]:mt-0.5 max-[560px]:mb-1.5 max-[560px]:basis-full" data-seek min="0" max="14" step="0.1" value="0" aria-label="Seek" aria-valuetext="0:00 of 0:14">
    <span class="px-1.5 text-[.82rem] whitespace-nowrap text-[#475569] tabular-nums max-[560px]:px-1" data-dur>0:14</span>
    <button type="button" class="group/m grid size-[38px] flex-none cursor-pointer place-items-center rounded-[10px] hover:bg-[#eef0fb] focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[#4f46e5] max-[560px]:ml-auto" data-mute aria-label="Mute" aria-pressed="false"><svg class="block size-[22px] group-aria-pressed/m:hidden" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H3v6h3l5 4z"/><path d="M15.5 8.5a5 5 0 0 1 0 7M18.5 5.5a9 9 0 0 1 0 13"/></svg><svg class="hidden size-[22px] group-aria-pressed/m:block" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H3v6h3l5 4z"/><path d="m16 9 6 6m0-6-6 6"/></svg></button>
    <button type="button" class="grid h-[38px] min-w-[46px] flex-none cursor-pointer place-items-center rounded-[10px] px-2 text-[.82rem] font-bold tabular-nums hover:bg-[#eef0fb] focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[#4f46e5]" data-speed><span class="sr-only">Playback speed </span><span data-speed-val>1×</span></button>
    <button type="button" class="relative grid size-[38px] flex-none cursor-pointer place-items-center rounded-[10px] hover:bg-[#eef0fb] focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[#4f46e5] aria-pressed:after:absolute aria-pressed:after:inset-x-[9px] aria-pressed:after:bottom-1 aria-pressed:after:h-0.5 aria-pressed:after:rounded-sm aria-pressed:after:bg-[#4f46e5]" data-cc aria-label="Captions" aria-pressed="true"><svg class="block size-[22px]" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="3"/><path d="M10.5 10.2a2.3 2.3 0 1 0 0 3.6M16.5 10.2a2.3 2.3 0 1 0 0 3.6"/></svg></button>
    <button type="button" class="grid size-[38px] flex-none cursor-pointer place-items-center rounded-[10px] hover:bg-[#eef0fb] focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-[#4f46e5]" data-fs aria-label="Full screen"><svg class="block size-[22px] group-data-fullscreen/vp:hidden" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg><svg class="hidden size-[22px] group-data-fullscreen/vp:block" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"/></svg></button>
  </div>
</div></div>
CSS
/* Range inputs need vendor pseudo-elements, so they get one small utility. --f is the filled share
   (defaults to --p, the played share set by the script) and --fb the buffered share. */
@utility vp-range {
  -webkit-appearance: none; appearance: none; display: block; min-width: 0; height: 14px; margin: 0; background: none; cursor: pointer;
  --f: var(--vp-f, var(--p)); --fb: var(--vp-fb, var(--b));
  --fill: linear-gradient(to right, var(--vp-accent) 0 calc(7px + (100% - 14px) * var(--f)),
    var(--vp-buf) 0 calc(7px + (100% - 14px) * var(--fb)), var(--vp-rail) 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: var(--vp-thumb, #fff); box-shadow: 0 1px 4px rgb(0 0 0 / .4); }
  &::-moz-range-thumb { width: 14px; height: 14px; border: 0; border-radius: 50%; background: var(--vp-thumb, #fff); box-shadow: 0 1px 4px rgb(0 0 0 / .4); }
  &:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 3px var(--vp-halo, var(--vp-accent)), 0 0 0 5px var(--vp-ring); }
  &:focus-visible::-moz-range-thumb { box-shadow: 0 0 0 3px var(--vp-halo, var(--vp-accent)), 0 0 0 5px var(--vp-ring); }
}
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 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.