Open demo

CSS code

HTML
<div><span class="lbl">Chapters from a WebVTT file</span>
<div class="vc" data-vp="ch" data-title="Rule 30 Loom" data-autohide role="group" aria-label="Rule 30 Loom video player with chapters" tabindex="-1">
  <div class="vc-stage" data-stage>
    <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>
      <track kind="chapters" src="../media/clip-2-chapters.vtt" srclang="en" label="Chapters">
    </video>
    <p class="vc-cc-box" data-cc-box hidden></p>
    <div class="vc-bar">
      <div class="vc-seek">
        <input type="range" data-seek min="0" max="20" step="0.1" value="0" aria-label="Seek" aria-valuetext="0:00 of 0:20">
        <span class="vc-ticks" data-ticks aria-hidden="true"></span>
      </div>
      <div class="vc-row">
        <button type="button" class="vc-btn" 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>
        <button type="button" class="vc-btn vc-skip" data-skip="-10" aria-label="Back 10 seconds"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 12a8.5 8.5 0 1 0 2.6-6.1"/><path d="M3.5 4v4.5H8"/><text x="12.6" y="15.4" fill="currentColor" stroke="none" font-size="7" font-weight="700" text-anchor="middle" font-family="system-ui,sans-serif">10</text></svg></button>
        <button type="button" class="vc-btn vc-skip" data-skip="10" aria-label="Forward 10 seconds"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.5 12a8.5 8.5 0 1 1-2.6-6.1"/><path d="M20.5 4v4.5H16"/><text x="11.4" y="15.4" fill="currentColor" stroke="none" font-size="7" font-weight="700" text-anchor="middle" font-family="system-ui,sans-serif">10</text></svg></button>
        <span class="vc-time"><span data-cur>0:00</span> / <span data-dur>0:20</span></span>
        <span class="vc-now" data-chapter-now></span>
        <button type="button" class="vc-btn vc-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="vc-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>
  <ol class="vc-chapters" data-chapters aria-label="Chapters"></ol>
  <template data-chapter-template>
    <li><button type="button" class="vc-chapter"><span data-chapter-time></span><b data-chapter-title></b><i aria-hidden="true"></i></button></li>
  </template>
</div></div>

<div><span class="lbl">Interactive transcript</span>
<div class="vc vc-split" data-vp="ch" data-title="Rule 30 Loom" data-autohide role="group" aria-label="Rule 30 Loom video player with transcript" tabindex="-1">
  <div class="vc-stage" data-stage>
    <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">
    </video>
    <p class="vc-cc-box" data-cc-box hidden></p>
    <div class="vc-bar">
      <div class="vc-seek"><input type="range" data-seek min="0" max="20" step="0.1" value="0" aria-label="Seek" aria-valuetext="0:00 of 0:20"></div>
      <div class="vc-row">
        <button type="button" class="vc-btn" 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="vc-time"><span data-cur>0:00</span> / <span data-dur>0:20</span></span>
        <span class="vc-now"></span>
        <button type="button" class="vc-btn vc-cc" data-cc aria-label="Captions" aria-pressed="false"><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="vc-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>
  <section class="vc-panel" aria-labelledby="vc-tr-title">
    <h3 id="vc-tr-title">Transcript <small>Select a line to jump there</small></h3>
    <ol class="vc-lines" data-transcript>
      <li><button type="button" class="vc-line" data-at="0"><span class="vc-at">0:00</span><span>On screen: Rule 30 Loom, Northlight Studio, Study 02. <em>[Quiet synth pad in D]</em></span></button></li>
      <li><button type="button" class="vc-line" data-at="1.5"><span class="vc-at">0:01</span><span>A single lit cell sits at the top centre of a dark navy frame.</span></button></li>
      <li><button type="button" class="vc-line" data-at="3"><span class="vc-at">0:03</span><span>Each new row is worked out from the three cells above it, and the rows scroll upward.</span></button></li>
      <li><button type="button" class="vc-line" data-at="5"><span class="vc-at">0:05</span><span>Chapter 2, First branches. <em>[A chime, then a plucked arpeggio starts]</em></span></button></li>
      <li><button type="button" class="vc-line" data-at="6.5"><span class="vc-at">0:06</span><span>The cell has grown into a triangle, orderly on its left edge and ragged on its right.</span></button></li>
      <li><button type="button" class="vc-line" data-at="8.5"><span class="vc-at">0:08</span><span>Small upside-down triangles open and close inside the pattern.</span></button></li>
      <li><button type="button" class="vc-line" data-at="10"><span class="vc-at">0:10</span><span>Chapter 3, The pattern widens. <em>[A soft bass pulse enters under the arpeggio]</em></span></button></li>
      <li><button type="button" class="vc-line" data-at="12"><span class="vc-at">0:12</span><span>The triangle reaches both sides of the frame as the colour drifts from cyan to violet.</span></button></li>
      <li><button type="button" class="vc-line" data-at="15"><span class="vc-at">0:15</span><span>Chapter 4, Full weave. <em>[Ticking percussion, the melody rises]</em></span></button></li>
      <li><button type="button" class="vc-line" data-at="17"><span class="vc-at">0:17</span><span>The woven texture fills the whole frame and keeps scrolling upward.</span></button></li>
      <li><button type="button" class="vc-line" data-at="19"><span class="vc-at">0:19</span><span><em>[The music fades out]</em></span></button></li>
    </ol>
  </section>
</div></div>
CSS
/* Video players with chapters and a transcript. Chapters come from a WebVTT file (<track kind="chapters">);
   the transcript is plain markup, one button per line with data-at="seconds". The script sets --p on .vc,
   --cp on each chapter button, and aria-current on the current chapter and transcript line. */
.vc{--vc-accent:#14b8a6;--vc-deep:#0f766e;--vc-ink:#0f172a;--vc-muted:#475569;--p:0;--b:0;
  display:block;color:var(--vc-ink)}
.vc:focus{outline:none}
.vc [hidden]{display:none !important}
.vc-stage{position:relative;overflow:hidden;border-radius:18px;background:#0b1026;color:#fff;isolation:isolate;box-shadow:0 18px 40px rgba(15,60,55,.18)}
.vc video{display:block;width:100%;height:auto;aspect-ratio:16/9;object-fit:cover;background:#0b1026}
.vc-stage:fullscreen{border-radius:0}
.vc-stage:fullscreen video{height:100%;object-fit:contain}
.vc:not([data-ready]) .vc-bar{display:none}
.vc[data-idle] .vc-stage{cursor:none}
.vc button{font:inherit;color:inherit;cursor:pointer}
.vc-btn{flex:none;display:grid;place-items:center;width:38px;height:38px;padding:0;border:0;border-radius:10px;background:none;position:relative}
.vc-btn:hover{background:rgba(255,255,255,.16)}
.vc-btn:focus-visible{outline:2px solid #fff;outline-offset:1px}
.vc-btn svg{display:block;width:22px;height:22px}
.vc .i-pause,.vc[data-playing] .i-play,.vc .i-shrink,.vc[data-fullscreen] .i-expand{display:none}
.vc[data-playing] .i-pause,.vc[data-fullscreen] .i-shrink{display:block}
.vc-cc[aria-pressed="true"]::after{content:"";position:absolute;left:9px;right:9px;bottom:4px;height:2px;border-radius:2px;background:var(--vc-accent)}
.vc-time{padding:0 6px;font-size:.82rem;font-variant-numeric:tabular-nums;white-space:nowrap}
.vc-now{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:.85rem;font-weight:600}
.vc-now:not(:empty)::before{content:"· ";opacity:.7}
/* overlay bar */
.vc-bar{position:absolute;inset:auto 0 0;z-index:2;padding:34px 14px 10px;background:linear-gradient(to top,rgba(5,10,30,.92),rgba(5,10,30,.6) 55%,transparent);
  transition:opacity .25s}
.vc[data-idle] .vc-bar{opacity:0;pointer-events:none}
.vc-row{display:flex;align-items:center;gap:2px;margin-top:6px}
/* seek slider with chapter gaps drawn on top */
.vc-seek{position:relative}
.vc-ticks{position:absolute;inset:0 7px;pointer-events:none}
.vc-ticks i{position:absolute;top:50%;width:4px;height:6px;margin:-3px 0 0 -2px;background:#0b1026}
.vc input[type="range"]{-webkit-appearance:none;appearance:none;display:block;width:100%;height:14px;margin:0;background:none;cursor:pointer;
  --fill:linear-gradient(to right,var(--vc-accent) 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)}
.vc input[type="range"]:focus-visible{outline:none}
.vc input[type="range"]::-webkit-slider-runnable-track{height:4px;border-radius:99px;background:var(--fill)}
.vc input[type="range"]::-moz-range-track{height:4px;border-radius:99px;background:var(--fill)}
.vc input[type="range"]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;position:relative;z-index:1;width:14px;height:14px;margin-top:-5px;border:0;border-radius:50%;background:#fff}
.vc input[type="range"]::-moz-range-thumb{width:14px;height:14px;border:0;border-radius:50%;background:#fff}
.vc input[type="range"]:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px var(--vc-accent),0 0 0 5px #fff}
.vc input[type="range"]:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px var(--vc-accent),0 0 0 5px #fff}
.vc-cc-box{position:absolute;left:50%;bottom:92px;z-index:1;transform:translateX(-50%);width:max-content;max-width:88%;margin:0;padding:.3em .75em;border-radius:8px;
  background:rgba(5,10,30,.84);color:#fff;font-size:clamp(.82rem,2.3vw,1.05rem);line-height:1.4;text-align:center;white-space:pre-line;pointer-events:none}
.vc[data-idle] .vc-cc-box{bottom:18px}
/* 1. chapter cards built from the VTT file */
.vc-chapters{display:grid;grid-template-columns:repeat(4,1fr);gap:10px;margin-top:14px;list-style:none}
.vc-chapters:empty{display:none}
.vc-chapter{--cp:0;display:flex;flex-direction:column;gap:4px;width:100%;height:100%;padding:12px 14px 14px;border:1px solid #cfe0dc;border-radius:14px;background:#fff;text-align:left}
.vc-chapter:hover{border-color:var(--vc-deep)}
.vc-chapter:focus-visible{outline:2px solid var(--vc-deep);outline-offset:2px}
.vc-chapter b{font-size:.92rem}
.vc-chapter span{font-size:.78rem;color:var(--vc-muted);font-variant-numeric:tabular-nums}
.vc-chapter i{display:block;height:4px;margin-top:auto;border-radius:99px;background:linear-gradient(to right,var(--vc-deep) calc(var(--cp) * 100%),#c9d9d6 0)}
.vc-chapter[aria-current="true"]{border-color:var(--vc-deep);background:#f0fdfa;box-shadow:inset 0 0 0 1px var(--vc-deep)}
/* 2. side-by-side transcript */
.vc-split{display:grid;grid-template-columns:minmax(0,1.55fr) minmax(0,1fr);gap:18px;align-items:start}
.vc-panel{display:flex;flex-direction:column;border:1px solid #cfe0dc;border-radius:18px;background:#fff;overflow:hidden}
.vc-panel h3{display:flex;justify-content:space-between;align-items:baseline;padding:14px 18px;border-bottom:1px solid #e2ece9;font-size:.95rem}
.vc-panel h3 small{font-size:.75rem;font-weight:500;color:var(--vc-muted)}
.vc-lines{position:relative;max-height:262px;overflow-y:auto;padding:8px;list-style:none;overscroll-behavior:contain}
.vc-line{display:flex;gap:12px;width:100%;padding:8px 10px;border:0;border-radius:10px;background:none;text-align:left;font-size:.9rem;line-height:1.45}
.vc-line .vc-at{flex:none;min-width:2.6em;padding-top:1px;font-size:.78rem;font-weight:600;color:var(--vc-deep);font-variant-numeric:tabular-nums}
.vc-line:hover{background:#f0fdfa}
.vc-line:focus-visible{outline:2px solid var(--vc-deep);outline-offset:-2px}
.vc-line[aria-current="true"]{background:#ccfbf1;color:#134e4a}
.vc-line em{font-style:normal;color:var(--vc-muted)}
.vc-line[aria-current="true"] em{color:#134e4a}
@media (max-width:760px){
  .vc-split{grid-template-columns:1fr}
  .vc-lines{max-height:240px}
  .vc-chapters{grid-template-columns:repeat(2,1fr)}
}
@media (max-width:560px){.vc-skip,.vc-now{display:none}.vc-cc-box{bottom:84px}.vc-row .vc-time{margin-right:auto}}
@media (prefers-reduced-motion:reduce){.vc-bar{transition:none}}
JavaScript
(() => {
// Video player core. Each [data-vp] wraps one native <video>; its controls are found by data attributes
// (data-play, data-seek, data-skip, data-mute, data-vol, data-cc, data-speed, data-fs, data-pip, data-cur, data-dur),
// so a player only needs the controls it shows. Without JavaScript the video keeps its native controls.
// Shortcuts work while focus is inside the player: Space or K play/pause, Left/Right seek 5 s, M mute,
// F full screen, C captions.
const vpFmt = s => { s = Math.max(0, Math.floor(s || 0)); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
function vpPlayer(root) {
  const $ = s => root.querySelector(s);
  const video = $('video'), plays = [...root.querySelectorAll('[data-play]')];
  const seek = $('[data-seek]'), vol = $('[data-vol]'), mute = $('[data-mute]'), cc = $('[data-cc]'), ccBox = $('[data-cc-box]');
  const stage = $('[data-stage]') || root; // the element that goes full screen
  const speed = $('[data-speed]'), fs = $('[data-fs]'), pip = $('[data-pip]'), cur = $('[data-cur]'), dur = $('[data-dur]');
  const RATES = [1, 1.25, 1.5, 2, 0.5, 0.75], listeners = [];
  let raf = 0, idle = 0, track = null, pointer = 'mouse';
  video.controls = false;
  root.setAttribute('data-ready', '');

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

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

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

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

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

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

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

// Chapters and transcript. Chapter buttons are built from <track kind="chapters"> by cloning
// [data-chapter-template]; transcript lines are buttons with data-at="seconds". Both seek on click
// and carry aria-current while their part of the video plays.
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)');
document.querySelectorAll('[data-vp="ch"]').forEach(root => {
  const player = vpPlayer(root), video = player.video;
  const list = root.querySelector('[data-chapters]'), tpl = root.querySelector('[data-chapter-template]');
  const ticks = root.querySelector('[data-ticks]'), now = root.querySelector('[data-chapter-now]');
  const box = root.querySelector('[data-transcript]'), lines = box ? [...box.querySelectorAll('[data-at]')] : [];
  const chapterTrack = video.querySelector('track[kind="chapters"]');
  let chapters = [], buttons = [], current = null;

  const seekTo = t => { player.jump(t); player.start(); };
  const build = () => {
    const cues = chapterTrack && chapterTrack.track.cues;
    if (!cues || !cues.length || chapters.length) return;
    chapters = [...cues].map(c => ({ start: c.startTime, end: c.endTime, title: c.text }));
    const d = video.duration || chapters[chapters.length - 1].end;
    if (ticks) ticks.replaceChildren(...chapters.slice(1).map(c => {
      const i = document.createElement('i');
      i.style.left = (c.start / d) * 100 + '%';
      return i;
    }));
    if (list && tpl) {
      list.replaceChildren(...chapters.map(c => {
        const li = tpl.content.firstElementChild.cloneNode(true), b = li.querySelector('button');
        li.querySelector('[data-chapter-time]').textContent = vpFmt(c.start);
        li.querySelector('[data-chapter-title]').textContent = c.title;
        b.addEventListener('click', () => seekTo(c.start));
        return li;
      }));
      buttons = [...list.querySelectorAll('button')];
    }
    update(video.currentTime);
  };
  const update = t => {
    const i = chapters.findLastIndex(c => c.start <= t + 0.05);
    buttons.forEach((b, n) => {
      const c = chapters[n];
      b.style.setProperty('--cp', Math.min(Math.max((t - c.start) / (c.end - c.start), 0), 1));
      if (n === i) b.setAttribute('aria-current', 'true'); else b.removeAttribute('aria-current');
    });
    if (now) now.textContent = i >= 0 ? chapters[i].title : '';
    const line = lines.filter(l => +l.dataset.at <= t + 0.05).pop() || null;
    if (line === current) return;
    if (current) current.removeAttribute('aria-current');
    current = line;
    if (!line) return;
    line.setAttribute('aria-current', 'true');
    // keep the current line in view inside the transcript box, without scrolling the page
    const top = line.offsetTop, bottom = top + line.offsetHeight;
    if (top < box.scrollTop || bottom > box.scrollTop + box.clientHeight)
      box.scrollTo({ top: top - box.clientHeight / 3, behavior: reduceMotion.matches ? 'auto' : 'smooth' });
  };

  player.onTime(update);
  lines.forEach(l => l.addEventListener('click', () => seekTo(+l.dataset.at)));
  if (chapterTrack) {
    chapterTrack.track.mode = 'hidden'; // loads the cues without showing them
    chapterTrack.addEventListener('load', build);
    if (chapterTrack.readyState === 2) build();
  }
  update(0);
});
})();

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<div><span class="lbl">Chapters from a WebVTT file</span>
<div class="vc" data-vp="ch" data-title="Rule 30 Loom" data-autohide role="group" aria-label="Rule 30 Loom video player with chapters" tabindex="-1">
  <div class="vc-stage position-relative overflow-hidden rounded-4 text-white" data-stage>
    <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>
        <track kind="chapters" src="../media/clip-2-chapters.vtt" srclang="en" label="Chapters">
      </video>
    </div>
    <p class="vc-cc-box m-0 rounded-2 text-center" data-cc-box hidden></p>
    <div class="vc-bar position-absolute bottom-0 start-0 end-0">
      <div class="position-relative">
        <input type="range" class="form-range d-block" data-seek min="0" max="20" step="0.1" value="0" aria-label="Seek" aria-valuetext="0:00 of 0:20">
        <span class="vc-ticks position-absolute" data-ticks aria-hidden="true"></span>
      </div>
      <div class="d-flex align-items-center gap-1 mt-1">
        <button type="button" class="btn" 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>
        <button type="button" class="btn vc-skip" data-skip="-10" aria-label="Back 10 seconds"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 12a8.5 8.5 0 1 0 2.6-6.1"/><path d="M3.5 4v4.5H8"/><text x="12.6" y="15.4" fill="currentColor" stroke="none" font-size="7" font-weight="700" text-anchor="middle" font-family="system-ui,sans-serif">10</text></svg></button>
        <button type="button" class="btn vc-skip" data-skip="10" aria-label="Forward 10 seconds"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.5 12a8.5 8.5 0 1 1-2.6-6.1"/><path d="M20.5 4v4.5H16"/><text x="11.4" y="15.4" fill="currentColor" stroke="none" font-size="7" font-weight="700" text-anchor="middle" font-family="system-ui,sans-serif">10</text></svg></button>
        <span class="vc-time px-2 text-nowrap"><span data-cur>0:00</span> / <span data-dur>0:20</span></span>
        <span class="vc-now flex-grow-1 text-truncate fw-semibold" data-chapter-now></span>
        <button type="button" class="btn vc-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>
  </div>
  <ol class="vc-chapters row row-cols-2 row-cols-md-4 g-2 list-unstyled mt-2 mb-0" data-chapters aria-label="Chapters"></ol>
  <template data-chapter-template>
    <li class="col"><button type="button" class="vc-chapter btn btn-light bg-white d-flex flex-column gap-1 w-100 h-100 px-3 pt-2 pb-3 text-start"><span data-chapter-time></span><b data-chapter-title></b><i class="mt-auto" aria-hidden="true"></i></button></li>
  </template>
</div></div>

<div><span class="lbl">Interactive transcript</span>
<div class="vc row g-3 align-items-start" data-vp="ch" data-title="Rule 30 Loom" data-autohide role="group" aria-label="Rule 30 Loom video player with transcript" tabindex="-1">
  <div class="col-md-7">
    <div class="vc-stage position-relative overflow-hidden rounded-4 text-white" data-stage>
      <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">
        </video>
      </div>
      <p class="vc-cc-box m-0 rounded-2 text-center" data-cc-box hidden></p>
      <div class="vc-bar position-absolute bottom-0 start-0 end-0">
        <input type="range" class="form-range d-block" data-seek min="0" max="20" step="0.1" value="0" aria-label="Seek" aria-valuetext="0:00 of 0:20">
        <div class="d-flex align-items-center gap-1 mt-1">
          <button type="button" class="btn" 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="vc-time px-2 me-auto text-nowrap"><span data-cur>0:00</span> / <span data-dur>0:20</span></span>
          <button type="button" class="btn vc-cc" data-cc aria-label="Captions" aria-pressed="false"><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>
    </div>
  </div>
  <div class="col-md-5">
    <section class="vc-panel card" aria-labelledby="vc-tr-title">
      <h3 class="card-header d-flex justify-content-between align-items-baseline py-3 px-3 fs-6 fw-bold mb-0" id="vc-tr-title">Transcript <small class="fw-normal text-secondary-emphasis" style="font-size:.75rem">Select a line to jump there</small></h3>
      <ol class="vc-lines list-group list-group-flush list-unstyled p-2 mb-0" data-transcript>
        <li><button type="button" class="list-group-item list-group-item-action" data-at="0"><span class="vc-at">0:00</span><span>On screen: Rule 30 Loom, Northlight Studio, Study 02. <em>[Quiet synth pad in D]</em></span></button></li>
        <li><button type="button" class="list-group-item list-group-item-action" data-at="1.5"><span class="vc-at">0:01</span><span>A single lit cell sits at the top centre of a dark navy frame.</span></button></li>
        <li><button type="button" class="list-group-item list-group-item-action" data-at="3"><span class="vc-at">0:03</span><span>Each new row is worked out from the three cells above it, and the rows scroll upward.</span></button></li>
        <li><button type="button" class="list-group-item list-group-item-action" data-at="5"><span class="vc-at">0:05</span><span>Chapter 2, First branches. <em>[A chime, then a plucked arpeggio starts]</em></span></button></li>
        <li><button type="button" class="list-group-item list-group-item-action" data-at="6.5"><span class="vc-at">0:06</span><span>The cell has grown into a triangle, orderly on its left edge and ragged on its right.</span></button></li>
        <li><button type="button" class="list-group-item list-group-item-action" data-at="8.5"><span class="vc-at">0:08</span><span>Small upside-down triangles open and close inside the pattern.</span></button></li>
        <li><button type="button" class="list-group-item list-group-item-action" data-at="10"><span class="vc-at">0:10</span><span>Chapter 3, The pattern widens. <em>[A soft bass pulse enters under the arpeggio]</em></span></button></li>
        <li><button type="button" class="list-group-item list-group-item-action" data-at="12"><span class="vc-at">0:12</span><span>The triangle reaches both sides of the frame as the colour drifts from cyan to violet.</span></button></li>
        <li><button type="button" class="list-group-item list-group-item-action" data-at="15"><span class="vc-at">0:15</span><span>Chapter 4, Full weave. <em>[Ticking percussion, the melody rises]</em></span></button></li>
        <li><button type="button" class="list-group-item list-group-item-action" data-at="17"><span class="vc-at">0:17</span><span>The woven texture fills the whole frame and keeps scrolling upward.</span></button></li>
        <li><button type="button" class="list-group-item list-group-item-action" data-at="19"><span class="vc-at">0:19</span><span><em>[The music fades out]</em></span></button></li>
      </ol>
    </section>
  </div>
</div></div>
CSS
/* Video players with chapters and a transcript on Bootstrap 5: .btn controls, .form-range seek, a .row of chapter
   buttons built from <track kind="chapters">, and a .card with a .list-group transcript. The script sets --p on .vc,
   --cp on each chapter button and aria-current on the current chapter and line. */
.vc{--vc-accent:#14b8a6;--vc-deep:#0f766e;--vc-muted:#475569;--p:0;--b:0}
.vc:focus{outline:none}
.vc-stage{background:#0b1026;isolation:isolate;box-shadow:0 18px 40px rgba(15,60,55,.18)}
.vc video{object-fit:cover;background:#0b1026}
.vc-stage:fullscreen{border-radius:0 !important}
.vc-stage:fullscreen .ratio{height:100%}
.vc-stage:fullscreen .ratio::before{display:none}
.vc-stage:fullscreen video{object-fit:contain}
.vc:not([data-ready]) .vc-bar{display:none}
.vc[data-idle] .vc-stage{cursor:none}
.vc-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}
.vc-bar .btn:focus-visible{outline:2px solid #fff;outline-offset:1px}
.vc-bar .btn svg{display:block;width:22px;height:22px}
.vc .btn .i-pause,.vc[data-playing] .btn .i-play,.vc .btn .i-shrink,.vc[data-fullscreen] .btn .i-expand{display:none}
.vc[data-playing] .btn .i-pause,.vc[data-fullscreen] .btn .i-shrink{display:block}
.vc-cc[aria-pressed="true"]::after{content:"";position:absolute;left:9px;right:9px;bottom:4px;height:2px;border-radius:2px;background:var(--vc-accent)}
.vc-time{font-size:.82rem;font-variant-numeric:tabular-nums}
.vc-now{font-size:.85rem}
.vc-now:not(:empty)::before{content:"· ";opacity:.7}
.vc-bar{z-index:2;padding:34px 14px 10px;background:linear-gradient(to top,rgba(5,10,30,.92),rgba(5,10,30,.6) 55%,transparent);transition:opacity .25s}
.vc[data-idle] .vc-bar{opacity:0;pointer-events:none}
.vc-ticks{inset:0 7px;pointer-events:none}
.vc-ticks i{position:absolute;top:50%;width:4px;height:6px;margin:-3px 0 0 -2px;background:#0b1026}
.vc .form-range{height:14px;--fill:linear-gradient(to right,var(--vc-accent) 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)}
.vc .form-range::-webkit-slider-runnable-track{height:4px;border-radius:99px;background:var(--fill)}
.vc .form-range::-moz-range-track{height:4px;border-radius:99px;background:var(--fill)}
.vc .form-range::-webkit-slider-thumb{position:relative;z-index:1;width:14px;height:14px;margin-top:-5px;background:#fff;transition:none}
.vc .form-range::-moz-range-thumb{width:14px;height:14px;border:0;background:#fff;transition:none}
.vc .form-range:focus::-webkit-slider-thumb{box-shadow:none}
.vc .form-range:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px var(--vc-accent),0 0 0 5px #fff}
.vc .form-range:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px var(--vc-accent),0 0 0 5px #fff}
.vc-cc-box{position:absolute;left:50%;bottom:92px;z-index:1;transform:translateX(-50%);width:max-content;max-width:88%;padding:.3em .75em;
  background:rgba(5,10,30,.84);font-size:clamp(.82rem,2.3vw,1.05rem);line-height:1.4;white-space:pre-line;pointer-events:none}
.vc[data-idle] .vc-cc-box{bottom:18px}
/* chapter buttons */
.vc-chapters:empty{display:none}
.vc-chapter{--cp:0;border:1px solid #cfe0dc;border-radius:14px;line-height:1.25}
.vc-chapter:hover{border-color:var(--vc-deep)}
.vc-chapter:focus-visible{outline:2px solid var(--vc-deep);outline-offset:2px}
.vc-chapter span{font-size:.78rem;color:var(--vc-muted);font-variant-numeric:tabular-nums}
.vc-chapter b{font-size:.92rem}
.vc-chapter i{display:block;height:4px;border-radius:99px;background:linear-gradient(to right,var(--vc-deep) calc(var(--cp) * 100%),#c9d9d6 0)}
.vc-chapter[aria-current="true"]{border-color:var(--vc-deep);background:#f0fdfa;box-shadow:inset 0 0 0 1px var(--vc-deep)}
/* transcript */
.vc-panel{--bs-card-border-color:#cfe0dc;--bs-card-border-radius:18px;--bs-card-cap-bg:#fff}
.vc-lines{position:relative;max-height:262px;overflow-y:auto;overscroll-behavior:contain;--bs-list-group-border-width:0;
  --bs-list-group-action-color:#0f172a;--bs-list-group-action-hover-bg:#f0fdfa;--bs-list-group-action-active-bg:#ccfbf1;--bs-list-group-action-hover-color:#0f172a}
.vc-lines .list-group-item{display:flex;gap:12px;border-radius:10px;font-size:.9rem;line-height:1.45}
.vc-lines .list-group-item:focus-visible{outline:2px solid var(--vc-deep);outline-offset:-2px;z-index:2}
.vc-at{flex:none;min-width:2.6em;padding-top:1px;font-size:.78rem;font-weight:600;color:var(--vc-deep);font-variant-numeric:tabular-nums}
.vc-lines em{font-style:normal;color:var(--vc-muted)}
.vc-lines .list-group-item[aria-current="true"]{background:#ccfbf1;color:#134e4a}
.vc-lines .list-group-item[aria-current="true"] em{color:#134e4a}
@media (max-width:560px){.vc-skip,.vc-now{display:none !important}.vc-cc-box{bottom:84px}.vc-bar .vc-time{margin-right:auto}}
@media (prefers-reduced-motion:reduce){.vc-bar{transition:none}}
JavaScript
(() => {
// Video player core. Each [data-vp] wraps one native <video>; its controls are found by data attributes
// (data-play, data-seek, data-skip, data-mute, data-vol, data-cc, data-speed, data-fs, data-pip, data-cur, data-dur),
// so a player only needs the controls it shows. Without JavaScript the video keeps its native controls.
// Shortcuts work while focus is inside the player: Space or K play/pause, Left/Right seek 5 s, M mute,
// F full screen, C captions.
const vpFmt = s => { s = Math.max(0, Math.floor(s || 0)); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
function vpPlayer(root) {
  const $ = s => root.querySelector(s);
  const video = $('video'), plays = [...root.querySelectorAll('[data-play]')];
  const seek = $('[data-seek]'), vol = $('[data-vol]'), mute = $('[data-mute]'), cc = $('[data-cc]'), ccBox = $('[data-cc-box]');
  const stage = $('[data-stage]') || root; // the element that goes full screen
  const speed = $('[data-speed]'), fs = $('[data-fs]'), pip = $('[data-pip]'), cur = $('[data-cur]'), dur = $('[data-dur]');
  const RATES = [1, 1.25, 1.5, 2, 0.5, 0.75], listeners = [];
  let raf = 0, idle = 0, track = null, pointer = 'mouse';
  video.controls = false;
  root.setAttribute('data-ready', '');

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

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

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

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

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

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

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

// Chapters and transcript. Chapter buttons are built from <track kind="chapters"> by cloning
// [data-chapter-template]; transcript lines are buttons with data-at="seconds". Both seek on click
// and carry aria-current while their part of the video plays.
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)');
document.querySelectorAll('[data-vp="ch"]').forEach(root => {
  const player = vpPlayer(root), video = player.video;
  const list = root.querySelector('[data-chapters]'), tpl = root.querySelector('[data-chapter-template]');
  const ticks = root.querySelector('[data-ticks]'), now = root.querySelector('[data-chapter-now]');
  const box = root.querySelector('[data-transcript]'), lines = box ? [...box.querySelectorAll('[data-at]')] : [];
  const chapterTrack = video.querySelector('track[kind="chapters"]');
  let chapters = [], buttons = [], current = null;

  const seekTo = t => { player.jump(t); player.start(); };
  const build = () => {
    const cues = chapterTrack && chapterTrack.track.cues;
    if (!cues || !cues.length || chapters.length) return;
    chapters = [...cues].map(c => ({ start: c.startTime, end: c.endTime, title: c.text }));
    const d = video.duration || chapters[chapters.length - 1].end;
    if (ticks) ticks.replaceChildren(...chapters.slice(1).map(c => {
      const i = document.createElement('i');
      i.style.left = (c.start / d) * 100 + '%';
      return i;
    }));
    if (list && tpl) {
      list.replaceChildren(...chapters.map(c => {
        const li = tpl.content.firstElementChild.cloneNode(true), b = li.querySelector('button');
        li.querySelector('[data-chapter-time]').textContent = vpFmt(c.start);
        li.querySelector('[data-chapter-title]').textContent = c.title;
        b.addEventListener('click', () => seekTo(c.start));
        return li;
      }));
      buttons = [...list.querySelectorAll('button')];
    }
    update(video.currentTime);
  };
  const update = t => {
    const i = chapters.findLastIndex(c => c.start <= t + 0.05);
    buttons.forEach((b, n) => {
      const c = chapters[n];
      b.style.setProperty('--cp', Math.min(Math.max((t - c.start) / (c.end - c.start), 0), 1));
      if (n === i) b.setAttribute('aria-current', 'true'); else b.removeAttribute('aria-current');
    });
    if (now) now.textContent = i >= 0 ? chapters[i].title : '';
    const line = lines.filter(l => +l.dataset.at <= t + 0.05).pop() || null;
    if (line === current) return;
    if (current) current.removeAttribute('aria-current');
    current = line;
    if (!line) return;
    line.setAttribute('aria-current', 'true');
    // keep the current line in view inside the transcript box, without scrolling the page
    const top = line.offsetTop, bottom = top + line.offsetHeight;
    if (top < box.scrollTop || bottom > box.scrollTop + box.clientHeight)
      box.scrollTo({ top: top - box.clientHeight / 3, behavior: reduceMotion.matches ? 'auto' : 'smooth' });
  };

  player.onTime(update);
  lines.forEach(l => l.addEventListener('click', () => seekTo(+l.dataset.at)));
  if (chapterTrack) {
    chapterTrack.track.mode = 'hidden'; // loads the cues without showing them
    chapterTrack.addEventListener('load', build);
    if (chapterTrack.readyState === 2) build();
  }
  update(0);
});
})();

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div><span class="mb-3.5 block text-[.72rem] tracking-[.12em] text-[#4b635f] uppercase">Chapters from a WebVTT file</span>
<div class="group/vp block [--b:0] [--p:0] focus:outline-none" data-vp="ch" data-title="Rule 30 Loom" data-autohide role="group" aria-label="Rule 30 Loom video player with chapters" tabindex="-1">
  <div class="relative isolate overflow-hidden rounded-[18px] bg-[#0b1026] text-white shadow-[0_18px_40px_rgba(15,60,55,.18)] group-data-idle/vp:cursor-none group-data-fullscreen/vp:rounded-none" data-stage>
    <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>
      <track kind="chapters" src="../media/clip-2-chapters.vtt" srclang="en" label="Chapters">
    </video>
    <p class="pointer-events-none absolute bottom-[92px] left-1/2 z-[1] m-0 w-max max-w-[88%] -translate-x-1/2 rounded-lg bg-[rgba(5,10,30,.84)] 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] max-[560px]:bottom-[84px] max-[560px]:group-data-idle/vp:bottom-[18px]" data-cc-box hidden></p>
    <div class="absolute inset-x-0 bottom-0 z-[2] hidden bg-[linear-gradient(to_top,rgba(5,10,30,.92),rgba(5,10,30,.6)_55%,transparent)] px-3.5 pt-[34px] pb-2.5 transition-opacity duration-250 group-data-ready/vp:block group-data-idle/vp:pointer-events-none group-data-idle/vp:opacity-0 motion-reduce:transition-none">
      <div class="relative">
        <input type="range" class="vc-range" data-seek min="0" max="20" step="0.1" value="0" aria-label="Seek" aria-valuetext="0:00 of 0:20">
        <span class="pointer-events-none absolute inset-y-0 right-[7px] left-[7px] [&>i]:absolute [&>i]:top-1/2 [&>i]:-mt-[3px] [&>i]:-ml-0.5 [&>i]:h-1.5 [&>i]:w-1 [&>i]:bg-[#0b1026]" data-ticks aria-hidden="true"></span>
      </div>
      <div class="mt-1.5 flex items-center gap-0.5">
        <button type="button" class="grid size-[38px] flex-none cursor-pointer place-items-center rounded-[10px] hover:bg-white/16 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white" data-play aria-label="Play Rule 30 Loom"><svg class="block size-[22px] group-data-playing/vp:hidden" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="currentColor"><path d="M8 5.5v13a1 1 0 0 0 1.5.86l10.5-6.5a1 1 0 0 0 0-1.72L9.5 4.64A1 1 0 0 0 8 5.5z"/></svg><svg class="hidden size-[22px] group-data-playing/vp:block" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="currentColor"><rect x="6.5" y="5" width="4" height="14" rx="1.2"/><rect x="13.5" y="5" width="4" height="14" rx="1.2"/></svg></button>
        <button type="button" class="grid size-[38px] flex-none cursor-pointer place-items-center rounded-[10px] hover:bg-white/16 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white max-[560px]:hidden" data-skip="-10" aria-label="Back 10 seconds"><svg class="block size-[22px]" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 12a8.5 8.5 0 1 0 2.6-6.1"/><path d="M3.5 4v4.5H8"/><text x="12.6" y="15.4" fill="currentColor" stroke="none" font-size="7" font-weight="700" text-anchor="middle" font-family="system-ui,sans-serif">10</text></svg></button>
        <button type="button" class="grid size-[38px] flex-none cursor-pointer place-items-center rounded-[10px] hover:bg-white/16 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white max-[560px]:hidden" data-skip="10" aria-label="Forward 10 seconds"><svg class="block size-[22px]" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.5 12a8.5 8.5 0 1 1-2.6-6.1"/><path d="M20.5 4v4.5H16"/><text x="11.4" y="15.4" fill="currentColor" stroke="none" font-size="7" font-weight="700" text-anchor="middle" font-family="system-ui,sans-serif">10</text></svg></button>
        <span class="px-1.5 text-[.82rem] whitespace-nowrap tabular-nums max-[560px]:mr-auto"><span data-cur>0:00</span> / <span data-dur>0:20</span></span>
        <span class="min-w-0 flex-1 truncate text-[.85rem] font-semibold before:opacity-70 not-empty:before:content-['·_'] max-[560px]:hidden" data-chapter-now></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-[#14b8a6]" data-cc aria-label="Captions" aria-pressed="true"><svg class="block size-[22px]" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="3"/><path d="M10.5 10.2a2.3 2.3 0 1 0 0 3.6M16.5 10.2a2.3 2.3 0 1 0 0 3.6"/></svg></button>
        <button type="button" class="grid size-[38px] flex-none cursor-pointer place-items-center rounded-[10px] hover:bg-white/16 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white" data-fs aria-label="Full screen"><svg class="block size-[22px] group-data-fullscreen/vp:hidden" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg><svg class="hidden size-[22px] group-data-fullscreen/vp:block" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"/></svg></button>
      </div>
    </div>
  </div>
  <ol class="mt-3.5 grid grid-cols-4 gap-2.5 empty:hidden max-[760px]:grid-cols-2" data-chapters aria-label="Chapters"></ol>
  <template data-chapter-template>
    <li><button type="button" class="flex size-full cursor-pointer flex-col gap-1 rounded-[14px] border border-[#cfe0dc] bg-white px-3.5 pt-3 pb-3.5 text-left [--cp:0] hover:border-[#0f766e] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#0f766e] aria-[current=true]:border-[#0f766e] aria-[current=true]:bg-[#f0fdfa] aria-[current=true]:shadow-[inset_0_0_0_1px_#0f766e]"><span class="text-[.78rem] text-[#475569] tabular-nums" data-chapter-time></span><b class="text-[.92rem]" data-chapter-title></b><i class="mt-auto block h-1 rounded-full bg-[linear-gradient(to_right,#0f766e_calc(var(--cp)*100%),#c9d9d6_0)]" aria-hidden="true"></i></button></li>
  </template>
</div></div>

<div><span class="mb-3.5 block text-[.72rem] tracking-[.12em] text-[#4b635f] uppercase">Interactive transcript</span>
<div class="group/vp grid grid-cols-[minmax(0,1.55fr)_minmax(0,1fr)] items-start gap-[18px] [--b:0] [--p:0] focus:outline-none max-[760px]:grid-cols-1" data-vp="ch" data-title="Rule 30 Loom" data-autohide role="group" aria-label="Rule 30 Loom video player with transcript" tabindex="-1">
  <div class="relative isolate overflow-hidden rounded-[18px] bg-[#0b1026] text-white shadow-[0_18px_40px_rgba(15,60,55,.18)] group-data-idle/vp:cursor-none group-data-fullscreen/vp:rounded-none" data-stage>
    <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">
    </video>
    <p class="pointer-events-none absolute bottom-[92px] left-1/2 z-[1] m-0 w-max max-w-[88%] -translate-x-1/2 rounded-lg bg-[rgba(5,10,30,.84)] 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] max-[560px]:bottom-[84px] max-[560px]:group-data-idle/vp:bottom-[18px]" data-cc-box hidden></p>
    <div class="absolute inset-x-0 bottom-0 z-[2] hidden bg-[linear-gradient(to_top,rgba(5,10,30,.92),rgba(5,10,30,.6)_55%,transparent)] px-3.5 pt-[34px] pb-2.5 transition-opacity duration-250 group-data-ready/vp:block group-data-idle/vp:pointer-events-none group-data-idle/vp:opacity-0 motion-reduce:transition-none">
      <input type="range" class="vc-range" data-seek min="0" max="20" step="0.1" value="0" aria-label="Seek" aria-valuetext="0:00 of 0:20">
      <div class="mt-1.5 flex items-center gap-0.5">
        <button type="button" class="grid size-[38px] flex-none cursor-pointer place-items-center rounded-[10px] hover:bg-white/16 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white" data-play aria-label="Play Rule 30 Loom"><svg class="block size-[22px] group-data-playing/vp:hidden" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="currentColor"><path d="M8 5.5v13a1 1 0 0 0 1.5.86l10.5-6.5a1 1 0 0 0 0-1.72L9.5 4.64A1 1 0 0 0 8 5.5z"/></svg><svg class="hidden size-[22px] group-data-playing/vp:block" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="currentColor"><rect x="6.5" y="5" width="4" height="14" rx="1.2"/><rect x="13.5" y="5" width="4" height="14" rx="1.2"/></svg></button>
        <span class="mr-auto px-1.5 text-[.82rem] whitespace-nowrap tabular-nums"><span data-cur>0:00</span> / <span data-dur>0:20</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-[#14b8a6]" data-cc aria-label="Captions" aria-pressed="false"><svg class="block size-[22px]" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="3"/><path d="M10.5 10.2a2.3 2.3 0 1 0 0 3.6M16.5 10.2a2.3 2.3 0 1 0 0 3.6"/></svg></button>
        <button type="button" class="grid size-[38px] flex-none cursor-pointer place-items-center rounded-[10px] hover:bg-white/16 focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-white" data-fs aria-label="Full screen"><svg class="block size-[22px] group-data-fullscreen/vp:hidden" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 9V4h5M20 9V4h-5M4 15v5h5M20 15v5h-5"/></svg><svg class="hidden size-[22px] group-data-fullscreen/vp:block" viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 4v5H4M15 4v5h5M9 20v-5H4M15 20v-5h5"/></svg></button>
      </div>
    </div>
  </div>
  <section class="flex flex-col overflow-hidden rounded-[18px] border border-[#cfe0dc] bg-white" aria-labelledby="vc-tr-title">
    <h3 class="flex items-baseline justify-between border-b border-[#e2ece9] px-[18px] py-3.5 text-[.95rem] font-bold" id="vc-tr-title">Transcript <small class="text-[.75rem] font-medium text-[#475569]">Select a line to jump there</small></h3>
    <ol class="relative max-h-[262px] overflow-y-auto overscroll-contain p-2 max-[760px]:max-h-[240px]" data-transcript>
      <li><button type="button" class="flex w-full cursor-pointer gap-3 rounded-[10px] px-2.5 py-2 text-left text-[.9rem] leading-[1.45] hover:bg-[#f0fdfa] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#0f766e] aria-[current=true]:bg-[#ccfbf1] aria-[current=true]:text-[#134e4a] [&_em]:text-[#475569] [&_em]:not-italic aria-[current=true]:[&_em]:text-[#134e4a]" data-at="0"><span class="min-w-[2.6em] flex-none pt-px text-[.78rem] font-semibold text-[#0f766e] tabular-nums">0:00</span><span>On screen: Rule 30 Loom, Northlight Studio, Study 02. <em>[Quiet synth pad in D]</em></span></button></li>
      <li><button type="button" class="flex w-full cursor-pointer gap-3 rounded-[10px] px-2.5 py-2 text-left text-[.9rem] leading-[1.45] hover:bg-[#f0fdfa] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#0f766e] aria-[current=true]:bg-[#ccfbf1] aria-[current=true]:text-[#134e4a] [&_em]:text-[#475569] [&_em]:not-italic aria-[current=true]:[&_em]:text-[#134e4a]" data-at="1.5"><span class="min-w-[2.6em] flex-none pt-px text-[.78rem] font-semibold text-[#0f766e] tabular-nums">0:01</span><span>A single lit cell sits at the top centre of a dark navy frame.</span></button></li>
      <li><button type="button" class="flex w-full cursor-pointer gap-3 rounded-[10px] px-2.5 py-2 text-left text-[.9rem] leading-[1.45] hover:bg-[#f0fdfa] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#0f766e] aria-[current=true]:bg-[#ccfbf1] aria-[current=true]:text-[#134e4a] [&_em]:text-[#475569] [&_em]:not-italic aria-[current=true]:[&_em]:text-[#134e4a]" data-at="3"><span class="min-w-[2.6em] flex-none pt-px text-[.78rem] font-semibold text-[#0f766e] tabular-nums">0:03</span><span>Each new row is worked out from the three cells above it, and the rows scroll upward.</span></button></li>
      <li><button type="button" class="flex w-full cursor-pointer gap-3 rounded-[10px] px-2.5 py-2 text-left text-[.9rem] leading-[1.45] hover:bg-[#f0fdfa] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#0f766e] aria-[current=true]:bg-[#ccfbf1] aria-[current=true]:text-[#134e4a] [&_em]:text-[#475569] [&_em]:not-italic aria-[current=true]:[&_em]:text-[#134e4a]" data-at="5"><span class="min-w-[2.6em] flex-none pt-px text-[.78rem] font-semibold text-[#0f766e] tabular-nums">0:05</span><span>Chapter 2, First branches. <em>[A chime, then a plucked arpeggio starts]</em></span></button></li>
      <li><button type="button" class="flex w-full cursor-pointer gap-3 rounded-[10px] px-2.5 py-2 text-left text-[.9rem] leading-[1.45] hover:bg-[#f0fdfa] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#0f766e] aria-[current=true]:bg-[#ccfbf1] aria-[current=true]:text-[#134e4a] [&_em]:text-[#475569] [&_em]:not-italic aria-[current=true]:[&_em]:text-[#134e4a]" data-at="6.5"><span class="min-w-[2.6em] flex-none pt-px text-[.78rem] font-semibold text-[#0f766e] tabular-nums">0:06</span><span>The cell has grown into a triangle, orderly on its left edge and ragged on its right.</span></button></li>
      <li><button type="button" class="flex w-full cursor-pointer gap-3 rounded-[10px] px-2.5 py-2 text-left text-[.9rem] leading-[1.45] hover:bg-[#f0fdfa] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#0f766e] aria-[current=true]:bg-[#ccfbf1] aria-[current=true]:text-[#134e4a] [&_em]:text-[#475569] [&_em]:not-italic aria-[current=true]:[&_em]:text-[#134e4a]" data-at="8.5"><span class="min-w-[2.6em] flex-none pt-px text-[.78rem] font-semibold text-[#0f766e] tabular-nums">0:08</span><span>Small upside-down triangles open and close inside the pattern.</span></button></li>
      <li><button type="button" class="flex w-full cursor-pointer gap-3 rounded-[10px] px-2.5 py-2 text-left text-[.9rem] leading-[1.45] hover:bg-[#f0fdfa] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#0f766e] aria-[current=true]:bg-[#ccfbf1] aria-[current=true]:text-[#134e4a] [&_em]:text-[#475569] [&_em]:not-italic aria-[current=true]:[&_em]:text-[#134e4a]" data-at="10"><span class="min-w-[2.6em] flex-none pt-px text-[.78rem] font-semibold text-[#0f766e] tabular-nums">0:10</span><span>Chapter 3, The pattern widens. <em>[A soft bass pulse enters under the arpeggio]</em></span></button></li>
      <li><button type="button" class="flex w-full cursor-pointer gap-3 rounded-[10px] px-2.5 py-2 text-left text-[.9rem] leading-[1.45] hover:bg-[#f0fdfa] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#0f766e] aria-[current=true]:bg-[#ccfbf1] aria-[current=true]:text-[#134e4a] [&_em]:text-[#475569] [&_em]:not-italic aria-[current=true]:[&_em]:text-[#134e4a]" data-at="12"><span class="min-w-[2.6em] flex-none pt-px text-[.78rem] font-semibold text-[#0f766e] tabular-nums">0:12</span><span>The triangle reaches both sides of the frame as the colour drifts from cyan to violet.</span></button></li>
      <li><button type="button" class="flex w-full cursor-pointer gap-3 rounded-[10px] px-2.5 py-2 text-left text-[.9rem] leading-[1.45] hover:bg-[#f0fdfa] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#0f766e] aria-[current=true]:bg-[#ccfbf1] aria-[current=true]:text-[#134e4a] [&_em]:text-[#475569] [&_em]:not-italic aria-[current=true]:[&_em]:text-[#134e4a]" data-at="15"><span class="min-w-[2.6em] flex-none pt-px text-[.78rem] font-semibold text-[#0f766e] tabular-nums">0:15</span><span>Chapter 4, Full weave. <em>[Ticking percussion, the melody rises]</em></span></button></li>
      <li><button type="button" class="flex w-full cursor-pointer gap-3 rounded-[10px] px-2.5 py-2 text-left text-[.9rem] leading-[1.45] hover:bg-[#f0fdfa] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#0f766e] aria-[current=true]:bg-[#ccfbf1] aria-[current=true]:text-[#134e4a] [&_em]:text-[#475569] [&_em]:not-italic aria-[current=true]:[&_em]:text-[#134e4a]" data-at="17"><span class="min-w-[2.6em] flex-none pt-px text-[.78rem] font-semibold text-[#0f766e] tabular-nums">0:17</span><span>The woven texture fills the whole frame and keeps scrolling upward.</span></button></li>
      <li><button type="button" class="flex w-full cursor-pointer gap-3 rounded-[10px] px-2.5 py-2 text-left text-[.9rem] leading-[1.45] hover:bg-[#f0fdfa] focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-[#0f766e] aria-[current=true]:bg-[#ccfbf1] aria-[current=true]:text-[#134e4a] [&_em]:text-[#475569] [&_em]:not-italic aria-[current=true]:[&_em]:text-[#134e4a]" data-at="19"><span class="min-w-[2.6em] flex-none pt-px text-[.78rem] font-semibold text-[#0f766e] tabular-nums">0:19</span><span><em>[The music fades out]</em></span></button></li>
    </ol>
  </section>
</div></div>
CSS
/* The seek slider needs vendor pseudo-elements, so it gets one small utility. --p (played) and --b (buffered)
   are set by the script. */
@utility vc-range {
  -webkit-appearance: none; appearance: none; display: block; width: 100%; height: 14px; margin: 0; background: none; cursor: pointer;
  --fill: linear-gradient(to right, #14b8a6 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; position: relative; z-index: 1; 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 #14b8a6, 0 0 0 5px #fff; }
  &:focus-visible::-moz-range-thumb { box-shadow: 0 0 0 3px #14b8a6, 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 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) };
}

// Chapters and transcript. Chapter buttons are built from <track kind="chapters"> by cloning
// [data-chapter-template]; transcript lines are buttons with data-at="seconds". Both seek on click
// and carry aria-current while their part of the video plays.
const reduceMotion = matchMedia('(prefers-reduced-motion: reduce)');
document.querySelectorAll('[data-vp="ch"]').forEach(root => {
  const player = vpPlayer(root), video = player.video;
  const list = root.querySelector('[data-chapters]'), tpl = root.querySelector('[data-chapter-template]');
  const ticks = root.querySelector('[data-ticks]'), now = root.querySelector('[data-chapter-now]');
  const box = root.querySelector('[data-transcript]'), lines = box ? [...box.querySelectorAll('[data-at]')] : [];
  const chapterTrack = video.querySelector('track[kind="chapters"]');
  let chapters = [], buttons = [], current = null;

  const seekTo = t => { player.jump(t); player.start(); };
  const build = () => {
    const cues = chapterTrack && chapterTrack.track.cues;
    if (!cues || !cues.length || chapters.length) return;
    chapters = [...cues].map(c => ({ start: c.startTime, end: c.endTime, title: c.text }));
    const d = video.duration || chapters[chapters.length - 1].end;
    if (ticks) ticks.replaceChildren(...chapters.slice(1).map(c => {
      const i = document.createElement('i');
      i.style.left = (c.start / d) * 100 + '%';
      return i;
    }));
    if (list && tpl) {
      list.replaceChildren(...chapters.map(c => {
        const li = tpl.content.firstElementChild.cloneNode(true), b = li.querySelector('button');
        li.querySelector('[data-chapter-time]').textContent = vpFmt(c.start);
        li.querySelector('[data-chapter-title]').textContent = c.title;
        b.addEventListener('click', () => seekTo(c.start));
        return li;
      }));
      buttons = [...list.querySelectorAll('button')];
    }
    update(video.currentTime);
  };
  const update = t => {
    const i = chapters.findLastIndex(c => c.start <= t + 0.05);
    buttons.forEach((b, n) => {
      const c = chapters[n];
      b.style.setProperty('--cp', Math.min(Math.max((t - c.start) / (c.end - c.start), 0), 1));
      if (n === i) b.setAttribute('aria-current', 'true'); else b.removeAttribute('aria-current');
    });
    if (now) now.textContent = i >= 0 ? chapters[i].title : '';
    const line = lines.filter(l => +l.dataset.at <= t + 0.05).pop() || null;
    if (line === current) return;
    if (current) current.removeAttribute('aria-current');
    current = line;
    if (!line) return;
    line.setAttribute('aria-current', 'true');
    // keep the current line in view inside the transcript box, without scrolling the page
    const top = line.offsetTop, bottom = top + line.offsetHeight;
    if (top < box.scrollTop || bottom > box.scrollTop + box.clientHeight)
      box.scrollTo({ top: top - box.clientHeight / 3, behavior: reduceMotion.matches ? 'auto' : 'smooth' });
  };

  player.onTime(update);
  lines.forEach(l => l.addEventListener('click', () => seekTo(+l.dataset.at)));
  if (chapterTrack) {
    chapterTrack.track.mode = 'hidden'; // loads the cues without showing them
    chapterTrack.addEventListener('load', build);
    if (chapterTrack.readyState === 2) build();
  }
  update(0);
});
})();

About this set

Two players for long-form video where people want to skip to the part they need. The chapters player reads a WebVTT file loaded through a track element with kind set to chapters. The script turns each cue into a chapter card under the video with its start time, title and a small progress bar, draws a gap on the seek slider at every chapter boundary, and shows the current chapter’s name in the control bar. Clicking a card seeks to that chapter and starts playback, and the current card carries aria-current. The interactive transcript player places the video beside a scrolling transcript panel; on narrow screens the panel moves below the video. Every transcript line is a button with its timestamp and a data-at attribute in seconds, so the transcript is plain HTML that search engines and screen readers can read without the video. Clicking a line seeks there, the line that matches the playback position is highlighted with aria-current, and the panel scrolls itself to keep that line in view without moving the page. Lines include sound descriptions in brackets, which makes the transcript a text alternative for the whole clip.

What’s included

  • Chapter cards and seek bar gaps built from a WebVTT chapters track
  • Current chapter name in the control bar and aria-current on its card
  • Transcript lines are buttons that seek to their timestamp
  • Current line highlighted and kept in view inside the panel only
  • Transcript is plain HTML, readable without JavaScript

Accessibility

Chapter cards and transcript lines are buttons with their time and text as the accessible name, and the current one has aria-current. The seek slider announces text such as 0:05 of 0:20. Auto-scrolling of the transcript is instant instead of smooth under prefers-reduced-motion, and every control shows a visible focus outline.

Customise it

Change --vc-accent and --vc-deep on .vc for the seek fill, chapter progress and highlights, and the max-height of .vc-lines for the transcript height. Bootstrap uses .list-group variables for the transcript; in Tailwind edit the colour values in the aria-[current=true] classes.