Open demo

CSS code

HTML
<div><span class="lbl">Video card grid</span>
<div class="vx-cards">
  <article class="vx-card">
    <button type="button" class="vx-thumb" data-open="vx-lightbox" data-src="../media/clip-1.mp4" data-poster="../media/clip-1-poster.jpg" data-captions="../media/clip-1-captions.vtt" data-title="Fractal Tide" data-meta="Northlight Studio · Study 01" data-duration="16" aria-haspopup="dialog" aria-label="Play Fractal Tide, 0:16">
      <img src="../media/clip-1-thumb.jpg" alt="" width="480" height="270" loading="lazy"><span class="vx-play" aria-hidden="true"><svg 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></span><span class="vx-len" aria-hidden="true">0:16</span>
    </button>
    <h3>Fractal Tide</h3><p>A slow dive into the Mandelbrot set</p>
  </article>
  <article class="vx-card">
    <button type="button" class="vx-thumb" data-open="vx-lightbox" data-src="../media/clip-2.mp4" data-poster="../media/clip-2-poster.jpg" data-captions="../media/clip-2-captions.vtt" data-title="Rule 30 Loom" data-meta="Northlight Studio · Study 02" data-duration="20" aria-haspopup="dialog" aria-label="Play Rule 30 Loom, 0:20">
      <img src="../media/clip-2-thumb.jpg" alt="" width="480" height="270" loading="lazy"><span class="vx-play" aria-hidden="true"><svg 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></span><span class="vx-len" aria-hidden="true">0:20</span>
    </button>
    <h3>Rule 30 Loom</h3><p>One row of cells, one simple rule</p>
  </article>
  <article class="vx-card">
    <button type="button" class="vx-thumb" data-open="vx-lightbox" data-src="../media/clip-3.mp4" data-poster="../media/clip-3-poster.jpg" data-captions="../media/clip-3-captions.vtt" data-title="Life in Colour" data-meta="Northlight Studio · Study 03" data-duration="14" aria-haspopup="dialog" aria-label="Play Life in Colour, 0:14">
      <img src="../media/clip-3-thumb.jpg" alt="" width="480" height="270" loading="lazy"><span class="vx-play" aria-hidden="true"><svg 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></span><span class="vx-len" aria-hidden="true">0:14</span>
    </button>
    <h3>Life in Colour</h3><p>Conway's Game of Life, painted</p>
  </article>
</div></div>

<div><span class="lbl">Featured video card</span>
<article class="vx-feature">
  <button type="button" class="vx-thumb" data-open="vx-lightbox" data-src="../media/clip-4.mp4" data-poster="../media/clip-4-poster.jpg" data-captions="../media/clip-4-captions.vtt" data-title="Slow Gradients" data-meta="Northlight Studio · Study 04" data-duration="12" aria-haspopup="dialog" aria-label="Play Slow Gradients, 0:12">
    <img src="../media/clip-4-thumb.jpg" alt="" width="480" height="270" loading="lazy"><span class="vx-play" aria-hidden="true"><svg 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></span>
  </button>
  <div class="vx-body">
    <p class="vx-kicker">New study · 0:12</p>
    <h3>Slow Gradients</h3>
    <p>Four colours drift across the frame and blend where they meet, set to a single warm synth chord. The fourth film in the Northlight series.</p>
    <button type="button" class="vx-watch" data-open="vx-lightbox" data-src="../media/clip-4.mp4" data-poster="../media/clip-4-poster.jpg" data-captions="../media/clip-4-captions.vtt" data-title="Slow Gradients" data-meta="Northlight Studio · Study 04" data-duration="12" aria-haspopup="dialog"><svg 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>Watch the film</button>
  </div>
</article></div>

<dialog class="vx-dialog" id="vx-lightbox" data-lightbox aria-labelledby="vx-lightbox-title">
  <div class="vx-head">
    <div><h2 id="vx-lightbox-title" data-lightbox-title>Video</h2><p data-lightbox-meta></p></div>
    <button type="button" class="vx-close" data-close aria-label="Close video"><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="M6 6l12 12M18 6 6 18"/></svg></button>
  </div>
  <div class="vp" data-vp="lb" data-title="video" data-autohide role="group" aria-label="Video player" tabindex="-1">
    <video preload="none" playsinline controls></video>
    <p class="vp-cc-box" data-cc-box hidden></p>
    <div class="vp-bar">
      <input type="range" data-seek min="0" max="16" step="0.1" value="0" aria-label="Seek" aria-valuetext="0:00 of 0:16">
      <button type="button" class="vp-btn" data-play aria-label="Play video"><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" 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>
      <span class="vp-time"><span data-cur>0:00</span> / <span data-dur>0:16</span></span>
      <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" 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>
</dialog>
CSS
/* Video cards that open a <dialog> player. Each opener is a button with data-open="<dialog id>" and the video's
   data-src, data-poster, data-captions, data-title and data-duration. One dialog serves every card on the page. */
.vx-cards,.vx-feature,.vx-dialog{--vx-accent:#f59e0b;--vx-muted:#526075}
.vx-cards{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:22px}
.vx-card h3{margin-top:12px;font-size:1rem;line-height:1.3}
.vx-card p{margin-top:3px;font-size:.84rem;color:var(--vx-muted)}
.vx-thumb{position:relative;display:block;width:100%;padding:0;border:0;border-radius:16px;overflow:hidden;background:#0f172a;cursor:pointer;
  box-shadow:0 10px 26px rgba(15,23,42,.14)}
.vx-thumb img{display:block;width:100%;height:auto;aspect-ratio:16/9;object-fit:cover;transition:transform .35s}
.vx-thumb::after{content:"";position:absolute;inset:0;background:linear-gradient(to top,rgba(15,23,42,.55),transparent 55%)}
.vx-thumb:hover img{transform:scale(1.05)}
.vx-thumb:focus-visible{outline:3px solid #0f172a;outline-offset:3px}
.vx-play{position:absolute;left:50%;top:50%;z-index:1;display:grid;place-items:center;width:56px;height:56px;margin:-28px 0 0 -28px;border-radius:50%;
  background:var(--vx-accent);color:#0f172a;box-shadow:0 8px 22px rgba(0,0,0,.3);transition:transform .2s}
.vx-play svg{width:24px;height:24px;margin-left:3px}
.vx-thumb:hover .vx-play{transform:scale(1.08)}
.vx-len{position:absolute;right:10px;bottom:10px;z-index:1;padding:2px 8px;border-radius:7px;background:rgba(15,23,42,.85);color:#fff;font-size:.76rem;font-weight:600;font-variant-numeric:tabular-nums}
/* featured card */
.vx-feature{display:grid;grid-template-columns:minmax(0,1.25fr) minmax(0,1fr);overflow:hidden;border-radius:22px;background:#0f172a;color:#fff;box-shadow:0 20px 44px rgba(15,23,42,.2)}
.vx-feature .vx-thumb{border-radius:0;box-shadow:none;height:100%}
.vx-feature .vx-thumb img{height:100%}
.vx-feature .vx-thumb::after{background:linear-gradient(to right,transparent 60%,rgba(15,23,42,.6))}
.vx-feature .vx-thumb:focus-visible{outline:3px solid var(--vx-accent);outline-offset:-3px}
.vx-feature .vx-play{width:72px;height:72px;margin:-36px 0 0 -36px}
.vx-feature .vx-play svg{width:30px;height:30px}
.vx-body{display:flex;flex-direction:column;justify-content:center;gap:10px;padding:30px 30px 32px}
.vx-body .vx-kicker{font-size:.75rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--vx-accent)}
.vx-body h3{font-size:1.6rem;letter-spacing:-.02em;line-height:1.15}
.vx-body p{font-size:.95rem;line-height:1.55;color:#cbd5e1}
.vx-watch{align-self:flex-start;display:inline-flex;align-items:center;gap:8px;margin-top:6px;padding:11px 20px 11px 16px;border:0;border-radius:99px;
  background:var(--vx-accent);color:#0f172a;font:inherit;font-size:.92rem;font-weight:700;cursor:pointer}
.vx-watch svg{width:18px;height:18px}
.vx-watch:hover{background:#fbbf24}
.vx-watch:focus-visible{outline:2px solid #fff;outline-offset:3px}
/* the lightbox dialog and its player */
.vx-dialog{width:min(960px,calc(100% - 32px));max-width:none;max-height:calc(100% - 32px);margin:auto;padding:0;border:0;border-radius:20px;
  background:#0b1020;color:#fff;overflow:hidden;box-shadow:0 30px 80px rgba(0,0,0,.5)}
.vx-dialog::backdrop{background:rgba(2,6,23,.82);-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px)}
.vx-dialog[open]{animation:vx-in .25s ease-out}
@keyframes vx-in{from{opacity:0;transform:translateY(12px) scale(.98)}}
.vx-head{display:flex;align-items:center;gap:12px;padding:12px 12px 12px 20px}
.vx-head div{flex:1;min-width:0}
.vx-head h2{font-size:1.02rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.vx-head p{font-size:.8rem;color:#a5b4c8}
.vx-close{flex:none;display:grid;place-items:center;width:40px;height:40px;border:0;border-radius:50%;background:rgba(255,255,255,.1);color:#fff;cursor:pointer}
.vx-close svg{width:20px;height:20px}
.vx-close:hover{background:rgba(255,255,255,.2)}
.vx-close:focus-visible{outline:2px solid var(--vx-accent);outline-offset:2px}
.vp{--p:0;--b:0;position:relative;background:#000;isolation:isolate}
.vp:focus{outline:none}
.vp [hidden]{display:none !important}
.vp video{display:block;width:100%;height:auto;aspect-ratio:16/9;max-height:calc(100vh - 120px);object-fit:contain;background:#000}
.vp[data-fullscreen] video{height:100%;max-height:none}
.vp:not([data-ready]) .vp-bar{display:none}
.vp[data-idle]{cursor:none}
.vp-bar{position:absolute;inset:auto 0 0;z-index:2;display:flex;flex-wrap:wrap;align-items:center;gap:2px;padding:30px 12px 8px;
  background:linear-gradient(to top,rgba(11,16,32,.92),rgba(11,16,32,.55) 60%,transparent);transition:opacity .25s}
.vp[data-idle] .vp-bar{opacity:0;pointer-events:none}
.vp-bar input{flex:1 0 100%;margin:0 4px 4px}
.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:#fff;font:inherit;cursor:pointer}
.vp-btn:hover{background:rgba(255,255,255,.16)}
.vp-btn:focus-visible{outline:2px solid #fff;outline-offset:1px}
.vp-btn svg{display:block;width:22px;height:22px}
.vp .i-pause,.vp[data-playing] .i-play,.vp .i-shrink,.vp[data-fullscreen] .i-expand,.vp .i-muted,.vp [aria-pressed="true"] > .i-vol{display:none}
.vp[data-playing] .i-pause,.vp[data-fullscreen] .i-shrink,.vp [aria-pressed="true"] > .i-muted{display:block}
.vp-cc[aria-pressed="true"]::after{content:"";position:absolute;left:9px;right:9px;bottom:4px;height:2px;border-radius:2px;background:#f59e0b}
.vp-time{flex:1;padding:0 6px;font-size:.82rem;font-variant-numeric:tabular-nums;white-space:nowrap}
.vp input[type="range"]{-webkit-appearance:none;appearance:none;display:block;min-width:0;height:14px;background:none;cursor:pointer;
  --fill:linear-gradient(to right,#f59e0b 0 calc(7px + (100% - 14px) * var(--p)),rgba(255,255,255,.75) 0 calc(7px + (100% - 14px) * var(--b)),rgba(255,255,255,.45) 0)}
.vp input[type="range"]:focus-visible{outline:none}
.vp input[type="range"]::-webkit-slider-runnable-track{height:4px;border-radius:99px;background:var(--fill)}
.vp input[type="range"]::-moz-range-track{height:4px;border-radius:99px;background:var(--fill)}
.vp input[type="range"]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:14px;height:14px;margin-top:-5px;border:0;border-radius:50%;background:#fff}
.vp input[type="range"]::-moz-range-thumb{width:14px;height:14px;border:0;border-radius:50%;background:#fff}
.vp input[type="range"]:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px #f59e0b,0 0 0 5px #fff}
.vp input[type="range"]:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px #f59e0b,0 0 0 5px #fff}
.vp-cc-box{position:absolute;left:50%;bottom:96px;z-index:1;transform:translateX(-50%);width:max-content;max-width:88%;margin:0;padding:.3em .75em;border-radius:8px;
  background:rgba(11,16,32,.86);color:#fff;font-size:clamp(.82rem,2.3vw,1.05rem);line-height:1.4;text-align:center;white-space:pre-line;pointer-events:none}
.vp[data-idle] .vp-cc-box{bottom:18px}
@media (max-width:760px){.vx-cards{grid-template-columns:1fr 1fr}.vx-feature{grid-template-columns:1fr}.vx-feature .vx-thumb img{aspect-ratio:16/9}.vx-body{padding:22px}}
@media (max-width:520px){.vx-cards{grid-template-columns:1fr}.vx-body h3{font-size:1.3rem}.vx-dialog{width:calc(100% - 16px);border-radius:14px}}
@media (prefers-reduced-motion:reduce){.vx-thumb img,.vx-play,.vp-bar{transition:none}.vx-thumb:hover img,.vx-thumb:hover .vx-play{transform:none}.vx-dialog[open]{animation:none}}
JavaScript
(() => {
// Video player core. Each [data-vp] wraps one native <video>; its controls are found by data attributes
// (data-play, data-seek, data-skip, data-mute, data-vol, data-cc, data-speed, data-fs, data-pip, data-cur, data-dur),
// so a player only needs the controls it shows. Without JavaScript the video keeps its native controls.
// Shortcuts work while focus is inside the player: Space or K play/pause, Left/Right seek 5 s, M mute,
// F full screen, C captions.
const vpFmt = s => { s = Math.max(0, Math.floor(s || 0)); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
function vpPlayer(root) {
  const $ = s => root.querySelector(s);
  const video = $('video'), plays = [...root.querySelectorAll('[data-play]')];
  const seek = $('[data-seek]'), vol = $('[data-vol]'), mute = $('[data-mute]'), cc = $('[data-cc]'), ccBox = $('[data-cc-box]');
  const stage = $('[data-stage]') || root; // the element that goes full screen
  const speed = $('[data-speed]'), fs = $('[data-fs]'), pip = $('[data-pip]'), cur = $('[data-cur]'), dur = $('[data-dur]');
  const RATES = [1, 1.25, 1.5, 2, 0.5, 0.75], listeners = [];
  let raf = 0, idle = 0, track = null, pointer = 'mouse';
  video.controls = false;
  root.setAttribute('data-ready', '');

  const length = () => (isFinite(video.duration) && video.duration) || (seek ? +seek.max : 0);
  const sync = () => {
    const t = video.currentTime, d = length();
    root.style.setProperty('--p', d ? Math.min(t / d, 1) : 0);
    if (cur) cur.textContent = vpFmt(t);
    if (seek) {
      seek.value = t;
      const text = vpFmt(t) + ' of ' + vpFmt(d);
      if (seek.getAttribute('aria-valuetext') !== text) seek.setAttribute('aria-valuetext', text);
    }
    listeners.forEach(fn => fn(t, d));
  };
  const buffered = () => {
    const b = video.buffered, d = length();
    let end = 0;
    for (let i = 0; i < b.length; i++) if (b.start(i) <= video.currentTime + 0.5) end = Math.max(end, b.end(i));
    root.style.setProperty('--b', d ? Math.min(end / d, 1) : 0);
  };
  const tick = () => { sync(); raf = requestAnimationFrame(tick); };
  const meta = () => { if (seek) seek.max = length(); if (dur) dur.textContent = vpFmt(length()); sync(); buffered(); };
  const jump = t => { video.currentTime = Math.min(Math.max(t, 0), length()); sync(); wake(); };
  const start = () => {
    // one player at a time; muted background loops (data-bg) keep running
    document.querySelectorAll('video, audio').forEach(m => { if (m !== video && !m.hasAttribute('data-bg')) m.pause(); });
    return video.play().catch(() => {});
  };
  const toggle = () => (video.paused || video.ended ? start() : video.pause());
  const name = () => root.dataset.title || 'video';
  const state = () => {
    const playing = !video.paused;
    root.toggleAttribute('data-playing', playing);
    plays.forEach(b => b.setAttribute('aria-label', (playing ? 'Pause ' : 'Play ') + name()));
    // if the focused play button was just hidden (a big centre button), hand focus to a visible one
    const a = document.activeElement;
    if (plays.includes(a) && !a.checkVisibility?.()) plays.find(b => b.checkVisibility?.())?.focus();
  };

  // Captions: the track stays in "hidden" mode and the script prints the active cue into [data-cc-box],
  // so the text sits above the control bar. Players without a box fall back to native rendering.
  // A player without a captions button shows captions when the root has data-captions.
  const ccOn = () => (cc ? cc.getAttribute('aria-pressed') === 'true' : root.hasAttribute('data-captions'));
  const render = () => {
    if (!ccBox) return;
    const cues = ccOn() && track && track.activeCues ? [...track.activeCues] : [];
    const text = cues.map(c => c.text.replace(/<[^>]+>/g, '')).join('\n');
    ccBox.textContent = text;
    ccBox.hidden = !text;
  };
  const captions = () => {
    track = [...video.textTracks].find(t => t.kind === 'captions' || t.kind === 'subtitles') || null;
    if (cc) cc.hidden = !track;
    if (!track) return render();
    track.mode = ccBox || !ccOn() ? 'hidden' : 'showing';
    track.oncuechange = render;
    render();
  };

  // Controls fade out after 2.5 s of playback without pointer movement, unless keyboard focus is inside.
  const keyboardFocus = () => { const a = document.activeElement; return !!a && a !== root && root.contains(a) && a.matches(':focus-visible'); };
  function wake() {
    root.removeAttribute('data-idle');
    clearTimeout(idle);
    if (!root.hasAttribute('data-autohide') || video.paused) return;
    idle = setTimeout(() => { if (!video.paused && !keyboardFocus()) root.setAttribute('data-idle', ''); }, 2500);
  }

  const fsElement = () => document.fullscreenElement || document.webkitFullscreenElement;
  const fullscreen = () => {
    if (fsElement() === stage) (document.exitFullscreen || document.webkitExitFullscreen).call(document);
    else if (stage.requestFullscreen) stage.requestFullscreen().catch(() => {});
    else if (stage.webkitRequestFullscreen) stage.webkitRequestFullscreen();
    else if (video.webkitEnterFullscreen) video.webkitEnterFullscreen(); // iPhone: native player
  };
  const fsSync = () => {
    const on = fsElement() === stage;
    root.toggleAttribute('data-fullscreen', on);
    if (fs) fs.setAttribute('aria-label', on ? 'Exit full screen' : 'Full screen');
  };

  plays.forEach(b => b.addEventListener('click', toggle));
  root.querySelectorAll('[data-skip]').forEach(b => b.addEventListener('click', () => jump(video.currentTime + +b.dataset.skip)));
  root.addEventListener('pointerdown', e => { pointer = e.pointerType; }, true);
  video.addEventListener('click', () => {
    // a tap on hidden controls only reveals them; a click toggles playback and gives the player focus for shortcuts
    if (pointer !== 'mouse' && root.hasAttribute('data-idle')) return wake();
    toggle();
    if (!root.contains(document.activeElement)) root.focus({ preventScroll: true });
  });
  video.addEventListener('play', () => { state(); tick(); wake(); });
  video.addEventListener('pause', () => { state(); cancelAnimationFrame(raf); sync(); wake(); });
  video.addEventListener('loadedmetadata', () => { meta(); captions(); });
  video.addEventListener('progress', buffered);
  video.addEventListener('seeked', () => { sync(); buffered(); });
  video.addEventListener('volumechange', () => {
    const v = video.muted ? 0 : video.volume;
    root.style.setProperty('--v', v);
    if (vol) { vol.value = v; vol.setAttribute('aria-valuetext', Math.round(v * 100) + '%'); }
    if (mute) mute.setAttribute('aria-pressed', String(v === 0));
  });
  video.addEventListener('ratechange', () => {
    if (speed) (speed.querySelector('[data-speed-val]') || speed).textContent = video.playbackRate + '×';
  });
  video.textTracks.addEventListener('addtrack', captions);
  video.querySelectorAll('track').forEach(t => t.addEventListener('load', captions));
  if (seek) seek.addEventListener('input', () => jump(+seek.value));
  if (vol) vol.addEventListener('input', () => { video.volume = +vol.value; video.muted = +vol.value === 0; });
  if (mute) mute.addEventListener('click', () => {
    if (video.muted || video.volume === 0) { video.muted = false; if (video.volume === 0) video.volume = 0.6; }
    else video.muted = true;
  });
  if (cc) cc.addEventListener('click', () => {
    cc.setAttribute('aria-pressed', String(!ccOn()));
    if (track && !ccBox) track.mode = ccOn() ? 'showing' : 'hidden';
    render();
  });
  if (speed) speed.addEventListener('click', () => {
    const rate = RATES[(RATES.indexOf(video.playbackRate) + 1) % RATES.length];
    video.playbackRate = video.defaultPlaybackRate = rate;
  });
  if (fs) {
    fs.hidden = !(stage.requestFullscreen || stage.webkitRequestFullscreen || video.webkitEnterFullscreen);
    fs.addEventListener('click', fullscreen);
    document.addEventListener('fullscreenchange', fsSync);
    document.addEventListener('webkitfullscreenchange', fsSync);
  }
  if (pip) {
    pip.hidden = !(document.pictureInPictureEnabled && !video.disablePictureInPicture);
    pip.addEventListener('click', () => (document.pictureInPictureElement === video
      ? document.exitPictureInPicture() : video.requestPictureInPicture()).catch(() => {}));
    video.addEventListener('enterpictureinpicture', () => pip.setAttribute('aria-pressed', 'true'));
    video.addEventListener('leavepictureinpicture', () => pip.setAttribute('aria-pressed', 'false'));
  }
  ['pointermove', 'focusin', 'focusout'].forEach(t => root.addEventListener(t, wake));
  root.addEventListener('pointerleave', () => {
    if (!root.hasAttribute('data-autohide') || video.paused || keyboardFocus()) return;
    clearTimeout(idle);
    idle = setTimeout(() => root.setAttribute('data-idle', ''), 600);
  });
  root.addEventListener('keydown', e => {
    if (e.altKey || e.ctrlKey || e.metaKey || e.defaultPrevented) return;
    const t = e.target, k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
    const control = t.matches('button, a[href], select, textarea, input:not([type="range"])');
    wake();
    if (k === 'k' || (k === ' ' && !control)) toggle();
    else if (k === 'm') video.muted = !video.muted;
    else if (k === 'f' && fs && !fs.hidden) fullscreen();
    else if (k === 'c' && cc && !cc.hidden) cc.click();
    else if ((k === 'ArrowLeft' || k === 'ArrowRight') && (t === seek || !t.matches('input[type="range"]')))
      jump(video.currentTime + (k === 'ArrowLeft' ? -5 : 5));
    else return;
    e.preventDefault();
  });

  // load() swaps the source, poster and captions file (playlists and lightboxes use it)
  const load = ({ src, poster, captions: vtt, title, duration }) => {
    video.pause();
    video.querySelectorAll('track').forEach(t => t.remove());
    if (title) root.dataset.title = title;
    video.poster = poster || '';
    video.src = src;
    if (vtt) {
      const t = Object.assign(document.createElement('track'), { kind: 'captions', srclang: 'en', label: 'English', src: vtt });
      t.addEventListener('load', captions);
      video.append(t);
    }
    if (seek && duration) seek.max = duration;
    if (dur && duration) dur.textContent = vpFmt(duration);
    video.load();
    state(); sync(); captions();
  };

  if (video.readyState > 0) meta();
  captions(); state(); fsSync();
  video.dispatchEvent(new Event('volumechange'));
  return { root, video, jump, start, toggle, load, onTime: fn => listeners.push(fn) };
}

// Lightbox. Buttons with data-open="<dialog id>" carry the video's src, poster, captions, title and duration.
// Opening loads that video into the dialog's player, opens the <dialog> as a modal, moves focus to the play
// button and starts playback; Esc, the close button or a click on the backdrop closes it, pauses the video
// and returns focus to the card that opened it.
document.querySelectorAll('dialog[data-lightbox]').forEach(dialog => {
  const root = dialog.querySelector('[data-vp="lb"]'), player = vpPlayer(root);
  const title = dialog.querySelector('[data-lightbox-title]'), meta = dialog.querySelector('[data-lightbox-meta]');
  let opener = null;
  document.querySelectorAll('[data-open="' + dialog.id + '"]').forEach(btn => btn.addEventListener('click', () => {
    const d = btn.dataset;
    opener = btn;
    if (title) title.textContent = d.title;
    if (meta) meta.textContent = d.meta || '';
    player.load({ src: d.src, poster: d.poster, captions: d.captions, title: d.title, duration: +d.duration });
    dialog.showModal();
    root.querySelector('[data-play]').focus();
    player.start(); // the visitor asked for this video, so it starts with sound
  }));
  dialog.querySelector('[data-close]').addEventListener('click', () => dialog.close());
  dialog.addEventListener('click', e => { if (e.target === dialog) dialog.close(); });
  dialog.addEventListener('close', () => {
    player.video.pause();
    if (opener) opener.focus();
  });
});
})();

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<div><span class="lbl">Video card grid</span>
<div class="vx-cards row row-cols-1 row-cols-sm-2 row-cols-md-3 g-4">
  <div class="col"><article class="vx-card">
    <button type="button" class="vx-thumb btn position-relative d-block w-100 overflow-hidden" data-open="vx-lightbox" data-src="../media/clip-1.mp4" data-poster="../media/clip-1-poster.jpg" data-captions="../media/clip-1-captions.vtt" data-title="Fractal Tide" data-meta="Northlight Studio · Study 01" data-duration="16" aria-haspopup="dialog" aria-label="Play Fractal Tide, 0:16">
      <img class="d-block w-100 h-auto" src="../media/clip-1-thumb.jpg" alt="" width="480" height="270" loading="lazy"><span class="vx-play position-absolute d-grid rounded-circle" aria-hidden="true"><svg 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></span><span class="vx-len position-absolute rounded-2 text-white fw-semibold" aria-hidden="true">0:16</span>
    </button>
    <h3 class="fw-bold mt-3 mb-1 lh-sm">Fractal Tide</h3><p class="mb-0">A slow dive into the Mandelbrot set</p>
  </article></div>
  <div class="col"><article class="vx-card">
    <button type="button" class="vx-thumb btn position-relative d-block w-100 overflow-hidden" data-open="vx-lightbox" data-src="../media/clip-2.mp4" data-poster="../media/clip-2-poster.jpg" data-captions="../media/clip-2-captions.vtt" data-title="Rule 30 Loom" data-meta="Northlight Studio · Study 02" data-duration="20" aria-haspopup="dialog" aria-label="Play Rule 30 Loom, 0:20">
      <img class="d-block w-100 h-auto" src="../media/clip-2-thumb.jpg" alt="" width="480" height="270" loading="lazy"><span class="vx-play position-absolute d-grid rounded-circle" aria-hidden="true"><svg 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></span><span class="vx-len position-absolute rounded-2 text-white fw-semibold" aria-hidden="true">0:20</span>
    </button>
    <h3 class="fw-bold mt-3 mb-1 lh-sm">Rule 30 Loom</h3><p class="mb-0">One row of cells, one simple rule</p>
  </article></div>
  <div class="col"><article class="vx-card">
    <button type="button" class="vx-thumb btn position-relative d-block w-100 overflow-hidden" data-open="vx-lightbox" data-src="../media/clip-3.mp4" data-poster="../media/clip-3-poster.jpg" data-captions="../media/clip-3-captions.vtt" data-title="Life in Colour" data-meta="Northlight Studio · Study 03" data-duration="14" aria-haspopup="dialog" aria-label="Play Life in Colour, 0:14">
      <img class="d-block w-100 h-auto" src="../media/clip-3-thumb.jpg" alt="" width="480" height="270" loading="lazy"><span class="vx-play position-absolute d-grid rounded-circle" aria-hidden="true"><svg 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></span><span class="vx-len position-absolute rounded-2 text-white fw-semibold" aria-hidden="true">0:14</span>
    </button>
    <h3 class="fw-bold mt-3 mb-1 lh-sm">Life in Colour</h3><p class="mb-0">Conway's Game of Life, painted</p>
  </article></div>
</div></div>

<div><span class="lbl">Featured video card</span>
<article class="vx-feature card flex-md-row overflow-hidden">
  <button type="button" class="vx-thumb btn position-relative d-block overflow-hidden col-md-7 flex-shrink-0" data-open="vx-lightbox" data-src="../media/clip-4.mp4" data-poster="../media/clip-4-poster.jpg" data-captions="../media/clip-4-captions.vtt" data-title="Slow Gradients" data-meta="Northlight Studio · Study 04" data-duration="12" aria-haspopup="dialog" aria-label="Play Slow Gradients, 0:12">
    <img class="d-block w-100" src="../media/clip-4-thumb.jpg" alt="" width="480" height="270" loading="lazy"><span class="vx-play position-absolute d-grid rounded-circle" aria-hidden="true"><svg 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></span>
  </button>
  <div class="card-body d-flex flex-column justify-content-center gap-2 p-4">
    <p class="vx-kicker fw-bold text-uppercase mb-0">New study · 0:12</p>
    <h3 class="fw-bold mb-0">Slow Gradients</h3>
    <p class="vx-text mb-0">Four colours drift across the frame and blend where they meet, set to a single warm synth chord. The fourth film in the Northlight series.</p>
    <button type="button" class="vx-watch btn rounded-pill align-self-start d-inline-flex align-items-center gap-2 mt-1" data-open="vx-lightbox" data-src="../media/clip-4.mp4" data-poster="../media/clip-4-poster.jpg" data-captions="../media/clip-4-captions.vtt" data-title="Slow Gradients" data-meta="Northlight Studio · Study 04" data-duration="12" aria-haspopup="dialog"><svg 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>Watch the film</button>
  </div>
</article></div>

<dialog class="vx-dialog" id="vx-lightbox" data-lightbox aria-labelledby="vx-lightbox-title">
  <div class="d-flex align-items-center gap-3 py-2 pe-2 ps-4" style="min-height:64px">
    <div class="flex-grow-1 min-w-0"><h2 class="fw-bold text-truncate mb-0" id="vx-lightbox-title" data-lightbox-title>Video</h2><p class="vx-meta mb-0" data-lightbox-meta></p></div>
    <button type="button" class="vx-close btn rounded-circle d-grid flex-shrink-0" data-close aria-label="Close video"><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="M6 6l12 12M18 6 6 18"/></svg></button>
  </div>
  <div class="vp position-relative" data-vp="lb" data-title="video" data-autohide role="group" aria-label="Video player" tabindex="-1">
    <video preload="none" playsinline controls></video>
    <p class="vp-cc-box m-0 rounded-2 text-center" data-cc-box hidden></p>
    <div class="vp-bar position-absolute bottom-0 start-0 end-0 d-flex flex-wrap align-items-center gap-1">
      <input type="range" class="form-range d-block w-100 mx-1 mb-1" data-seek min="0" max="16" step="0.1" value="0" aria-label="Seek" aria-valuetext="0:00 of 0:16">
      <button type="button" class="btn" data-play aria-label="Play video"><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" 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>
      <span class="vp-time flex-grow-1 px-2 text-nowrap"><span data-cur>0:00</span> / <span data-dur>0:16</span></span>
      <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" 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>
</dialog>
CSS
/* Video cards that open a native <dialog> player, on Bootstrap 5 grid, card and button classes. The dialog stays a
   native element (no Bootstrap JS), so focus handling and Esc come from the browser. */
.vx-cards,.vx-feature,.vx-dialog{--vx-accent:#f59e0b;--vx-muted:#526075}
.vx-card h3{font-size:1rem}
.vx-card p{font-size:.84rem;color:var(--vx-muted)}
.vx-thumb{--bs-btn-padding-x:0;--bs-btn-padding-y:0;--bs-btn-border-width:0;--bs-btn-border-radius:16px;--bs-btn-focus-box-shadow:none;
  background:#0f172a;box-shadow:0 10px 26px rgba(15,23,42,.14)}
.vx-thumb img{aspect-ratio:16/9;object-fit:cover;transition:transform .35s}
.vx-thumb::after{content:"";position:absolute;inset:0;background:linear-gradient(to top,rgba(15,23,42,.55),transparent 55%)}
.vx-thumb:hover img{transform:scale(1.05)}
.vx-thumb:focus-visible{outline:3px solid #0f172a;outline-offset:3px}
.vx-play{left:50%;top:50%;z-index:1;width:56px;height:56px;margin:-28px 0 0 -28px;background:var(--vx-accent);color:#0f172a;
  box-shadow:0 8px 22px rgba(0,0,0,.3);transition:transform .2s;place-items:center}
.vx-play svg{width:24px;height:24px;margin-left:3px}
.vx-thumb:hover .vx-play{transform:scale(1.08)}
.vx-len{right:10px;bottom:10px;z-index:1;padding:2px 8px;background:rgba(15,23,42,.85);font-size:.76rem;font-variant-numeric:tabular-nums}
.vx-feature{--bs-card-border-width:0;--bs-card-border-radius:22px;--bs-card-bg:#0f172a;--bs-card-color:#fff;box-shadow:0 20px 44px rgba(15,23,42,.2)}
.vx-feature .vx-thumb{--bs-btn-border-radius:0;box-shadow:none}
.vx-feature .vx-thumb img{height:100%}
.vx-feature .vx-thumb::after{background:linear-gradient(to right,transparent 60%,rgba(15,23,42,.6))}
.vx-feature .vx-thumb:focus-visible{outline:3px solid var(--vx-accent);outline-offset:-3px}
.vx-feature .vx-play{width:72px;height:72px;margin:-36px 0 0 -36px}
.vx-feature .vx-play svg{width:30px;height:30px}
.vx-kicker{font-size:.75rem;letter-spacing:.1em;color:var(--vx-accent)}
.vx-feature h3{font-size:1.6rem;letter-spacing:-.02em;line-height:1.15}
.vx-feature .vx-text{font-size:.95rem;line-height:1.55;color:#cbd5e1}
.vx-watch{--bs-btn-bg:var(--vx-accent);--bs-btn-border-color:var(--vx-accent);--bs-btn-color:#0f172a;--bs-btn-hover-bg:#fbbf24;--bs-btn-hover-border-color:#fbbf24;
  --bs-btn-hover-color:#0f172a;--bs-btn-active-bg:#fbbf24;--bs-btn-active-color:#0f172a;--bs-btn-font-weight:700;--bs-btn-focus-box-shadow:none;--bs-btn-font-size:.92rem;
  --bs-btn-padding-x:20px;--bs-btn-padding-y:10px}
.vx-watch svg{width:18px;height:18px}
.vx-watch:focus-visible{outline:2px solid #fff;outline-offset:3px}
.vx-dialog{width:min(960px,calc(100% - 32px));max-width:none;max-height:calc(100% - 32px);margin:auto;padding:0;border:0;border-radius:20px;
  background:#0b1020;color:#fff;overflow:hidden;box-shadow:0 30px 80px rgba(0,0,0,.5)}
.vx-dialog::backdrop{background:rgba(2,6,23,.82);-webkit-backdrop-filter:blur(4px);backdrop-filter:blur(4px)}
.vx-dialog[open]{animation:vx-in .25s ease-out}
@keyframes vx-in{from{opacity:0;transform:translateY(12px) scale(.98)}}
.vx-dialog h2{font-size:1.02rem}
.vx-dialog .vx-meta{font-size:.8rem;color:#a5b4c8}
.vx-close{--bs-btn-padding-x:0;--bs-btn-padding-y:0;--bs-btn-border-width:0;--bs-btn-bg:rgba(255,255,255,.1);--bs-btn-hover-bg:rgba(255,255,255,.2);--bs-btn-active-bg:rgba(255,255,255,.26);
  --bs-btn-color:#fff;--bs-btn-hover-color:#fff;--bs-btn-active-color:#fff;--bs-btn-focus-box-shadow:none;width:40px;height:40px;place-items:center}
.vx-close svg{width:20px;height:20px}
.vx-close:focus-visible{outline:2px solid var(--vx-accent);outline-offset:2px}
.vp{--p:0;--b:0;background:#000;isolation:isolate}
.vp:focus{outline:none}
.vp video{display:block;width:100%;height:auto;aspect-ratio:16/9;max-height:calc(100vh - 120px);object-fit:contain;background:#000}
.vp[data-fullscreen] video{height:100%;max-height:none}
.vp:not([data-ready]) .vp-bar{display:none !important}
.vp[data-idle]{cursor:none}
.vp-bar{z-index:2;padding:30px 12px 8px;background:linear-gradient(to top,rgba(11,16,32,.92),rgba(11,16,32,.55) 60%,transparent);transition:opacity .25s}
.vp[data-idle] .vp-bar{opacity:0;pointer-events:none}
.vp-bar .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:10px;--bs-btn-focus-box-shadow:none;
  position:relative;display:grid;place-items:center;flex:none;width:38px;height:38px}
.vp-bar .btn:focus-visible{outline:2px solid #fff;outline-offset:1px}
.vp-bar .btn svg{display:block;width:22px;height:22px}
.vp .btn .i-pause,.vp[data-playing] .btn .i-play,.vp .btn .i-shrink,.vp[data-fullscreen] .btn .i-expand,.vp .btn .i-muted,.vp .btn[aria-pressed="true"] .i-vol{display:none}
.vp[data-playing] .btn .i-pause,.vp[data-fullscreen] .btn .i-shrink,.vp .btn[aria-pressed="true"] .i-muted{display:block}
.vp-cc[aria-pressed="true"]::after{content:"";position:absolute;left:9px;right:9px;bottom:4px;height:2px;border-radius:2px;background:#f59e0b}
.vp-time{font-size:.82rem;font-variant-numeric:tabular-nums}
.vp .form-range{height:14px;--fill:linear-gradient(to right,#f59e0b 0 calc(7px + (100% - 14px) * var(--p)),rgba(255,255,255,.75) 0 calc(7px + (100% - 14px) * var(--b)),rgba(255,255,255,.45) 0)}
.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;transition:none}
.vp .form-range::-moz-range-thumb{width:14px;height:14px;border:0;background:#fff;transition:none}
.vp .form-range:focus::-webkit-slider-thumb{box-shadow:none}
.vp .form-range:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px #f59e0b,0 0 0 5px #fff}
.vp .form-range:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px #f59e0b,0 0 0 5px #fff}
.vp-cc-box{position:absolute;left:50%;bottom:96px;z-index:1;transform:translateX(-50%);width:max-content;max-width:88%;padding:.3em .75em;
  background:rgba(11,16,32,.86);font-size:clamp(.82rem,2.3vw,1.05rem);line-height:1.4;white-space:pre-line;pointer-events:none}
.vp[data-idle] .vp-cc-box{bottom:18px}
@media (max-width:520px){.vx-feature h3{font-size:1.3rem}.vx-dialog{width:calc(100% - 16px);border-radius:14px}}
@media (prefers-reduced-motion:reduce){.vx-thumb img,.vx-play,.vp-bar{transition:none}.vx-thumb:hover img,.vx-thumb:hover .vx-play{transform:none}.vx-dialog[open]{animation:none}}
JavaScript
(() => {
// Video player core. Each [data-vp] wraps one native <video>; its controls are found by data attributes
// (data-play, data-seek, data-skip, data-mute, data-vol, data-cc, data-speed, data-fs, data-pip, data-cur, data-dur),
// so a player only needs the controls it shows. Without JavaScript the video keeps its native controls.
// Shortcuts work while focus is inside the player: Space or K play/pause, Left/Right seek 5 s, M mute,
// F full screen, C captions.
const vpFmt = s => { s = Math.max(0, Math.floor(s || 0)); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
function vpPlayer(root) {
  const $ = s => root.querySelector(s);
  const video = $('video'), plays = [...root.querySelectorAll('[data-play]')];
  const seek = $('[data-seek]'), vol = $('[data-vol]'), mute = $('[data-mute]'), cc = $('[data-cc]'), ccBox = $('[data-cc-box]');
  const stage = $('[data-stage]') || root; // the element that goes full screen
  const speed = $('[data-speed]'), fs = $('[data-fs]'), pip = $('[data-pip]'), cur = $('[data-cur]'), dur = $('[data-dur]');
  const RATES = [1, 1.25, 1.5, 2, 0.5, 0.75], listeners = [];
  let raf = 0, idle = 0, track = null, pointer = 'mouse';
  video.controls = false;
  root.setAttribute('data-ready', '');

  const length = () => (isFinite(video.duration) && video.duration) || (seek ? +seek.max : 0);
  const sync = () => {
    const t = video.currentTime, d = length();
    root.style.setProperty('--p', d ? Math.min(t / d, 1) : 0);
    if (cur) cur.textContent = vpFmt(t);
    if (seek) {
      seek.value = t;
      const text = vpFmt(t) + ' of ' + vpFmt(d);
      if (seek.getAttribute('aria-valuetext') !== text) seek.setAttribute('aria-valuetext', text);
    }
    listeners.forEach(fn => fn(t, d));
  };
  const buffered = () => {
    const b = video.buffered, d = length();
    let end = 0;
    for (let i = 0; i < b.length; i++) if (b.start(i) <= video.currentTime + 0.5) end = Math.max(end, b.end(i));
    root.style.setProperty('--b', d ? Math.min(end / d, 1) : 0);
  };
  const tick = () => { sync(); raf = requestAnimationFrame(tick); };
  const meta = () => { if (seek) seek.max = length(); if (dur) dur.textContent = vpFmt(length()); sync(); buffered(); };
  const jump = t => { video.currentTime = Math.min(Math.max(t, 0), length()); sync(); wake(); };
  const start = () => {
    // one player at a time; muted background loops (data-bg) keep running
    document.querySelectorAll('video, audio').forEach(m => { if (m !== video && !m.hasAttribute('data-bg')) m.pause(); });
    return video.play().catch(() => {});
  };
  const toggle = () => (video.paused || video.ended ? start() : video.pause());
  const name = () => root.dataset.title || 'video';
  const state = () => {
    const playing = !video.paused;
    root.toggleAttribute('data-playing', playing);
    plays.forEach(b => b.setAttribute('aria-label', (playing ? 'Pause ' : 'Play ') + name()));
    // if the focused play button was just hidden (a big centre button), hand focus to a visible one
    const a = document.activeElement;
    if (plays.includes(a) && !a.checkVisibility?.()) plays.find(b => b.checkVisibility?.())?.focus();
  };

  // Captions: the track stays in "hidden" mode and the script prints the active cue into [data-cc-box],
  // so the text sits above the control bar. Players without a box fall back to native rendering.
  // A player without a captions button shows captions when the root has data-captions.
  const ccOn = () => (cc ? cc.getAttribute('aria-pressed') === 'true' : root.hasAttribute('data-captions'));
  const render = () => {
    if (!ccBox) return;
    const cues = ccOn() && track && track.activeCues ? [...track.activeCues] : [];
    const text = cues.map(c => c.text.replace(/<[^>]+>/g, '')).join('\n');
    ccBox.textContent = text;
    ccBox.hidden = !text;
  };
  const captions = () => {
    track = [...video.textTracks].find(t => t.kind === 'captions' || t.kind === 'subtitles') || null;
    if (cc) cc.hidden = !track;
    if (!track) return render();
    track.mode = ccBox || !ccOn() ? 'hidden' : 'showing';
    track.oncuechange = render;
    render();
  };

  // Controls fade out after 2.5 s of playback without pointer movement, unless keyboard focus is inside.
  const keyboardFocus = () => { const a = document.activeElement; return !!a && a !== root && root.contains(a) && a.matches(':focus-visible'); };
  function wake() {
    root.removeAttribute('data-idle');
    clearTimeout(idle);
    if (!root.hasAttribute('data-autohide') || video.paused) return;
    idle = setTimeout(() => { if (!video.paused && !keyboardFocus()) root.setAttribute('data-idle', ''); }, 2500);
  }

  const fsElement = () => document.fullscreenElement || document.webkitFullscreenElement;
  const fullscreen = () => {
    if (fsElement() === stage) (document.exitFullscreen || document.webkitExitFullscreen).call(document);
    else if (stage.requestFullscreen) stage.requestFullscreen().catch(() => {});
    else if (stage.webkitRequestFullscreen) stage.webkitRequestFullscreen();
    else if (video.webkitEnterFullscreen) video.webkitEnterFullscreen(); // iPhone: native player
  };
  const fsSync = () => {
    const on = fsElement() === stage;
    root.toggleAttribute('data-fullscreen', on);
    if (fs) fs.setAttribute('aria-label', on ? 'Exit full screen' : 'Full screen');
  };

  plays.forEach(b => b.addEventListener('click', toggle));
  root.querySelectorAll('[data-skip]').forEach(b => b.addEventListener('click', () => jump(video.currentTime + +b.dataset.skip)));
  root.addEventListener('pointerdown', e => { pointer = e.pointerType; }, true);
  video.addEventListener('click', () => {
    // a tap on hidden controls only reveals them; a click toggles playback and gives the player focus for shortcuts
    if (pointer !== 'mouse' && root.hasAttribute('data-idle')) return wake();
    toggle();
    if (!root.contains(document.activeElement)) root.focus({ preventScroll: true });
  });
  video.addEventListener('play', () => { state(); tick(); wake(); });
  video.addEventListener('pause', () => { state(); cancelAnimationFrame(raf); sync(); wake(); });
  video.addEventListener('loadedmetadata', () => { meta(); captions(); });
  video.addEventListener('progress', buffered);
  video.addEventListener('seeked', () => { sync(); buffered(); });
  video.addEventListener('volumechange', () => {
    const v = video.muted ? 0 : video.volume;
    root.style.setProperty('--v', v);
    if (vol) { vol.value = v; vol.setAttribute('aria-valuetext', Math.round(v * 100) + '%'); }
    if (mute) mute.setAttribute('aria-pressed', String(v === 0));
  });
  video.addEventListener('ratechange', () => {
    if (speed) (speed.querySelector('[data-speed-val]') || speed).textContent = video.playbackRate + '×';
  });
  video.textTracks.addEventListener('addtrack', captions);
  video.querySelectorAll('track').forEach(t => t.addEventListener('load', captions));
  if (seek) seek.addEventListener('input', () => jump(+seek.value));
  if (vol) vol.addEventListener('input', () => { video.volume = +vol.value; video.muted = +vol.value === 0; });
  if (mute) mute.addEventListener('click', () => {
    if (video.muted || video.volume === 0) { video.muted = false; if (video.volume === 0) video.volume = 0.6; }
    else video.muted = true;
  });
  if (cc) cc.addEventListener('click', () => {
    cc.setAttribute('aria-pressed', String(!ccOn()));
    if (track && !ccBox) track.mode = ccOn() ? 'showing' : 'hidden';
    render();
  });
  if (speed) speed.addEventListener('click', () => {
    const rate = RATES[(RATES.indexOf(video.playbackRate) + 1) % RATES.length];
    video.playbackRate = video.defaultPlaybackRate = rate;
  });
  if (fs) {
    fs.hidden = !(stage.requestFullscreen || stage.webkitRequestFullscreen || video.webkitEnterFullscreen);
    fs.addEventListener('click', fullscreen);
    document.addEventListener('fullscreenchange', fsSync);
    document.addEventListener('webkitfullscreenchange', fsSync);
  }
  if (pip) {
    pip.hidden = !(document.pictureInPictureEnabled && !video.disablePictureInPicture);
    pip.addEventListener('click', () => (document.pictureInPictureElement === video
      ? document.exitPictureInPicture() : video.requestPictureInPicture()).catch(() => {}));
    video.addEventListener('enterpictureinpicture', () => pip.setAttribute('aria-pressed', 'true'));
    video.addEventListener('leavepictureinpicture', () => pip.setAttribute('aria-pressed', 'false'));
  }
  ['pointermove', 'focusin', 'focusout'].forEach(t => root.addEventListener(t, wake));
  root.addEventListener('pointerleave', () => {
    if (!root.hasAttribute('data-autohide') || video.paused || keyboardFocus()) return;
    clearTimeout(idle);
    idle = setTimeout(() => root.setAttribute('data-idle', ''), 600);
  });
  root.addEventListener('keydown', e => {
    if (e.altKey || e.ctrlKey || e.metaKey || e.defaultPrevented) return;
    const t = e.target, k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
    const control = t.matches('button, a[href], select, textarea, input:not([type="range"])');
    wake();
    if (k === 'k' || (k === ' ' && !control)) toggle();
    else if (k === 'm') video.muted = !video.muted;
    else if (k === 'f' && fs && !fs.hidden) fullscreen();
    else if (k === 'c' && cc && !cc.hidden) cc.click();
    else if ((k === 'ArrowLeft' || k === 'ArrowRight') && (t === seek || !t.matches('input[type="range"]')))
      jump(video.currentTime + (k === 'ArrowLeft' ? -5 : 5));
    else return;
    e.preventDefault();
  });

  // load() swaps the source, poster and captions file (playlists and lightboxes use it)
  const load = ({ src, poster, captions: vtt, title, duration }) => {
    video.pause();
    video.querySelectorAll('track').forEach(t => t.remove());
    if (title) root.dataset.title = title;
    video.poster = poster || '';
    video.src = src;
    if (vtt) {
      const t = Object.assign(document.createElement('track'), { kind: 'captions', srclang: 'en', label: 'English', src: vtt });
      t.addEventListener('load', captions);
      video.append(t);
    }
    if (seek && duration) seek.max = duration;
    if (dur && duration) dur.textContent = vpFmt(duration);
    video.load();
    state(); sync(); captions();
  };

  if (video.readyState > 0) meta();
  captions(); state(); fsSync();
  video.dispatchEvent(new Event('volumechange'));
  return { root, video, jump, start, toggle, load, onTime: fn => listeners.push(fn) };
}

// Lightbox. Buttons with data-open="<dialog id>" carry the video's src, poster, captions, title and duration.
// Opening loads that video into the dialog's player, opens the <dialog> as a modal, moves focus to the play
// button and starts playback; Esc, the close button or a click on the backdrop closes it, pauses the video
// and returns focus to the card that opened it.
document.querySelectorAll('dialog[data-lightbox]').forEach(dialog => {
  const root = dialog.querySelector('[data-vp="lb"]'), player = vpPlayer(root);
  const title = dialog.querySelector('[data-lightbox-title]'), meta = dialog.querySelector('[data-lightbox-meta]');
  let opener = null;
  document.querySelectorAll('[data-open="' + dialog.id + '"]').forEach(btn => btn.addEventListener('click', () => {
    const d = btn.dataset;
    opener = btn;
    if (title) title.textContent = d.title;
    if (meta) meta.textContent = d.meta || '';
    player.load({ src: d.src, poster: d.poster, captions: d.captions, title: d.title, duration: +d.duration });
    dialog.showModal();
    root.querySelector('[data-play]').focus();
    player.start(); // the visitor asked for this video, so it starts with sound
  }));
  dialog.querySelector('[data-close]').addEventListener('click', () => dialog.close());
  dialog.addEventListener('click', e => { if (e.target === dialog) dialog.close(); });
  dialog.addEventListener('close', () => {
    player.video.pause();
    if (opener) opener.focus();
  });
});
})();

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div><span class="mb-3.5 block text-[.72rem] tracking-[.12em] text-[#526075] uppercase">Video card grid</span>
<div class="grid grid-cols-3 gap-[22px] max-[760px]:grid-cols-2 max-[520px]:grid-cols-1">
  <article>
    <button type="button" class="group/t relative block w-full cursor-pointer overflow-hidden rounded-2xl bg-[#0f172a] shadow-[0_10px_26px_rgba(15,23,42,.14)] after:absolute after:inset-0 after:bg-[linear-gradient(to_top,rgba(15,23,42,.55),transparent_55%)] focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-[#0f172a]" data-open="vx-lightbox" data-src="../media/clip-1.mp4" data-poster="../media/clip-1-poster.jpg" data-captions="../media/clip-1-captions.vtt" data-title="Fractal Tide" data-meta="Northlight Studio · Study 01" data-duration="16" aria-haspopup="dialog" aria-label="Play Fractal Tide, 0:16">
      <img class="block aspect-video h-auto w-full object-cover transition-transform duration-350 group-hover/t:scale-105 motion-reduce:transition-none motion-reduce:group-hover/t:scale-100" src="../media/clip-1-thumb.jpg" alt="" width="480" height="270" loading="lazy"><span class="absolute top-1/2 left-1/2 z-[1] -mt-7 -ml-7 grid size-14 place-items-center rounded-full bg-[#f59e0b] text-[#0f172a] shadow-[0_8px_22px_rgba(0,0,0,.3)] transition-transform duration-200 group-hover/t:scale-108 motion-reduce:transition-none motion-reduce:group-hover/t:scale-100" aria-hidden="true"><svg class="ml-[3px] size-6" 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></span><span class="absolute right-2.5 bottom-2.5 z-[1] rounded-[7px] bg-[rgba(15,23,42,.85)] px-2 py-0.5 text-[.76rem] font-semibold text-white tabular-nums" aria-hidden="true">0:16</span>
    </button>
    <h3 class="mt-3 text-base leading-[1.3] font-bold">Fractal Tide</h3><p class="mt-[3px] text-[.84rem] text-[#526075]">A slow dive into the Mandelbrot set</p>
  </article>
  <article>
    <button type="button" class="group/t relative block w-full cursor-pointer overflow-hidden rounded-2xl bg-[#0f172a] shadow-[0_10px_26px_rgba(15,23,42,.14)] after:absolute after:inset-0 after:bg-[linear-gradient(to_top,rgba(15,23,42,.55),transparent_55%)] focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-[#0f172a]" data-open="vx-lightbox" data-src="../media/clip-2.mp4" data-poster="../media/clip-2-poster.jpg" data-captions="../media/clip-2-captions.vtt" data-title="Rule 30 Loom" data-meta="Northlight Studio · Study 02" data-duration="20" aria-haspopup="dialog" aria-label="Play Rule 30 Loom, 0:20">
      <img class="block aspect-video h-auto w-full object-cover transition-transform duration-350 group-hover/t:scale-105 motion-reduce:transition-none motion-reduce:group-hover/t:scale-100" src="../media/clip-2-thumb.jpg" alt="" width="480" height="270" loading="lazy"><span class="absolute top-1/2 left-1/2 z-[1] -mt-7 -ml-7 grid size-14 place-items-center rounded-full bg-[#f59e0b] text-[#0f172a] shadow-[0_8px_22px_rgba(0,0,0,.3)] transition-transform duration-200 group-hover/t:scale-108 motion-reduce:transition-none motion-reduce:group-hover/t:scale-100" aria-hidden="true"><svg class="ml-[3px] size-6" 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></span><span class="absolute right-2.5 bottom-2.5 z-[1] rounded-[7px] bg-[rgba(15,23,42,.85)] px-2 py-0.5 text-[.76rem] font-semibold text-white tabular-nums" aria-hidden="true">0:20</span>
    </button>
    <h3 class="mt-3 text-base leading-[1.3] font-bold">Rule 30 Loom</h3><p class="mt-[3px] text-[.84rem] text-[#526075]">One row of cells, one simple rule</p>
  </article>
  <article>
    <button type="button" class="group/t relative block w-full cursor-pointer overflow-hidden rounded-2xl bg-[#0f172a] shadow-[0_10px_26px_rgba(15,23,42,.14)] after:absolute after:inset-0 after:bg-[linear-gradient(to_top,rgba(15,23,42,.55),transparent_55%)] focus-visible:outline-3 focus-visible:outline-offset-3 focus-visible:outline-[#0f172a]" data-open="vx-lightbox" data-src="../media/clip-3.mp4" data-poster="../media/clip-3-poster.jpg" data-captions="../media/clip-3-captions.vtt" data-title="Life in Colour" data-meta="Northlight Studio · Study 03" data-duration="14" aria-haspopup="dialog" aria-label="Play Life in Colour, 0:14">
      <img class="block aspect-video h-auto w-full object-cover transition-transform duration-350 group-hover/t:scale-105 motion-reduce:transition-none motion-reduce:group-hover/t:scale-100" src="../media/clip-3-thumb.jpg" alt="" width="480" height="270" loading="lazy"><span class="absolute top-1/2 left-1/2 z-[1] -mt-7 -ml-7 grid size-14 place-items-center rounded-full bg-[#f59e0b] text-[#0f172a] shadow-[0_8px_22px_rgba(0,0,0,.3)] transition-transform duration-200 group-hover/t:scale-108 motion-reduce:transition-none motion-reduce:group-hover/t:scale-100" aria-hidden="true"><svg class="ml-[3px] size-6" 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></span><span class="absolute right-2.5 bottom-2.5 z-[1] rounded-[7px] bg-[rgba(15,23,42,.85)] px-2 py-0.5 text-[.76rem] font-semibold text-white tabular-nums" aria-hidden="true">0:14</span>
    </button>
    <h3 class="mt-3 text-base leading-[1.3] font-bold">Life in Colour</h3><p class="mt-[3px] text-[.84rem] text-[#526075]">Conway's Game of Life, painted</p>
  </article>
</div></div>

<div><span class="mb-3.5 block text-[.72rem] tracking-[.12em] text-[#526075] uppercase">Featured video card</span>
<article class="grid grid-cols-[minmax(0,1.25fr)_minmax(0,1fr)] overflow-hidden rounded-[22px] bg-[#0f172a] text-white shadow-[0_20px_44px_rgba(15,23,42,.2)] max-[760px]:grid-cols-1">
  <button type="button" class="group/t relative block h-full w-full cursor-pointer overflow-hidden bg-[#0f172a] after:absolute after:inset-0 after:bg-[linear-gradient(to_right,transparent_60%,rgba(15,23,42,.6))] focus-visible:outline-3 focus-visible:-outline-offset-3 focus-visible:outline-[#f59e0b]" data-open="vx-lightbox" data-src="../media/clip-4.mp4" data-poster="../media/clip-4-poster.jpg" data-captions="../media/clip-4-captions.vtt" data-title="Slow Gradients" data-meta="Northlight Studio · Study 04" data-duration="12" aria-haspopup="dialog" aria-label="Play Slow Gradients, 0:12">
    <img class="block aspect-video h-full w-full object-cover transition-transform duration-350 group-hover/t:scale-105 motion-reduce:transition-none motion-reduce:group-hover/t:scale-100" src="../media/clip-4-thumb.jpg" alt="" width="480" height="270" loading="lazy"><span class="absolute top-1/2 left-1/2 z-[1] -mt-9 -ml-9 grid size-[72px] place-items-center rounded-full bg-[#f59e0b] text-[#0f172a] shadow-[0_8px_22px_rgba(0,0,0,.3)] transition-transform duration-200 group-hover/t:scale-108 motion-reduce:transition-none motion-reduce:group-hover/t:scale-100" aria-hidden="true"><svg class="ml-[3px] size-[30px]" 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></span>
  </button>
  <div class="flex flex-col justify-center gap-2.5 px-[30px] pt-[30px] pb-8 max-[760px]:p-[22px]">
    <p class="text-[.75rem] font-bold tracking-[.1em] text-[#f59e0b] uppercase">New study · 0:12</p>
    <h3 class="text-[1.6rem] leading-[1.15] font-bold tracking-[-.02em] max-[520px]:text-[1.3rem]">Slow Gradients</h3>
    <p class="text-[.95rem] leading-[1.55] text-[#cbd5e1]">Four colours drift across the frame and blend where they meet, set to a single warm synth chord. The fourth film in the Northlight series.</p>
    <button type="button" class="mt-1.5 inline-flex cursor-pointer items-center gap-2 self-start rounded-full bg-[#f59e0b] py-[11px] pr-5 pl-4 text-[.92rem] font-bold text-[#0f172a] hover:bg-[#fbbf24] focus-visible:outline-2 focus-visible:outline-offset-3 focus-visible:outline-white" data-open="vx-lightbox" data-src="../media/clip-4.mp4" data-poster="../media/clip-4-poster.jpg" data-captions="../media/clip-4-captions.vtt" data-title="Slow Gradients" data-meta="Northlight Studio · Study 04" data-duration="12" aria-haspopup="dialog"><svg class="size-[18px]" 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>Watch the film</button>
  </div>
</article></div>

<dialog class="m-auto max-h-[calc(100%-32px)] w-[min(960px,calc(100%-32px))] max-w-none overflow-hidden rounded-[20px] bg-[#0b1020] p-0 text-white shadow-[0_30px_80px_rgba(0,0,0,.5)] backdrop:bg-[rgba(2,6,23,.82)] backdrop:backdrop-blur-[4px] open:animate-vx-in motion-reduce:open:animate-none max-[520px]:w-[calc(100%-16px)] max-[520px]:rounded-[14px]" id="vx-lightbox" data-lightbox aria-labelledby="vx-lightbox-title">
  <div class="flex items-center gap-3 py-3 pr-3 pl-5">
    <div class="min-w-0 flex-1"><h2 class="truncate text-[1.02rem] font-bold" id="vx-lightbox-title" data-lightbox-title>Video</h2><p class="text-[.8rem] text-[#a5b4c8]" data-lightbox-meta></p></div>
    <button type="button" class="grid size-10 flex-none cursor-pointer place-items-center rounded-full bg-white/10 hover:bg-white/20 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#f59e0b]" data-close aria-label="Close video"><svg class="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"><path d="M6 6l12 12M18 6 6 18"/></svg></button>
  </div>
  <div class="group/vp relative isolate bg-black [--b:0] [--p:0] focus:outline-none data-idle:cursor-none" data-vp="lb" data-title="video" data-autohide role="group" aria-label="Video player" tabindex="-1">
    <video class="block aspect-video h-auto max-h-[calc(100vh-120px)] w-full bg-black object-contain group-data-fullscreen/vp:h-full group-data-fullscreen/vp:max-h-none" preload="none" playsinline controls></video>
    <p class="pointer-events-none absolute bottom-24 left-1/2 z-[1] m-0 w-max max-w-[88%] -translate-x-1/2 rounded-lg bg-[rgba(11,16,32,.86)] px-[.75em] py-[.3em] text-center text-[clamp(.82rem,2.3vw,1.05rem)] leading-[1.4] whitespace-pre-line group-data-idle/vp:bottom-[18px]" data-cc-box hidden></p>
    <div class="absolute inset-x-0 bottom-0 z-[2] hidden flex-wrap items-center gap-0.5 bg-[linear-gradient(to_top,rgba(11,16,32,.92),rgba(11,16,32,.55)_60%,transparent)] px-3 pt-[30px] pb-2 transition-opacity duration-250 group-data-ready/vp:flex group-data-idle/vp:pointer-events-none group-data-idle/vp:opacity-0 motion-reduce:transition-none">
      <input type="range" class="vx-range mx-1 mb-1 flex-[1_0_100%]" data-seek min="0" max="16" step="0.1" value="0" aria-label="Seek" aria-valuetext="0:00 of 0:16">
      <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 video"><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="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>
      <span class="flex-1 px-1.5 text-[.82rem] whitespace-nowrap tabular-nums"><span data-cur>0:00</span> / <span data-dur>0:16</span></span>
      <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-[#f59e0b]" 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>
</dialog>
CSS
/* The seek slider needs vendor pseudo-elements and the dialog an entry animation, so they get a few lines of CSS. */
@utility vx-range {
  -webkit-appearance: none; appearance: none; display: block; min-width: 0; height: 14px; background: none; cursor: pointer;
  --fill: linear-gradient(to right, #f59e0b 0 calc(7px + (100% - 14px) * var(--p)), rgb(255 255 255 / .75) 0 calc(7px + (100% - 14px) * var(--b)), rgb(255 255 255 / .45) 0);
  &:focus-visible { outline: none; }
  &::-webkit-slider-runnable-track { height: 4px; border-radius: 99px; background: var(--fill); }
  &::-moz-range-track { height: 4px; border-radius: 99px; background: var(--fill); }
  &::-webkit-slider-thumb { -webkit-appearance: none; appearance: none; width: 14px; height: 14px; margin-top: -5px; border: 0; border-radius: 50%; background: #fff; }
  &::-moz-range-thumb { width: 14px; height: 14px; border: 0; border-radius: 50%; background: #fff; }
  &:focus-visible::-webkit-slider-thumb { box-shadow: 0 0 0 3px #f59e0b, 0 0 0 5px #fff; }
  &:focus-visible::-moz-range-thumb { box-shadow: 0 0 0 3px #f59e0b, 0 0 0 5px #fff; }
}
@theme { --animate-vx-in: vx-in .25s ease-out; }
@keyframes vx-in { from { opacity: 0; transform: translateY(12px) scale(.98); } }
JavaScript
(() => {
// Video player core. Each [data-vp] wraps one native <video>; its controls are found by data attributes
// (data-play, data-seek, data-skip, data-mute, data-vol, data-cc, data-speed, data-fs, data-pip, data-cur, data-dur),
// so a player only needs the controls it shows. Without JavaScript the video keeps its native controls.
// Shortcuts work while focus is inside the player: Space or K play/pause, Left/Right seek 5 s, M mute,
// F full screen, C captions.
const vpFmt = s => { s = Math.max(0, Math.floor(s || 0)); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
function vpPlayer(root) {
  const $ = s => root.querySelector(s);
  const video = $('video'), plays = [...root.querySelectorAll('[data-play]')];
  const seek = $('[data-seek]'), vol = $('[data-vol]'), mute = $('[data-mute]'), cc = $('[data-cc]'), ccBox = $('[data-cc-box]');
  const stage = $('[data-stage]') || root; // the element that goes full screen
  const speed = $('[data-speed]'), fs = $('[data-fs]'), pip = $('[data-pip]'), cur = $('[data-cur]'), dur = $('[data-dur]');
  const RATES = [1, 1.25, 1.5, 2, 0.5, 0.75], listeners = [];
  let raf = 0, idle = 0, track = null, pointer = 'mouse';
  video.controls = false;
  root.setAttribute('data-ready', '');

  const length = () => (isFinite(video.duration) && video.duration) || (seek ? +seek.max : 0);
  const sync = () => {
    const t = video.currentTime, d = length();
    root.style.setProperty('--p', d ? Math.min(t / d, 1) : 0);
    if (cur) cur.textContent = vpFmt(t);
    if (seek) {
      seek.value = t;
      const text = vpFmt(t) + ' of ' + vpFmt(d);
      if (seek.getAttribute('aria-valuetext') !== text) seek.setAttribute('aria-valuetext', text);
    }
    listeners.forEach(fn => fn(t, d));
  };
  const buffered = () => {
    const b = video.buffered, d = length();
    let end = 0;
    for (let i = 0; i < b.length; i++) if (b.start(i) <= video.currentTime + 0.5) end = Math.max(end, b.end(i));
    root.style.setProperty('--b', d ? Math.min(end / d, 1) : 0);
  };
  const tick = () => { sync(); raf = requestAnimationFrame(tick); };
  const meta = () => { if (seek) seek.max = length(); if (dur) dur.textContent = vpFmt(length()); sync(); buffered(); };
  const jump = t => { video.currentTime = Math.min(Math.max(t, 0), length()); sync(); wake(); };
  const start = () => {
    // one player at a time; muted background loops (data-bg) keep running
    document.querySelectorAll('video, audio').forEach(m => { if (m !== video && !m.hasAttribute('data-bg')) m.pause(); });
    return video.play().catch(() => {});
  };
  const toggle = () => (video.paused || video.ended ? start() : video.pause());
  const name = () => root.dataset.title || 'video';
  const state = () => {
    const playing = !video.paused;
    root.toggleAttribute('data-playing', playing);
    plays.forEach(b => b.setAttribute('aria-label', (playing ? 'Pause ' : 'Play ') + name()));
    // if the focused play button was just hidden (a big centre button), hand focus to a visible one
    const a = document.activeElement;
    if (plays.includes(a) && !a.checkVisibility?.()) plays.find(b => b.checkVisibility?.())?.focus();
  };

  // Captions: the track stays in "hidden" mode and the script prints the active cue into [data-cc-box],
  // so the text sits above the control bar. Players without a box fall back to native rendering.
  // A player without a captions button shows captions when the root has data-captions.
  const ccOn = () => (cc ? cc.getAttribute('aria-pressed') === 'true' : root.hasAttribute('data-captions'));
  const render = () => {
    if (!ccBox) return;
    const cues = ccOn() && track && track.activeCues ? [...track.activeCues] : [];
    const text = cues.map(c => c.text.replace(/<[^>]+>/g, '')).join('\n');
    ccBox.textContent = text;
    ccBox.hidden = !text;
  };
  const captions = () => {
    track = [...video.textTracks].find(t => t.kind === 'captions' || t.kind === 'subtitles') || null;
    if (cc) cc.hidden = !track;
    if (!track) return render();
    track.mode = ccBox || !ccOn() ? 'hidden' : 'showing';
    track.oncuechange = render;
    render();
  };

  // Controls fade out after 2.5 s of playback without pointer movement, unless keyboard focus is inside.
  const keyboardFocus = () => { const a = document.activeElement; return !!a && a !== root && root.contains(a) && a.matches(':focus-visible'); };
  function wake() {
    root.removeAttribute('data-idle');
    clearTimeout(idle);
    if (!root.hasAttribute('data-autohide') || video.paused) return;
    idle = setTimeout(() => { if (!video.paused && !keyboardFocus()) root.setAttribute('data-idle', ''); }, 2500);
  }

  const fsElement = () => document.fullscreenElement || document.webkitFullscreenElement;
  const fullscreen = () => {
    if (fsElement() === stage) (document.exitFullscreen || document.webkitExitFullscreen).call(document);
    else if (stage.requestFullscreen) stage.requestFullscreen().catch(() => {});
    else if (stage.webkitRequestFullscreen) stage.webkitRequestFullscreen();
    else if (video.webkitEnterFullscreen) video.webkitEnterFullscreen(); // iPhone: native player
  };
  const fsSync = () => {
    const on = fsElement() === stage;
    root.toggleAttribute('data-fullscreen', on);
    if (fs) fs.setAttribute('aria-label', on ? 'Exit full screen' : 'Full screen');
  };

  plays.forEach(b => b.addEventListener('click', toggle));
  root.querySelectorAll('[data-skip]').forEach(b => b.addEventListener('click', () => jump(video.currentTime + +b.dataset.skip)));
  root.addEventListener('pointerdown', e => { pointer = e.pointerType; }, true);
  video.addEventListener('click', () => {
    // a tap on hidden controls only reveals them; a click toggles playback and gives the player focus for shortcuts
    if (pointer !== 'mouse' && root.hasAttribute('data-idle')) return wake();
    toggle();
    if (!root.contains(document.activeElement)) root.focus({ preventScroll: true });
  });
  video.addEventListener('play', () => { state(); tick(); wake(); });
  video.addEventListener('pause', () => { state(); cancelAnimationFrame(raf); sync(); wake(); });
  video.addEventListener('loadedmetadata', () => { meta(); captions(); });
  video.addEventListener('progress', buffered);
  video.addEventListener('seeked', () => { sync(); buffered(); });
  video.addEventListener('volumechange', () => {
    const v = video.muted ? 0 : video.volume;
    root.style.setProperty('--v', v);
    if (vol) { vol.value = v; vol.setAttribute('aria-valuetext', Math.round(v * 100) + '%'); }
    if (mute) mute.setAttribute('aria-pressed', String(v === 0));
  });
  video.addEventListener('ratechange', () => {
    if (speed) (speed.querySelector('[data-speed-val]') || speed).textContent = video.playbackRate + '×';
  });
  video.textTracks.addEventListener('addtrack', captions);
  video.querySelectorAll('track').forEach(t => t.addEventListener('load', captions));
  if (seek) seek.addEventListener('input', () => jump(+seek.value));
  if (vol) vol.addEventListener('input', () => { video.volume = +vol.value; video.muted = +vol.value === 0; });
  if (mute) mute.addEventListener('click', () => {
    if (video.muted || video.volume === 0) { video.muted = false; if (video.volume === 0) video.volume = 0.6; }
    else video.muted = true;
  });
  if (cc) cc.addEventListener('click', () => {
    cc.setAttribute('aria-pressed', String(!ccOn()));
    if (track && !ccBox) track.mode = ccOn() ? 'showing' : 'hidden';
    render();
  });
  if (speed) speed.addEventListener('click', () => {
    const rate = RATES[(RATES.indexOf(video.playbackRate) + 1) % RATES.length];
    video.playbackRate = video.defaultPlaybackRate = rate;
  });
  if (fs) {
    fs.hidden = !(stage.requestFullscreen || stage.webkitRequestFullscreen || video.webkitEnterFullscreen);
    fs.addEventListener('click', fullscreen);
    document.addEventListener('fullscreenchange', fsSync);
    document.addEventListener('webkitfullscreenchange', fsSync);
  }
  if (pip) {
    pip.hidden = !(document.pictureInPictureEnabled && !video.disablePictureInPicture);
    pip.addEventListener('click', () => (document.pictureInPictureElement === video
      ? document.exitPictureInPicture() : video.requestPictureInPicture()).catch(() => {}));
    video.addEventListener('enterpictureinpicture', () => pip.setAttribute('aria-pressed', 'true'));
    video.addEventListener('leavepictureinpicture', () => pip.setAttribute('aria-pressed', 'false'));
  }
  ['pointermove', 'focusin', 'focusout'].forEach(t => root.addEventListener(t, wake));
  root.addEventListener('pointerleave', () => {
    if (!root.hasAttribute('data-autohide') || video.paused || keyboardFocus()) return;
    clearTimeout(idle);
    idle = setTimeout(() => root.setAttribute('data-idle', ''), 600);
  });
  root.addEventListener('keydown', e => {
    if (e.altKey || e.ctrlKey || e.metaKey || e.defaultPrevented) return;
    const t = e.target, k = e.key.length === 1 ? e.key.toLowerCase() : e.key;
    const control = t.matches('button, a[href], select, textarea, input:not([type="range"])');
    wake();
    if (k === 'k' || (k === ' ' && !control)) toggle();
    else if (k === 'm') video.muted = !video.muted;
    else if (k === 'f' && fs && !fs.hidden) fullscreen();
    else if (k === 'c' && cc && !cc.hidden) cc.click();
    else if ((k === 'ArrowLeft' || k === 'ArrowRight') && (t === seek || !t.matches('input[type="range"]')))
      jump(video.currentTime + (k === 'ArrowLeft' ? -5 : 5));
    else return;
    e.preventDefault();
  });

  // load() swaps the source, poster and captions file (playlists and lightboxes use it)
  const load = ({ src, poster, captions: vtt, title, duration }) => {
    video.pause();
    video.querySelectorAll('track').forEach(t => t.remove());
    if (title) root.dataset.title = title;
    video.poster = poster || '';
    video.src = src;
    if (vtt) {
      const t = Object.assign(document.createElement('track'), { kind: 'captions', srclang: 'en', label: 'English', src: vtt });
      t.addEventListener('load', captions);
      video.append(t);
    }
    if (seek && duration) seek.max = duration;
    if (dur && duration) dur.textContent = vpFmt(duration);
    video.load();
    state(); sync(); captions();
  };

  if (video.readyState > 0) meta();
  captions(); state(); fsSync();
  video.dispatchEvent(new Event('volumechange'));
  return { root, video, jump, start, toggle, load, onTime: fn => listeners.push(fn) };
}

// Lightbox. Buttons with data-open="<dialog id>" carry the video's src, poster, captions, title and duration.
// Opening loads that video into the dialog's player, opens the <dialog> as a modal, moves focus to the play
// button and starts playback; Esc, the close button or a click on the backdrop closes it, pauses the video
// and returns focus to the card that opened it.
document.querySelectorAll('dialog[data-lightbox]').forEach(dialog => {
  const root = dialog.querySelector('[data-vp="lb"]'), player = vpPlayer(root);
  const title = dialog.querySelector('[data-lightbox-title]'), meta = dialog.querySelector('[data-lightbox-meta]');
  let opener = null;
  document.querySelectorAll('[data-open="' + dialog.id + '"]').forEach(btn => btn.addEventListener('click', () => {
    const d = btn.dataset;
    opener = btn;
    if (title) title.textContent = d.title;
    if (meta) meta.textContent = d.meta || '';
    player.load({ src: d.src, poster: d.poster, captions: d.captions, title: d.title, duration: +d.duration });
    dialog.showModal();
    root.querySelector('[data-play]').focus();
    player.start(); // the visitor asked for this video, so it starts with sound
  }));
  dialog.querySelector('[data-close]').addEventListener('click', () => dialog.close());
  dialog.addEventListener('click', e => { if (e.target === dialog) dialog.close(); });
  dialog.addEventListener('close', () => {
    player.video.pause();
    if (opener) opener.focus();
  });
});
})();

About this set

Video cards that open a full player in a lightbox built on the native dialog element. The card grid shows three poster thumbnails with an amber play badge, a duration label, a title and a one-line description; the featured card pairs a wide thumbnail with a kicker, title, paragraph and a Watch the film button. Every opener is a button with data-open set to the dialog’s id and data attributes for the video’s source, poster, captions file, title and duration, so one dialog serves every card on the page. Opening a card loads that video, opens the dialog with showModal, moves focus to the player’s play button and starts playback, because the visitor asked for that video. The dialog has a title, a close button and a player with seek, mute, time, captions and full screen. Pressing Esc, the close button or the dimmed backdrop closes it, pauses the video and returns focus to the card that opened it. Because the dialog is modal, the page behind it is inert and the browser keeps keyboard focus inside.

What’s included

  • Card grid and featured card with poster thumbnails and duration labels
  • One shared dialog player for every card
  • Focus moves to the play button on open and back to the card on close
  • Esc, the close button or the backdrop closes and pauses
  • Page behind the modal dialog is inert

Accessibility

Each card button is named Play followed by the title and length and has aria-haspopup set to dialog. The dialog is labelled by its heading, its player controls are named buttons and sliders, and captions are on by default. Focus is managed on open and close, focus outlines are visible, and the thumbnail zoom and dialog entry animation stop under prefers-reduced-motion.

Customise it

Change --vx-accent on .vx-cards, .vx-feature and .vx-dialog for the play badges, button and seek fill, and the width of .vx-dialog for the lightbox size. Bootstrap uses .card, .row and .btn variables; in Tailwind edit the bg-[#f59e0b] classes and the vx-range utility.