Open demo

CSS code

HTML
<div><span class="lbl">Big play button with a slim bar</span>
<div class="vm vm-slim" data-vp="ov" data-title="Slow Gradients" data-autohide role="group" aria-label="Slow Gradients video player" tabindex="-1">
  <video src="../media/clip-4.mp4" poster="../media/clip-4-poster.jpg" preload="metadata" playsinline controls>
    <track kind="captions" src="../media/clip-4-captions.vtt" srclang="en" label="English" default>
  </video>
  <p class="vm-cc-box" data-cc-box hidden></p>
  <button type="button" class="vm-big vm-ui" data-play aria-label="Play Slow Gradients"><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></button>
  <div class="vm-bar vm-ui">
    <button type="button" data-play aria-label="Play Slow Gradients"><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>
    <input type="range" data-seek min="0" max="12" step="0.1" value="0" aria-label="Seek" aria-valuetext="0:00 of 0:12">
    <span class="vm-time"><span data-cur>0:00</span> / <span data-dur>0:12</span></span>
    <button type="button" class="vm-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" 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" 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>

<div><span class="lbl">Corner pills</span>
<div class="vm vm-corner" data-vp="ov" data-title="Rule 30 Loom" data-autohide role="group" aria-label="Rule 30 Loom video player" tabindex="-1">
  <video src="../media/clip-2.mp4" poster="../media/clip-2-poster.jpg" preload="metadata" playsinline controls>
    <track kind="captions" src="../media/clip-2-captions.vtt" srclang="en" label="English" default>
  </video>
  <p class="vm-cc-box" data-cc-box hidden></p>
  <div class="vm-pill start vm-ui">
    <button type="button" class="vm-play" data-play aria-label="Play Rule 30 Loom"><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="vm-time"><span data-cur>0:00</span> / <span data-dur>0:20</span></span>
  </div>
  <div class="vm-pill end vm-ui">
    <button type="button" class="vm-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" 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" 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>
  <input type="range" class="vm-edge vm-ui" data-seek min="0" max="20" step="0.1" value="0" aria-label="Seek" aria-valuetext="0:00 of 0:20">
</div></div>

<div><span class="lbl">Square clip with tap to play</span>
<div class="vm-clip">
  <div class="vm" data-vp="ov" data-title="Life in Colour" data-captions role="group" aria-label="Life in Colour video player" tabindex="-1">
    <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="vm-cc-box" data-cc-box hidden></p>
    <button type="button" class="vm-big vm-ui" 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></button>
    <div class="vm-top vm-ui">
      <input type="range" data-seek min="0" max="14" step="0.1" value="0" aria-label="Seek" aria-valuetext="0:00 of 0:14">
      <div class="vm-row">
        <span class="vm-time"><span data-cur>0:00</span> / <span data-dur>0:14</span></span>
        <button type="button" class="vm-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>
      </div>
    </div>
  </div>
  <div class="vm-meta"><span class="vm-avatar" aria-hidden="true"></span><div><b>Northlight Studio</b><span>Life in Colour · Study 03</span></div></div>
</div></div>
CSS
/* Minimal overlay players: a big centred play button while paused, and a few controls that fade out
   during playback and come back on pointer movement or keyboard focus. The script sets --p and toggles
   [data-playing] and [data-idle] on .vm. */
.vm{--vm-accent:#ea580c;--vm-ring:#fff;--p:0;--v:1;
  position:relative;overflow:hidden;border-radius:22px;background:#1c1917;color:#fff;isolation:isolate;
  box-shadow:0 20px 44px rgba(68,40,20,.2)}
.vm:focus{outline:none}
.vm [hidden]{display:none !important}
.vm video{display:block;width:100%;height:auto;aspect-ratio:16/9;object-fit:cover;background:#1c1917}
.vm[data-fullscreen]{border-radius:0}
.vm[data-fullscreen] video{height:100%;object-fit:contain}
.vm:not([data-ready]) .vm-ui{display:none}
.vm[data-idle]{cursor:none}
.vm button{display:grid;place-items:center;flex:none;padding:0;border:0;background:none;color:inherit;font:inherit;cursor:pointer}
.vm button:focus-visible{outline:2px solid var(--vm-ring);outline-offset:2px}
.vm svg{display:block;width:20px;height:20px}
.vm .i-pause,.vm[data-playing] .i-play,.vm .i-muted,.vm [aria-pressed="true"] .i-vol,.vm .i-shrink,.vm[data-fullscreen] .i-expand{display:none}
.vm[data-playing] .i-pause,.vm [aria-pressed="true"] .i-muted,.vm[data-fullscreen] .i-shrink{display:block}
.vm-ui{transition:opacity .3s}
.vm[data-idle] .vm-ui{opacity:0;pointer-events:none}
/* big centred play button, only while paused */
.vm .vm-big{position:absolute;left:50%;top:50%;z-index:2;width:84px;height:84px;margin:-42px 0 0 -42px;border-radius:50%;
  background:rgba(255,255,255,.22);border:1.5px solid rgba(255,255,255,.7);-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);
  box-shadow:0 10px 30px rgba(0,0,0,.25);transition:transform .2s,background .2s}
.vm .vm-big svg{width:34px;height:34px;margin-left:4px}
.vm .vm-big:hover{transform:scale(1.06);background:rgba(255,255,255,.32)}
.vm[data-playing] .vm-big{display:none}
/* thin seek slider */
.vm input[type="range"]{-webkit-appearance:none;appearance:none;display:block;width:100%;min-width:0;height:14px;margin:0;background:none;cursor:pointer;
  --fill:linear-gradient(to right,var(--vm-accent) 0 calc(6px + (100% - 12px) * var(--p)),rgba(255,255,255,.5) 0)}
.vm input[type="range"]:focus-visible{outline:none}
.vm input[type="range"]::-webkit-slider-runnable-track{height:3px;border-radius:99px;background:var(--fill)}
.vm input[type="range"]::-moz-range-track{height:3px;border-radius:99px;background:var(--fill)}
.vm input[type="range"]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:12px;height:12px;margin-top:-4.5px;border:0;border-radius:50%;background:#fff}
.vm input[type="range"]::-moz-range-thumb{width:12px;height:12px;border:0;border-radius:50%;background:#fff}
.vm input[type="range"]:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px var(--vm-accent),0 0 0 5px #fff}
.vm input[type="range"]:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px var(--vm-accent),0 0 0 5px #fff}
.vm-time{font-size:.8rem;font-variant-numeric:tabular-nums;white-space:nowrap}
.vm-cc-box{position:absolute;left:50%;bottom:64px;z-index:1;transform:translateX(-50%);width:max-content;max-width:86%;margin:0;padding:.25em .7em;
  border-radius:8px;background:rgba(28,25,23,.84);color:#fff;font-size:clamp(.8rem,2.2vw,1rem);line-height:1.4;text-align:center;
  white-space:pre-line;pointer-events:none}
.vm[data-idle] .vm-cc-box{bottom:16px}
/* 1. big play button with a slim bar */
.vm-slim .vm-bar{position:absolute;inset:auto 0 0;z-index:2;display:flex;align-items:center;gap:12px;padding:30px 16px 12px;
  background:linear-gradient(to top,rgba(28,25,23,.85),transparent)}
.vm-slim .vm-bar button{width:34px;height:34px;border-radius:9px}
.vm-slim .vm-bar button:hover{background:rgba(255,255,255,.16)}
.vm-slim .vm-bar input{flex:1}
.vm-slim .vm-cc[aria-pressed="true"]{background:rgba(255,255,255,.2)}
/* 2. corner pills */
.vm-corner{border-radius:28px}
.vm-corner .vm-pill{position:absolute;bottom:16px;z-index:2;display:flex;align-items:center;gap:4px;padding:4px;border-radius:99px;
  background:rgba(28,25,23,.62);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)}
.vm-corner .vm-pill.start{left:16px;padding-right:14px}
.vm-corner .vm-pill.end{right:16px}
.vm-corner .vm-pill button{width:36px;height:36px;border-radius:50%}
.vm-corner .vm-pill button:hover{background:rgba(255,255,255,.18)}
.vm-corner .vm-pill .vm-play{background:#fff;color:#1c1917}
.vm-corner .vm-pill .vm-play:hover{background:#ffedd5}
.vm-corner .vm-edge{position:absolute;inset:auto 22px 2px;z-index:3}
.vm-corner .vm-cc-box{bottom:72px}
.vm-corner[data-idle] .vm-cc-box{bottom:16px}
/* 3. square social clip */
.vm-clip{max-width:320px;margin:0 auto}
.vm-clip .vm{border-radius:24px}
.vm-clip video{aspect-ratio:1;object-position:left center}
.vm-clip .vm-top{position:absolute;inset:0 0 auto;z-index:2;padding:12px 14px 30px;background:linear-gradient(rgba(28,25,23,.7),transparent)}
.vm-clip .vm-top .vm-row{display:flex;align-items:center;justify-content:space-between;margin-top:6px}
.vm-clip .vm-mute{width:36px;height:36px;border-radius:50%;background:rgba(28,25,23,.55)}
.vm-clip .vm-mute:hover{background:rgba(28,25,23,.75)}
.vm-clip .vm-big{width:68px;height:68px;margin:-34px 0 0 -34px}
.vm-clip .vm-big svg{width:28px;height:28px}
.vm-clip .vm-cc-box{bottom:16px}
.vm-meta{display:flex;align-items:center;gap:12px;margin-top:14px}
.vm-avatar{flex:none;width:40px;height:40px;border-radius:50%;background:conic-gradient(from 210deg,#f59e0b,#ea580c,#be185d,#7c3aed,#f59e0b)}
.vm-meta b{display:block;font-size:.95rem}
.vm-meta span{font-size:.82rem;color:#6b5f55}
@media (max-width:560px){.vm-slim .vm-big{width:60px;height:60px;margin:-52px 0 0 -30px}.vm-slim .vm-big svg{width:26px;height:26px;margin-left:3px}.vm-slim .vm-bar{flex-wrap:wrap;gap:4px 10px;padding-top:24px}.vm-slim .vm-bar input{order:-1;flex:1 0 100%}.vm-slim .vm-time{margin-right:auto}.vm .vm-cc-box{bottom:96px}.vm-corner .vm-cc-box{bottom:72px}.vm-clip .vm-cc-box{bottom:16px}}
@media (prefers-reduced-motion:reduce){.vm-ui,.vm .vm-big{transition:none}.vm .vm-big:hover{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.
  // 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() === 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="ov"]').forEach(vpPlayer);
})();

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<div><span class="lbl">Big play button with a slim bar</span>
<div class="vm vm-slim position-relative overflow-hidden text-white" style="border-radius:22px" data-vp="ov" data-title="Slow Gradients" data-autohide role="group" aria-label="Slow Gradients video player" tabindex="-1">
  <div class="ratio ratio-16x9">
    <video src="../media/clip-4.mp4" poster="../media/clip-4-poster.jpg" preload="metadata" playsinline controls>
      <track kind="captions" src="../media/clip-4-captions.vtt" srclang="en" label="English" default>
    </video>
  </div>
  <p class="vm-cc-box m-0 rounded-2 text-center" data-cc-box hidden></p>
  <button type="button" class="btn vm-big vm-ui" data-play aria-label="Play Slow Gradients"><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></button>
  <div class="vm-bar vm-ui position-absolute bottom-0 start-0 end-0 d-flex align-items-center gap-2" style="z-index:2">
    <button type="button" class="btn" data-play aria-label="Play Slow Gradients"><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>
    <input type="range" class="form-range flex-grow-1 w-auto" data-seek min="0" max="12" step="0.1" value="0" aria-label="Seek" aria-valuetext="0:00 of 0:12">
    <span class="vm-time text-nowrap"><span data-cur>0:00</span> / <span data-dur>0:12</span></span>
    <button type="button" class="btn vm-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" 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" 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>

<div><span class="lbl">Corner pills</span>
<div class="vm vm-corner position-relative overflow-hidden text-white" style="border-radius:28px" data-vp="ov" data-title="Rule 30 Loom" data-autohide role="group" aria-label="Rule 30 Loom video player" tabindex="-1">
  <div class="ratio ratio-16x9">
    <video src="../media/clip-2.mp4" poster="../media/clip-2-poster.jpg" preload="metadata" playsinline controls>
      <track kind="captions" src="../media/clip-2-captions.vtt" srclang="en" label="English" default>
    </video>
  </div>
  <p class="vm-cc-box m-0 rounded-2 text-center" data-cc-box hidden></p>
  <div class="vm-pill start vm-ui position-absolute d-flex align-items-center gap-1 rounded-pill">
    <button type="button" class="btn vm-play" data-play aria-label="Play Rule 30 Loom"><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="vm-time text-nowrap"><span data-cur>0:00</span> / <span data-dur>0:20</span></span>
  </div>
  <div class="vm-pill end vm-ui position-absolute d-flex align-items-center gap-1 rounded-pill">
    <button type="button" class="btn vm-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" 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" 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>
  <input type="range" class="form-range vm-edge vm-ui position-absolute" data-seek min="0" max="20" step="0.1" value="0" aria-label="Seek" aria-valuetext="0:00 of 0:20">
</div></div>

<div><span class="lbl">Square clip with tap to play</span>
<div class="vm-clip mx-auto">
  <div class="vm position-relative overflow-hidden text-white" style="border-radius:24px" data-vp="ov" data-title="Life in Colour" data-captions role="group" aria-label="Life in Colour video player" tabindex="-1">
    <div class="ratio ratio-1x1">
      <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="vm-cc-box m-0 rounded-2 text-center" data-cc-box hidden></p>
    <button type="button" class="btn vm-big vm-ui" 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></button>
    <div class="vm-top vm-ui position-absolute top-0 start-0 end-0" style="z-index:2">
      <input type="range" class="form-range d-block" data-seek min="0" max="14" step="0.1" value="0" aria-label="Seek" aria-valuetext="0:00 of 0:14">
      <div class="d-flex align-items-center justify-content-between mt-1">
        <span class="vm-time"><span data-cur>0:00</span> / <span data-dur>0:14</span></span>
        <button type="button" class="btn vm-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>
      </div>
    </div>
  </div>
  <div class="vm-meta d-flex align-items-center gap-3 mt-3"><span class="vm-avatar flex-shrink-0 rounded-circle" aria-hidden="true"></span><div class="lh-sm"><b class="d-block" style="font-size:.95rem">Northlight Studio</b><small>Life in Colour · Study 03</small></div></div>
</div></div>
CSS
/* Minimal overlay players on Bootstrap 5: .btn buttons, .form-range seek sliders, .ratio frames.
   The script sets --p and toggles [data-playing] and [data-idle] on .vm. */
.vm{--vm-accent:#ea580c;--vm-ring:#fff;--p:0;--v:1;background:#1c1917;isolation:isolate;box-shadow:0 20px 44px rgba(68,40,20,.2)}
.vm:focus{outline:none}
.vm video{object-fit:cover;background:#1c1917}
.vm[data-fullscreen]{border-radius:0 !important}
.vm[data-fullscreen] .ratio{height:100%}
.vm[data-fullscreen] .ratio::before{display:none}
.vm[data-fullscreen] video{object-fit:contain}
.vm:not([data-ready]) .vm-ui{display:none !important}
.vm[data-idle]{cursor:none}
.vm .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,.24);
  --bs-btn-border-width:0;--bs-btn-padding-x:0;--bs-btn-padding-y:0;--bs-btn-border-radius:9px;--bs-btn-focus-box-shadow:none;
  display:grid;place-items:center;flex:none;width:34px;height:34px}
.vm .btn:focus-visible{outline:2px solid var(--vm-ring);outline-offset:2px}
.vm .btn svg{display:block;width:20px;height:20px}
.vm .btn .i-pause,.vm[data-playing] .btn .i-play,.vm .btn .i-muted,.vm .btn[aria-pressed="true"] .i-vol,.vm .btn .i-shrink,.vm[data-fullscreen] .btn .i-expand{display:none}
.vm[data-playing] .btn .i-pause,.vm .btn[aria-pressed="true"] .i-muted,.vm[data-fullscreen] .btn .i-shrink{display:block}
.vm-ui{transition:opacity .3s}
.vm[data-idle] .vm-ui{opacity:0;pointer-events:none}
.vm .btn.vm-big{position:absolute;left:50%;top:50%;z-index:2;width:84px;height:84px;margin:-42px 0 0 -42px;--bs-btn-border-radius:50%;
  --bs-btn-bg:rgba(255,255,255,.22);--bs-btn-hover-bg:rgba(255,255,255,.32);--bs-btn-active-bg:rgba(255,255,255,.4);--bs-btn-border-width:1.5px;
  --bs-btn-border-color:rgba(255,255,255,.7);--bs-btn-hover-border-color:#fff;-webkit-backdrop-filter:blur(10px);backdrop-filter:blur(10px);
  box-shadow:0 10px 30px rgba(0,0,0,.25);transition:transform .2s,background-color .2s}
.vm .btn.vm-big svg{width:34px;height:34px;margin-left:4px}
.vm .btn.vm-big:hover{transform:scale(1.06)}
.vm[data-playing] .btn.vm-big{display:none}
.vm .form-range{height:14px;--fill:linear-gradient(to right,var(--vm-accent) 0 calc(6px + (100% - 12px) * var(--p)),rgba(255,255,255,.5) 0)}
.vm .form-range::-webkit-slider-runnable-track{height:3px;border-radius:99px;background:var(--fill)}
.vm .form-range::-moz-range-track{height:3px;border-radius:99px;background:var(--fill)}
.vm .form-range::-webkit-slider-thumb{width:12px;height:12px;margin-top:-4.5px;background:#fff;transition:none}
.vm .form-range::-moz-range-thumb{width:12px;height:12px;border:0;background:#fff;transition:none}
.vm .form-range:focus::-webkit-slider-thumb{box-shadow:none}
.vm .form-range:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px var(--vm-accent),0 0 0 5px #fff}
.vm .form-range:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px var(--vm-accent),0 0 0 5px #fff}
.vm-time{font-size:.8rem;font-variant-numeric:tabular-nums}
.vm-cc-box{position:absolute;left:50%;bottom:64px;z-index:1;transform:translateX(-50%);width:max-content;max-width:86%;padding:.25em .7em;
  background:rgba(28,25,23,.84);font-size:clamp(.8rem,2.2vw,1rem);line-height:1.4;white-space:pre-line;pointer-events:none}
.vm[data-idle] .vm-cc-box{bottom:16px}
/* 1. slim bar */
.vm-slim .vm-bar{padding:30px 16px 12px;background:linear-gradient(to top,rgba(28,25,23,.85),transparent)}
.vm-slim .vm-cc[aria-pressed="true"]{--bs-btn-bg:rgba(255,255,255,.2)}
/* 2. corner pills */
.vm-pill{bottom:16px;z-index:2;padding:4px;background:rgba(28,25,23,.62);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)}
.vm-pill.start{left:16px;padding-right:14px}
.vm-pill.end{right:16px}
.vm-pill .btn{--bs-btn-border-radius:50%;width:36px;height:36px}
.vm-pill .btn.vm-play{--bs-btn-bg:#fff;--bs-btn-color:#1c1917;--bs-btn-hover-bg:#ffedd5;--bs-btn-hover-color:#1c1917;--bs-btn-active-bg:#fed7aa;--bs-btn-active-color:#1c1917}
.vm-edge{left:22px;right:22px;bottom:2px;z-index:3;width:auto !important}
.vm-corner .vm-cc-box{bottom:72px}
.vm-corner[data-idle] .vm-cc-box{bottom:16px}
/* 3. square clip */
.vm-clip{max-width:320px}
.vm-clip video{object-position:left center}
.vm-clip .vm-top{padding:12px 14px 30px;background:linear-gradient(rgba(28,25,23,.7),transparent)}
.vm-clip .vm-mute{--bs-btn-bg:rgba(28,25,23,.55);--bs-btn-hover-bg:rgba(28,25,23,.75);--bs-btn-border-radius:50%;width:36px;height:36px}
.vm-clip .btn.vm-big{width:68px;height:68px;margin:-34px 0 0 -34px}
.vm-clip .btn.vm-big svg{width:28px;height:28px}
.vm-clip .vm-cc-box{bottom:16px}
.vm-avatar{width:40px;height:40px;background:conic-gradient(from 210deg,#f59e0b,#ea580c,#be185d,#7c3aed,#f59e0b)}
.vm-meta small{color:#6b5f55}
@media (max-width:560px){.vm-slim .btn.vm-big{width:60px;height:60px;margin:-52px 0 0 -30px}.vm-slim .btn.vm-big svg{width:26px;height:26px;margin-left:3px}.vm-slim .vm-bar{flex-wrap:wrap;column-gap:10px !important;row-gap:4px !important;padding-top:24px}.vm-slim .vm-bar .form-range{order:-1;flex:1 0 100%}.vm-slim .vm-time{margin-right:auto}.vm .vm-cc-box{bottom:96px}.vm-corner .vm-cc-box{bottom:72px}.vm-clip .vm-cc-box{bottom:16px}}
@media (prefers-reduced-motion:reduce){.vm-ui,.vm .btn.vm-big{transition:none}.vm .btn.vm-big:hover{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.
  // 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() === 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="ov"]').forEach(vpPlayer);
})();

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div><span class="mb-3.5 block text-[.72rem] tracking-[.12em] text-[#6b5f55] uppercase">Big play button with a slim bar</span>
<div class="group/vp relative isolate overflow-hidden rounded-[22px] bg-[#1c1917] text-white shadow-[0_20px_44px_rgba(68,40,20,.2)] [--p:0] focus:outline-none data-fullscreen:rounded-none data-idle:cursor-none" data-vp="ov" data-title="Slow Gradients" data-autohide role="group" aria-label="Slow Gradients video player" tabindex="-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-4.mp4" poster="../media/clip-4-poster.jpg" preload="metadata" playsinline controls>
    <track kind="captions" src="../media/clip-4-captions.vtt" srclang="en" label="English" default>
  </video>
  <p class="pointer-events-none absolute bottom-16 left-1/2 z-[1] m-0 w-max max-w-[86%] -translate-x-1/2 rounded-lg bg-[rgba(28,25,23,.84)] px-[.7em] py-[.25em] text-center text-[clamp(.8rem,2.2vw,1rem)] leading-[1.4] whitespace-pre-line group-data-idle/vp:bottom-4 max-[560px]:bottom-24 max-[560px]:group-data-idle/vp:bottom-4" data-cc-box hidden></p>
  <button type="button" class="absolute top-1/2 left-1/2 z-[2] -mt-[42px] -ml-[42px] hidden size-[84px] cursor-pointer place-items-center rounded-full border-[1.5px] border-white/70 bg-white/22 shadow-[0_10px_30px_rgba(0,0,0,.25)] backdrop-blur-[10px] transition-[transform,background-color,opacity] duration-200 group-data-ready/vp:grid group-data-playing/vp:hidden! group-data-idle/vp:pointer-events-none group-data-idle/vp:opacity-0 hover:scale-106 hover:bg-white/32 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white motion-reduce:transition-none motion-reduce:hover:scale-100 max-[560px]:-mt-[52px] max-[560px]:-ml-[30px] max-[560px]:size-[60px]" data-play aria-label="Play Slow Gradients"><svg class="ml-1 block size-[34px] max-[560px]:ml-[3px] max-[560px]:size-[26px]" 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></button>
  <div class="absolute inset-x-0 bottom-0 z-[2] hidden items-center gap-3 bg-[linear-gradient(to_top,rgba(28,25,23,.85),transparent)] px-4 pt-[30px] pb-3 transition-opacity duration-300 group-data-ready/vp:flex group-data-idle/vp:pointer-events-none group-data-idle/vp:opacity-0 motion-reduce:transition-none max-[560px]:flex-wrap max-[560px]:gap-x-2.5 max-[560px]:gap-y-1 max-[560px]:pt-6">
    <button type="button" class="grid size-[34px] flex-none cursor-pointer place-items-center rounded-[9px] hover:bg-white/16 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white" data-play aria-label="Play Slow Gradients"><svg class="block size-5 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-5 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>
    <input type="range" class="vm-range flex-1 max-[560px]:order-first max-[560px]:basis-full" data-seek min="0" max="12" step="0.1" value="0" aria-label="Seek" aria-valuetext="0:00 of 0:12">
    <span class="text-[.8rem] whitespace-nowrap tabular-nums max-[560px]:mr-auto"><span data-cur>0:00</span> / <span data-dur>0:12</span></span>
    <button type="button" class="grid size-[34px] flex-none cursor-pointer place-items-center rounded-[9px] hover:bg-white/16 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white aria-pressed:bg-white/20" data-cc aria-label="Captions" aria-pressed="true"><svg class="block size-5" 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="group/m grid size-[34px] flex-none cursor-pointer place-items-center rounded-[9px] hover:bg-white/16 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white" data-mute aria-label="Mute" aria-pressed="false"><svg class="block size-5 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-5 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 size-[34px] flex-none cursor-pointer place-items-center rounded-[9px] hover:bg-white/16 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white" data-fs aria-label="Full screen"><svg class="block size-5 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-5 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>

<div><span class="mb-3.5 block text-[.72rem] tracking-[.12em] text-[#6b5f55] uppercase">Corner pills</span>
<div class="group/vp relative isolate overflow-hidden rounded-[28px] bg-[#1c1917] text-white shadow-[0_20px_44px_rgba(68,40,20,.2)] [--p:0] focus:outline-none data-fullscreen:rounded-none data-idle:cursor-none" data-vp="ov" data-title="Rule 30 Loom" data-autohide role="group" aria-label="Rule 30 Loom video player" tabindex="-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-2.mp4" poster="../media/clip-2-poster.jpg" preload="metadata" playsinline controls>
    <track kind="captions" src="../media/clip-2-captions.vtt" srclang="en" label="English" default>
  </video>
  <p class="pointer-events-none absolute bottom-[72px] left-1/2 z-[1] m-0 w-max max-w-[86%] -translate-x-1/2 rounded-lg bg-[rgba(28,25,23,.84)] px-[.7em] py-[.25em] text-center text-[clamp(.8rem,2.2vw,1rem)] leading-[1.4] whitespace-pre-line group-data-idle/vp:bottom-4" data-cc-box hidden></p>
  <div class="absolute bottom-4 left-4 z-[2] hidden items-center gap-1 rounded-full bg-[rgba(28,25,23,.62)] p-1 pr-3.5 backdrop-blur-[8px] transition-opacity duration-300 group-data-ready/vp:flex group-data-idle/vp:pointer-events-none group-data-idle/vp:opacity-0 motion-reduce:transition-none">
    <button type="button" class="grid size-9 flex-none cursor-pointer place-items-center rounded-full bg-white text-[#1c1917] hover:bg-[#ffedd5] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white" data-play aria-label="Play Rule 30 Loom"><svg class="block size-5 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-5 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="text-[.8rem] whitespace-nowrap tabular-nums"><span data-cur>0:00</span> / <span data-dur>0:20</span></span>
  </div>
  <div class="absolute right-4 bottom-4 z-[2] hidden items-center gap-1 rounded-full bg-[rgba(28,25,23,.62)] p-1 backdrop-blur-[8px] transition-opacity duration-300 group-data-ready/vp:flex group-data-idle/vp:pointer-events-none group-data-idle/vp:opacity-0 motion-reduce:transition-none">
    <button type="button" class="grid size-9 flex-none cursor-pointer place-items-center rounded-full hover:bg-white/18 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white" data-cc aria-label="Captions" aria-pressed="true"><svg class="block size-5" 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="group/m grid size-9 flex-none cursor-pointer place-items-center rounded-full hover:bg-white/18 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white" data-mute aria-label="Mute" aria-pressed="false"><svg class="block size-5 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-5 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 size-9 flex-none cursor-pointer place-items-center rounded-full hover:bg-white/18 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white" data-fs aria-label="Full screen"><svg class="block size-5 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-5 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>
  <input type="range" class="vm-range absolute inset-x-[22px] bottom-0.5 z-[3] hidden transition-opacity duration-300 group-data-ready/vp:block group-data-idle/vp:pointer-events-none group-data-idle/vp:opacity-0 motion-reduce:transition-none" data-seek min="0" max="20" step="0.1" value="0" aria-label="Seek" aria-valuetext="0:00 of 0:20">
</div></div>

<div><span class="mb-3.5 block text-[.72rem] tracking-[.12em] text-[#6b5f55] uppercase">Square clip with tap to play</span>
<div class="mx-auto max-w-[320px]">
  <div class="group/vp relative isolate overflow-hidden rounded-3xl bg-[#1c1917] text-white shadow-[0_20px_44px_rgba(68,40,20,.2)] [--p:0] focus:outline-none data-fullscreen:rounded-none" data-vp="ov" data-title="Life in Colour" data-captions role="group" aria-label="Life in Colour video player" tabindex="-1">
    <video class="block aspect-square h-auto w-full object-cover object-left" 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-[86%] -translate-x-1/2 rounded-lg bg-[rgba(28,25,23,.84)] px-[.7em] py-[.25em] text-center text-[clamp(.8rem,2.2vw,1rem)] leading-[1.4] whitespace-pre-line" data-cc-box hidden></p>
    <button type="button" class="absolute top-1/2 left-1/2 z-[2] -mt-[34px] -ml-[34px] hidden size-[68px] cursor-pointer place-items-center rounded-full border-[1.5px] border-white/70 bg-white/22 shadow-[0_10px_30px_rgba(0,0,0,.25)] backdrop-blur-[10px] transition-[transform,background-color] duration-200 group-data-ready/vp:grid group-data-playing/vp:hidden! hover:scale-106 hover:bg-white/32 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white motion-reduce:transition-none motion-reduce:hover:scale-100" data-play aria-label="Play Life in Colour"><svg class="ml-1 block size-7" 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></button>
    <div class="absolute inset-x-0 top-0 z-[2] hidden bg-[linear-gradient(rgba(28,25,23,.7),transparent)] px-3.5 pt-3 pb-[30px] group-data-ready/vp:block">
      <input type="range" class="vm-range w-full" data-seek min="0" max="14" step="0.1" value="0" aria-label="Seek" aria-valuetext="0:00 of 0:14">
      <div class="mt-1.5 flex items-center justify-between">
        <span class="text-[.8rem] whitespace-nowrap tabular-nums"><span data-cur>0:00</span> / <span data-dur>0:14</span></span>
        <button type="button" class="group/m grid size-9 flex-none cursor-pointer place-items-center rounded-full bg-[rgba(28,25,23,.55)] hover:bg-[rgba(28,25,23,.75)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white" data-mute aria-label="Mute" aria-pressed="false"><svg class="block size-5 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-5 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>
      </div>
    </div>
  </div>
  <div class="mt-3.5 flex items-center gap-3"><span class="size-10 flex-none rounded-full bg-[conic-gradient(from_210deg,#f59e0b,#ea580c,#be185d,#7c3aed,#f59e0b)]" aria-hidden="true"></span><div><b class="block text-[.95rem]">Northlight Studio</b><span class="text-[.82rem] text-[#6b5f55]">Life in Colour · Study 03</span></div></div>
</div></div>
CSS
/* The thin seek slider needs vendor pseudo-elements, so it gets one small utility; --p is set by the script. */
@utility vm-range {
  -webkit-appearance: none; appearance: none; display: block; min-width: 0; height: 14px; margin: 0; background: none; cursor: pointer;
  --fill: linear-gradient(to right, #ea580c 0 calc(6px + (100% - 12px) * var(--p)), rgb(255 255 255 / .5) 0);
  &:focus-visible { outline: none; }
  &::-webkit-slider-runnable-track { height: 3px; border-radius: 99px; background: var(--fill); }
  &::-moz-range-track { height: 3px; border-radius: 99px; background: var(--fill); }
  &::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 12px; height: 12px; margin-top: -4.5px; border: 0; border-radius: 50%; background: #fff; }
  &::-moz-range-thumb { width: 12px; height: 12px; border: 0; border-radius: 50%; background: #fff; }
  &:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 3px #ea580c, 0 0 0 5px #fff; }
  &:focus-visible::-moz-range-thumb { box-shadow: 0 0 0 3px #ea580c, 0 0 0 5px #fff; }
}
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.
  // 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() === 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="ov"]').forEach(vpPlayer);
})();

About this set

Three players that keep the picture clean. The big play button player shows a frosted round button in the middle of the poster and a slim bar with play, a thin seek line, time, captions, mute and full screen; once the video plays, the centre button disappears and focus moves to the bar’s play button if it was on the centre one, so keyboard users never lose their place. The corner pills player has a rounded frame with a white play button and time in a pill on the left, captions, mute and full screen in a pill on the right, and a seek line along the bottom edge; all of it fades away during playback. The square clip crops the video to a square card for social feeds, with tap to play, a seek line and mute at the top and captions always shown, plus a creator line under the card. Each player wraps a native video element with preload set to metadata, so nothing but the length and poster loads until someone presses play, and a single script with no dependencies connects the controls. Only one video plays at a time on the page.

What’s included

  • Big centred play button that hands focus to the bar's play button
  • Corner pill controls with a seek line along the bottom edge
  • Square social clip with tap to play and captions always on
  • Controls fade out during playback and return on movement or focus
  • Starting one video pauses any other on the page

Accessibility

Each player is a group named after its video, the play buttons change their label between Play and Pause, mute and captions use aria-pressed, and the seek slider reports the current and total time as text. Controls stay visible while they hold keyboard focus and show a white outline. The button hover scale and the control fades are removed under prefers-reduced-motion.

Customise it

Change --vm-accent on .vm for the seek fill, and the border-radius on .vm, .vm-corner and .vm-clip .vm for the frame shape. Bootstrap restyles .btn through --bs-btn-* variables; in Tailwind change the accent in the vm-range utility and the rounded-* classes.