Open demo

CSS code

HTML
<div><span class="lbl">Episode player with chapters</span>
<div class="pc pc-card" data-pc data-length="30" role="group" aria-label="Field Notes FM, episode 42">
  <audio src="../audio/track-3.mp3" preload="metadata"></audio>
  <div class="pc-top">
    <div class="pc-art" style="--art:radial-gradient(circle at 50% 50%,#ecfeff 0 9%,transparent 9.5%),repeating-radial-gradient(circle at 50% 50%,#0f766e 0 8%,#115e59 8% 16%)" aria-hidden="true"></div>
    <div><p class="pc-show">Field Notes FM · Episode 42</p><p class="pc-title">The Sound of Empty Stations</p><p class="pc-hosts">With Ines Okafor and Theo Brandt</p></div>
  </div>
  <div class="pc-bar">
    <span class="pc-time" data-cur>0:00</span>
    <input type="range" data-seek min="0" max="30" step="any" value="0" aria-label="Seek episode" aria-valuetext="0:00 of 0:30">
    <span class="pc-time" data-dur>0:30</span>
  </div>
  <div class="pc-ctrls">
    <button type="button" class="pc-speed" data-speed aria-label="Playback speed 1×">1×</button>
    <div class="pc-mid">
      <button type="button" class="pc-skip" data-skip="-15" aria-label="Back 15 seconds"><svg viewBox="0 0 24 24" aria-hidden="true" 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">15</text></svg></button>
      <button type="button" class="pc-play" data-play="episode" aria-label="Play episode"><svg class="i-play" viewBox="0 0 24 24" aria-hidden="true" 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" 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="pc-skip" data-skip="30" aria-label="Forward 30 seconds"><svg viewBox="0 0 24 24" aria-hidden="true" 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">30</text></svg></button>
    </div>
    <span class="pc-time" data-left>0:30 left</span>
  </div>
  <div class="pc-chap">
    <p class="pc-chap-h">Chapters</p>
    <ol>
      <li><button type="button" class="pc-ch" data-at="0" aria-current="true"><span class="pc-time">0:00</span>Cold open</button></li>
      <li><button type="button" class="pc-ch" data-at="7.5"><span class="pc-time">0:07</span>Why stations hum at night</button></li>
      <li><button type="button" class="pc-ch" data-at="15"><span class="pc-time">0:15</span>The last bus driver</button></li>
      <li><button type="button" class="pc-ch" data-at="22.5"><span class="pc-time">0:22</span>Listener letters</button></li>
    </ol>
  </div>
</div></div>

<div><span class="lbl">Episode list rows</span>
<ul class="pc-list">
  <li><div class="pc pc-row" data-pc data-length="24" role="group" aria-label="Episode 41">
    <audio src="../audio/track-1.mp3" preload="metadata"></audio>
    <button type="button" class="pc-play" data-play="episode 41" aria-label="Play episode 41"><svg class="i-play" viewBox="0 0 24 24" aria-hidden="true" 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" 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>
    <div class="pc-txt">
      <p class="pc-ep">Ep. 41 · Night Shift at the Lighthouse</p>
      <p class="pc-sub">Field Notes FM · <span data-left>0:24 left</span></p>
      <div class="pc-prog" aria-hidden="true"><i></i></div>
    </div>
    <div class="pc-skips">
      <button type="button" class="pc-skip" data-skip="-15" aria-label="Back 15 seconds"><svg viewBox="0 0 24 24" aria-hidden="true" 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">15</text></svg></button>
      <button type="button" class="pc-skip" data-skip="30" aria-label="Forward 30 seconds"><svg viewBox="0 0 24 24" aria-hidden="true" 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">30</text></svg></button>
    </div>
  </div></li>
  <li><div class="pc pc-row" data-pc data-length="29" role="group" aria-label="Episode 40">
    <audio src="../audio/track-4.mp3" preload="metadata"></audio>
    <button type="button" class="pc-play" data-play="episode 40" aria-label="Play episode 40"><svg class="i-play" viewBox="0 0 24 24" aria-hidden="true" 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" 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>
    <div class="pc-txt">
      <p class="pc-ep">Ep. 40 · A Map of Copper Roofs</p>
      <p class="pc-sub">Field Notes FM · <span data-left>0:29 left</span></p>
      <div class="pc-prog" aria-hidden="true"><i></i></div>
    </div>
    <div class="pc-skips">
      <button type="button" class="pc-skip" data-skip="-15" aria-label="Back 15 seconds"><svg viewBox="0 0 24 24" aria-hidden="true" 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">15</text></svg></button>
      <button type="button" class="pc-skip" data-skip="30" aria-label="Forward 30 seconds"><svg viewBox="0 0 24 24" aria-hidden="true" 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">30</text></svg></button>
    </div>
  </div></li>
</ul></div>
CSS
/* Podcast players: skip back 15 s, forward 30 s, a speed button that cycles 1x to 2x and chapters that seek.
   The script sets --p (0 to 1) on .pc, toggles [data-playing] and marks the current chapter with aria-current. */
.pc{--pc-accent:#0f766e;--pc-soft:#f0fdfa;--pc-now:#ccfbf1;--pc-ink:#0f172a;--pc-muted:#475569;--pc-track:#7f9493;--p:0;color:var(--pc-ink)}
.pc button{flex:none;display:grid;place-items:center;border:0;background:none;color:inherit;cursor:pointer;font:inherit}
.pc button:focus-visible{outline:2px solid var(--pc-accent);outline-offset:2px}
.pc svg{display:block;width:22px;height:22px}
.pc .pc-play{width:56px;height:56px;border-radius:50%;background:var(--pc-accent);color:#fff;box-shadow:0 6px 16px rgba(15,118,110,.3)}
.pc .pc-play svg{width:24px;height:24px}
.pc .i-pause,.pc[data-playing] .i-play{display:none}
.pc[data-playing] .i-pause{display:block}
.pc .pc-skip{width:44px;height:44px;border-radius:50%;color:var(--pc-ink)}
.pc .pc-skip svg{width:28px;height:28px}
.pc .pc-skip:hover{background:var(--pc-soft)}
.pc .pc-speed{min-width:58px;height:34px;padding:0 12px;border:1.5px solid var(--pc-track);border-radius:999px;font-size:.85rem;font-weight:700;font-variant-numeric:tabular-nums}
.pc .pc-speed:hover{border-color:var(--pc-accent);color:var(--pc-accent)}
.pc-time{font-size:.8rem;color:var(--pc-muted);font-variant-numeric:tabular-nums;white-space:nowrap}
.pc input[type="range"]{-webkit-appearance:none;appearance:none;display:block;flex:1;min-width:0;height:16px;margin:0;background:none;cursor:pointer}
.pc input[type="range"]:focus-visible{outline:none}
.pc input[type="range"]::-webkit-slider-runnable-track{height:6px;border-radius:999px;
  background:linear-gradient(to right,var(--pc-accent) 0 calc(8px + (100% - 16px) * var(--p)),var(--pc-track) 0)}
.pc input[type="range"]::-moz-range-track{height:6px;border-radius:999px;
  background:linear-gradient(to right,var(--pc-accent) 0 calc(8px + (100% - 16px) * var(--p)),var(--pc-track) 0)}
.pc input[type="range"]::-webkit-slider-thumb{-webkit-appearance:none;appearance:none;width:16px;height:16px;margin-top:-5px;border:0;border-radius:50%;
  background:var(--pc-accent);box-shadow:0 0 0 3px #fff}
.pc input[type="range"]::-moz-range-thumb{width:16px;height:16px;border:0;border-radius:50%;background:var(--pc-accent);box-shadow:0 0 0 3px #fff}
.pc input[type="range"]:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px #fff,0 0 0 5px var(--pc-accent)}
.pc input[type="range"]:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px #fff,0 0 0 5px var(--pc-accent)}
/* 1. episode player with chapters */
.pc-card{background:#fff;border-radius:24px;padding:24px;box-shadow:0 1px 2px rgba(15,23,42,.05),0 14px 34px rgba(15,118,110,.1)}
.pc-top{display:flex;gap:16px;align-items:center}
.pc-art{flex:none;width:104px;aspect-ratio:1;border-radius:18px;background:var(--art)}
.pc-show{font-size:.75rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--pc-accent)}
.pc-title{font-size:1.25rem;font-weight:800;letter-spacing:-.015em;margin-top:4px;line-height:1.25}
.pc-hosts{font-size:.88rem;color:var(--pc-muted);margin-top:4px}
.pc-bar{display:flex;align-items:center;gap:16px;margin-top:22px}
.pc-ctrls{display:grid;grid-template-columns:1fr auto 1fr;align-items:center;margin-top:14px}
.pc-mid{display:flex;align-items:center;gap:16px}
.pc-ctrls .pc-time{justify-self:end}
.pc-ctrls .pc-speed{justify-self:start}
.pc-chap{margin-top:22px;border-top:1px solid #e2e8f0;padding-top:14px}
.pc-chap-h{font-size:.75rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--pc-muted);margin-bottom:6px}
.pc-chap ol{list-style:none}
.pc .pc-ch{width:100%;display:flex;justify-content:flex-start;gap:14px;padding:9px 12px;border-radius:12px;text-align:left;font-size:.92rem}
.pc .pc-ch:hover{background:var(--pc-soft)}
.pc .pc-ch[aria-current="true"]{background:var(--pc-now);color:#115e59;font-weight:600}
.pc .pc-ch:focus-visible{outline-offset:-2px}
.pc-ch .pc-time{min-width:2.4em;color:inherit}
/* 2. episode list rows */
.pc-list{list-style:none;background:#fff;border-radius:20px;box-shadow:0 1px 2px rgba(15,23,42,.05),0 14px 34px rgba(15,118,110,.1);overflow:hidden}
.pc-row{display:flex;align-items:center;gap:14px;padding:16px 18px}
.pc-list li + li{border-top:1px solid #e2e8f0}
.pc-skips{display:flex}
.pc-row .pc-play{width:44px;height:44px;box-shadow:none}
.pc-row .pc-play svg{width:20px;height:20px}
.pc-row .pc-skip{width:38px;height:38px}
.pc-row .pc-skip svg{width:24px;height:24px}
.pc-txt{flex:1;min-width:0}
.pc-ep{font-weight:700;font-size:.95rem;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.pc-sub{font-size:.8rem;color:var(--pc-muted);margin-top:3px}
.pc-prog{height:4px;margin-top:9px;border-radius:999px;background:#d5e3e1;overflow:hidden}
.pc-prog i{display:block;height:100%;width:calc(var(--p) * 100%);background:var(--pc-accent)}
@media (max-width:520px){
  .pc-card{padding:18px}
  .pc-art{width:72px;border-radius:14px}
  .pc-title{font-size:1.08rem}
  .pc-ctrls{grid-template-columns:auto 1fr;row-gap:12px}
  .pc-mid{grid-column:1 / -1;grid-row:1;justify-content:center}
  .pc-row{gap:10px;padding:14px}
}
JavaScript
// Podcast players. Each [data-pc] wraps one <audio>. Besides play and seek it handles skip buttons
// (data-skip="-15" / "30"), a speed button that cycles 1x to 2x, and chapter buttons (data-at="seconds").
(() => {
  const fmt = s => { s = Math.floor(s || 0); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
  const rates = [1, 1.25, 1.5, 2];
  document.querySelectorAll('[data-pc]').forEach(root => {
    const $ = s => root.querySelector(s);
    const audio = $('audio'), play = $('[data-play]'), seek = $('[data-seek]'), cur = $('[data-cur]'), dur = $('[data-dur]');
    const left = $('[data-left]'), speed = $('[data-speed]'), chapters = [...root.querySelectorAll('[data-at]')], name = play.dataset.play;
    let raf = 0;
    const length = () => audio.duration || +root.dataset.length;
    const sync = () => {
      const t = audio.currentTime, d = length(), text = fmt(t) + ' of ' + fmt(d);
      root.style.setProperty('--p', d ? t / d : 0);
      if (cur) cur.textContent = fmt(t);
      if (left) left.textContent = fmt(d - t) + ' left';
      if (seek) {
        seek.value = t;
        if (seek.getAttribute('aria-valuetext') !== text) seek.setAttribute('aria-valuetext', text);
      }
      // the chapter that contains the current time is marked with aria-current
      const now = chapters.filter(c => +c.dataset.at <= t + 0.05).pop();
      chapters.forEach(c => { if (c === now) c.setAttribute('aria-current', 'true'); else c.removeAttribute('aria-current'); });
    };
    const tick = () => { sync(); raf = requestAnimationFrame(tick); };
    const meta = () => { if (seek) seek.max = audio.duration; if (dur) dur.textContent = fmt(audio.duration); sync(); };
    const jump = t => { audio.currentTime = Math.min(Math.max(t, 0), length()); sync(); };
    const start = () => {
      document.querySelectorAll('audio').forEach(a => { if (a !== audio) a.pause(); });
      audio.play().catch(() => {});
    };
    play.addEventListener('click', () => (audio.paused ? start() : audio.pause()));
    audio.addEventListener('play', () => { root.setAttribute('data-playing', ''); play.setAttribute('aria-label', 'Pause ' + name); tick(); });
    audio.addEventListener('pause', () => { root.removeAttribute('data-playing'); play.setAttribute('aria-label', 'Play ' + name); cancelAnimationFrame(raf); sync(); });
    audio.addEventListener('loadedmetadata', meta);
    if (audio.readyState > 0) meta();
    root.querySelectorAll('[data-skip]').forEach(b => b.addEventListener('click', () => jump(audio.currentTime + +b.dataset.skip)));
    chapters.forEach(c => c.addEventListener('click', () => { jump(+c.dataset.at); start(); }));
    if (speed) speed.addEventListener('click', () => {
      const rate = rates[(rates.indexOf(audio.playbackRate) + 1) % rates.length];
      audio.playbackRate = audio.defaultPlaybackRate = rate;
      speed.textContent = rate + '×';
      speed.setAttribute('aria-label', 'Playback speed ' + rate + '×');
    });
    if (seek) {
      seek.addEventListener('input', () => jump(+seek.value));
      // Arrow keys jump 5 seconds; Home and End work natively.
      seek.addEventListener('keydown', e => {
        const step = { ArrowLeft: -5, ArrowDown: -5, ArrowRight: 5, ArrowUp: 5 }[e.key];
        if (!step) return;
        e.preventDefault();
        jump(audio.currentTime + step);
      });
    }
  });
})();

Bootstrap 5 code

Requires: Bootstrap 5.3.8 CSS

HTML
<div><span class="lbl">Episode player with chapters</span>
<div class="card pc pc-card" data-pc data-length="30" role="group" aria-label="Field Notes FM, episode 42">
  <div class="card-body">
  <audio src="../audio/track-3.mp3" preload="metadata"></audio>
  <div class="d-flex align-items-center gap-3">
    <div class="pc-art" style="--art:radial-gradient(circle at 50% 50%,#ecfeff 0 9%,transparent 9.5%),repeating-radial-gradient(circle at 50% 50%,#0f766e 0 8%,#115e59 8% 16%)" aria-hidden="true"></div>
    <div><p class="pc-show mb-0">Field Notes FM · Episode 42</p><p class="pc-title mb-0">The Sound of Empty Stations</p><p class="pc-hosts mb-0">With Ines Okafor and Theo Brandt</p></div>
  </div>
  <div class="d-flex align-items-center gap-3 pc-bar">
    <span class="pc-time" data-cur>0:00</span>
    <input type="range" class="form-range" data-seek min="0" max="30" step="any" value="0" aria-label="Seek episode" aria-valuetext="0:00 of 0:30">
    <span class="pc-time" data-dur>0:30</span>
  </div>
  <div class="pc-ctrls">
    <button type="button" class="btn pc-speed rounded-pill" data-speed aria-label="Playback speed 1×">1×</button>
    <div class="pc-mid d-flex align-items-center gap-3">
      <button type="button" class="btn pc-skip" data-skip="-15" aria-label="Back 15 seconds"><svg viewBox="0 0 24 24" aria-hidden="true" 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">15</text></svg></button>
      <button type="button" class="btn pc-play rounded-circle" data-play="episode" aria-label="Play episode"><svg class="i-play" viewBox="0 0 24 24" aria-hidden="true" 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" 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 pc-skip" data-skip="30" aria-label="Forward 30 seconds"><svg viewBox="0 0 24 24" aria-hidden="true" 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">30</text></svg></button>
    </div>
    <span class="pc-time" data-left>0:30 left</span>
  </div>
  <div class="pc-chap">
    <p class="pc-chap-h">Chapters</p>
    <ol class="list-group">
      <li class="list-group-item"><button type="button" class="list-group-item-action pc-ch" data-at="0" aria-current="true"><span class="pc-time">0:00</span>Cold open</button></li>
      <li class="list-group-item"><button type="button" class="list-group-item-action pc-ch" data-at="7.5"><span class="pc-time">0:07</span>Why stations hum at night</button></li>
      <li class="list-group-item"><button type="button" class="list-group-item-action pc-ch" data-at="15"><span class="pc-time">0:15</span>The last bus driver</button></li>
      <li class="list-group-item"><button type="button" class="list-group-item-action pc-ch" data-at="22.5"><span class="pc-time">0:22</span>Listener letters</button></li>
    </ol>
  </div>
  </div>
</div></div>

<div><span class="lbl">Episode list rows</span>
<ul class="list-group pc-list">
  <li class="list-group-item"><div class="pc pc-row" data-pc data-length="24" role="group" aria-label="Episode 41">
    <audio src="../audio/track-1.mp3" preload="metadata"></audio>
    <button type="button" class="btn pc-play rounded-circle" data-play="episode 41" aria-label="Play episode 41"><svg class="i-play" viewBox="0 0 24 24" aria-hidden="true" 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" 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>
    <div class="flex-grow-1 text-truncate">
      <p class="pc-ep mb-0 text-truncate">Ep. 41 · Night Shift at the Lighthouse</p>
      <p class="pc-sub mb-0">Field Notes FM · <span data-left>0:24 left</span></p>
      <div class="progress" aria-hidden="true"><div class="progress-bar"></div></div>
    </div>
    <div class="btn-group" role="group" aria-label="Skip">
      <button type="button" class="btn pc-skip" data-skip="-15" aria-label="Back 15 seconds"><svg viewBox="0 0 24 24" aria-hidden="true" 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">15</text></svg></button>
      <button type="button" class="btn pc-skip" data-skip="30" aria-label="Forward 30 seconds"><svg viewBox="0 0 24 24" aria-hidden="true" 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">30</text></svg></button>
    </div>
  </div></li>
  <li class="list-group-item"><div class="pc pc-row" data-pc data-length="29" role="group" aria-label="Episode 40">
    <audio src="../audio/track-4.mp3" preload="metadata"></audio>
    <button type="button" class="btn pc-play rounded-circle" data-play="episode 40" aria-label="Play episode 40"><svg class="i-play" viewBox="0 0 24 24" aria-hidden="true" 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" 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>
    <div class="flex-grow-1 text-truncate">
      <p class="pc-ep mb-0 text-truncate">Ep. 40 · A Map of Copper Roofs</p>
      <p class="pc-sub mb-0">Field Notes FM · <span data-left>0:29 left</span></p>
      <div class="progress" aria-hidden="true"><div class="progress-bar"></div></div>
    </div>
    <div class="btn-group" role="group" aria-label="Skip">
      <button type="button" class="btn pc-skip" data-skip="-15" aria-label="Back 15 seconds"><svg viewBox="0 0 24 24" aria-hidden="true" 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">15</text></svg></button>
      <button type="button" class="btn pc-skip" data-skip="30" aria-label="Forward 30 seconds"><svg viewBox="0 0 24 24" aria-hidden="true" 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">30</text></svg></button>
    </div>
  </div></li>
</ul></div>
CSS
/* Podcast players on Bootstrap: .card, .btn and .btn-group controls, .form-range, a .list-group of chapters and .progress.
   The script sets --p (0 to 1) on .pc, toggles [data-playing] and marks the current chapter with aria-current. */
.pc{--pc-accent:#0f766e;--pc-soft:#f0fdfa;--pc-now:#ccfbf1;--pc-ink:#0f172a;--pc-muted:#475569;--pc-track:#7f9493;--p:0;color:var(--pc-ink)}
.pc .btn{--bs-btn-padding-x:0;--bs-btn-padding-y:0;--bs-btn-border-width:0;flex:none;display:grid;place-items:center}
.pc .btn:focus-visible{outline:2px solid var(--pc-accent);outline-offset:2px;box-shadow:none}
.pc svg{display:block;width:22px;height:22px}
.pc .pc-play{--bs-btn-bg:var(--pc-accent);--bs-btn-color:#fff;--bs-btn-hover-bg:#115e59;--bs-btn-hover-color:#fff;
  --bs-btn-active-bg:#134e4a;--bs-btn-active-color:#fff;width:56px;height:56px;box-shadow:0 6px 16px rgba(15,118,110,.3)}
.pc .pc-play svg{width:24px;height:24px}
.pc .i-pause,.pc[data-playing] .i-play{display:none}
.pc[data-playing] .i-pause{display:block}
.pc .pc-skip{--bs-btn-color:var(--pc-ink);--bs-btn-hover-bg:var(--pc-soft);--bs-btn-hover-color:var(--pc-ink);--bs-btn-active-bg:var(--pc-now);
  --bs-btn-active-color:var(--pc-ink);width:44px;height:44px;border-radius:50%}
.pc .btn-group > .btn.pc-skip{border-radius:50%}
.pc .pc-skip svg{width:28px;height:28px}
.pc .pc-speed{--bs-btn-padding-x:12px;--bs-btn-border-width:1.5px;--bs-btn-border-color:var(--pc-track);--bs-btn-color:var(--pc-ink);
  --bs-btn-hover-bg:transparent;--bs-btn-hover-border-color:var(--pc-accent);--bs-btn-hover-color:var(--pc-accent);
  --bs-btn-active-bg:transparent;--bs-btn-active-border-color:var(--pc-accent);--bs-btn-active-color:var(--pc-accent);
  --bs-btn-font-size:.85rem;--bs-btn-font-weight:700;min-width:58px;height:34px;justify-self:start;font-variant-numeric:tabular-nums}
.pc-time{font-size:.8rem;color:var(--pc-muted);font-variant-numeric:tabular-nums;white-space:nowrap}
.pc .form-range{flex:1;min-width:0;width:auto;height:16px}
.pc .form-range::-webkit-slider-runnable-track{height:6px;border-radius:999px;
  background:linear-gradient(to right,var(--pc-accent) 0 calc(8px + (100% - 16px) * var(--p)),var(--pc-track) 0)}
.pc .form-range::-moz-range-track{height:6px;border-radius:999px;
  background:linear-gradient(to right,var(--pc-accent) 0 calc(8px + (100% - 16px) * var(--p)),var(--pc-track) 0)}
.pc .form-range::-webkit-slider-thumb{width:16px;height:16px;margin-top:-5px;background:var(--pc-accent);box-shadow:0 0 0 3px #fff}
.pc .form-range::-moz-range-thumb{width:16px;height:16px;background:var(--pc-accent);box-shadow:0 0 0 3px #fff}
.pc .form-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 3px #fff}
.pc .form-range:focus::-moz-range-thumb{box-shadow:0 0 0 3px #fff}
.pc .form-range:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 3px #fff,0 0 0 5px var(--pc-accent)}
.pc .form-range:focus-visible::-moz-range-thumb{box-shadow:0 0 0 3px #fff,0 0 0 5px var(--pc-accent)}
/* 1. episode player with chapters */
.pc-card{--bs-card-border-width:0;--bs-card-border-radius:24px;--bs-card-spacer-x:24px;--bs-card-spacer-y:24px;
  box-shadow:0 1px 2px rgba(15,23,42,.05),0 14px 34px rgba(15,118,110,.1)}
.pc-art{flex:none;width:104px;aspect-ratio:1;border-radius:18px;background:var(--art)}
.pc-show{font-size:.75rem;font-weight:700;letter-spacing:.08em;text-transform:uppercase;color:var(--pc-accent)}
.pc-title{font-size:1.25rem;font-weight:800;letter-spacing:-.015em;margin-top:4px;line-height:1.25}
.pc-hosts{font-size:.88rem;color:var(--pc-muted);margin-top:4px}
.pc-bar{margin-top:22px}
.pc-ctrls{display:grid;grid-template-columns:1fr auto 1fr;align-items:center;margin-top:14px}
.pc-ctrls .pc-time{justify-self:end}
.pc-chap{margin-top:22px;border-top:1px solid #e2e8f0;padding-top:14px}
.pc-chap-h{font-size:.75rem;font-weight:700;letter-spacing:.1em;text-transform:uppercase;color:var(--pc-muted);margin-bottom:6px}
.pc .list-group{--bs-list-group-border-width:0;--bs-list-group-bg:transparent;--bs-list-group-color:var(--pc-ink);--bs-list-group-action-color:var(--pc-ink);
  --bs-list-group-action-hover-bg:var(--pc-soft);--bs-list-group-action-hover-color:var(--pc-ink);--bs-list-group-action-active-bg:var(--pc-now);
  --bs-list-group-item-padding-x:0;--bs-list-group-item-padding-y:0}
.pc .pc-ch{display:flex;gap:14px;padding:9px 12px;border:0;border-radius:12px;background:none;font-size:.92rem}
.pc .pc-ch:hover{background:var(--pc-soft)}
.pc .pc-ch[aria-current="true"]{background:var(--pc-now);color:#115e59;font-weight:600}
.pc .pc-ch:focus-visible{outline:2px solid var(--pc-accent);outline-offset:-2px}
.pc-ch .pc-time{min-width:2.4em;color:inherit}
/* 2. episode list rows */
.pc-list{--bs-list-group-border-color:#e2e8f0;--bs-list-group-border-radius:20px;--bs-list-group-item-padding-x:18px;--bs-list-group-item-padding-y:16px;
  box-shadow:0 1px 2px rgba(15,23,42,.05),0 14px 34px rgba(15,118,110,.1)}
.pc-list > .list-group-item{border-left:0;border-right:0}
.pc-list > .list-group-item:first-child{border-top:0}
.pc-list > .list-group-item:last-child{border-bottom:0}
.pc-row{display:flex;align-items:center;gap:14px}
.pc-row .pc-play{width:44px;height:44px;box-shadow:none}
.pc-row .pc-play svg{width:20px;height:20px}
.pc-row .pc-skip{width:38px;height:38px}
.pc-row .pc-skip svg{width:24px;height:24px}
.pc-ep{font-weight:700;font-size:.95rem}
.pc-sub{font-size:.8rem;color:var(--pc-muted);margin-top:3px}
.pc-row .progress{--bs-progress-height:4px;--bs-progress-bg:#d5e3e1;--bs-progress-bar-bg:var(--pc-accent);--bs-progress-bar-transition:none;margin-top:9px}
.pc-row .progress-bar{width:calc(var(--p) * 100%)}
@media (prefers-reduced-motion:reduce){.pc .btn{transition:none}}
@media (max-width:520px){
  .pc-card{--bs-card-spacer-x:18px;--bs-card-spacer-y:18px}
  .pc-art{width:72px;border-radius:14px}
  .pc-title{font-size:1.08rem}
  .pc-ctrls{grid-template-columns:auto 1fr;row-gap:12px}
  .pc-mid{grid-column:1 / -1;grid-row:1;justify-content:center}
  .pc-list{--bs-list-group-item-padding-x:14px;--bs-list-group-item-padding-y:14px}
  .pc-row{gap:10px}
}
JavaScript
// Podcast players. Each [data-pc] wraps one <audio>. Besides play and seek it handles skip buttons
// (data-skip="-15" / "30"), a speed button that cycles 1x to 2x, and chapter buttons (data-at="seconds").
(() => {
  const fmt = s => { s = Math.floor(s || 0); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
  const rates = [1, 1.25, 1.5, 2];
  document.querySelectorAll('[data-pc]').forEach(root => {
    const $ = s => root.querySelector(s);
    const audio = $('audio'), play = $('[data-play]'), seek = $('[data-seek]'), cur = $('[data-cur]'), dur = $('[data-dur]');
    const left = $('[data-left]'), speed = $('[data-speed]'), chapters = [...root.querySelectorAll('[data-at]')], name = play.dataset.play;
    let raf = 0;
    const length = () => audio.duration || +root.dataset.length;
    const sync = () => {
      const t = audio.currentTime, d = length(), text = fmt(t) + ' of ' + fmt(d);
      root.style.setProperty('--p', d ? t / d : 0);
      if (cur) cur.textContent = fmt(t);
      if (left) left.textContent = fmt(d - t) + ' left';
      if (seek) {
        seek.value = t;
        if (seek.getAttribute('aria-valuetext') !== text) seek.setAttribute('aria-valuetext', text);
      }
      // the chapter that contains the current time is marked with aria-current
      const now = chapters.filter(c => +c.dataset.at <= t + 0.05).pop();
      chapters.forEach(c => { if (c === now) c.setAttribute('aria-current', 'true'); else c.removeAttribute('aria-current'); });
    };
    const tick = () => { sync(); raf = requestAnimationFrame(tick); };
    const meta = () => { if (seek) seek.max = audio.duration; if (dur) dur.textContent = fmt(audio.duration); sync(); };
    const jump = t => { audio.currentTime = Math.min(Math.max(t, 0), length()); sync(); };
    const start = () => {
      document.querySelectorAll('audio').forEach(a => { if (a !== audio) a.pause(); });
      audio.play().catch(() => {});
    };
    play.addEventListener('click', () => (audio.paused ? start() : audio.pause()));
    audio.addEventListener('play', () => { root.setAttribute('data-playing', ''); play.setAttribute('aria-label', 'Pause ' + name); tick(); });
    audio.addEventListener('pause', () => { root.removeAttribute('data-playing'); play.setAttribute('aria-label', 'Play ' + name); cancelAnimationFrame(raf); sync(); });
    audio.addEventListener('loadedmetadata', meta);
    if (audio.readyState > 0) meta();
    root.querySelectorAll('[data-skip]').forEach(b => b.addEventListener('click', () => jump(audio.currentTime + +b.dataset.skip)));
    chapters.forEach(c => c.addEventListener('click', () => { jump(+c.dataset.at); start(); }));
    if (speed) speed.addEventListener('click', () => {
      const rate = rates[(rates.indexOf(audio.playbackRate) + 1) % rates.length];
      audio.playbackRate = audio.defaultPlaybackRate = rate;
      speed.textContent = rate + '×';
      speed.setAttribute('aria-label', 'Playback speed ' + rate + '×');
    });
    if (seek) {
      seek.addEventListener('input', () => jump(+seek.value));
      // Arrow keys jump 5 seconds; Home and End work natively.
      seek.addEventListener('keydown', e => {
        const step = { ArrowLeft: -5, ArrowDown: -5, ArrowRight: 5, ArrowUp: 5 }[e.key];
        if (!step) return;
        e.preventDefault();
        jump(audio.currentTime + step);
      });
    }
  });
})();

Tailwind 4 code

Requires: Tailwind CSS 4

HTML
<div><span class="mb-3.5 block text-[.72rem] tracking-[.12em] text-[#5f6f6e] uppercase">Episode player with chapters</span>
<div class="group rounded-3xl bg-white p-6 text-[#0f172a] shadow-[0_1px_2px_rgba(15,23,42,.05),0_14px_34px_rgba(15,118,110,.1)] [--p:0] max-[520px]:p-[18px]" data-pc data-length="30" role="group" aria-label="Field Notes FM, episode 42">
  <audio src="../audio/track-3.mp3" preload="metadata"></audio>
  <div class="flex items-center gap-4">
    <div class="aspect-square w-[104px] flex-none rounded-[18px] [background:radial-gradient(circle_at_50%_50%,#ecfeff_0_9%,transparent_9.5%),repeating-radial-gradient(circle_at_50%_50%,#0f766e_0_8%,#115e59_8%_16%)] max-[520px]:w-[72px] max-[520px]:rounded-[14px]" aria-hidden="true"></div>
    <div><p class="text-[.75rem] font-bold tracking-[.08em] text-pc-accent uppercase">Field Notes FM · Episode 42</p><p class="mt-1 text-[1.25rem] leading-[1.25] font-extrabold tracking-[-.015em] max-[520px]:text-[1.08rem]">The Sound of Empty Stations</p><p class="mt-1 text-[.88rem] text-pc-muted">With Ines Okafor and Theo Brandt</p></div>
  </div>
  <div class="mt-[22px] flex items-center gap-4">
    <span class="text-[.8rem] whitespace-nowrap text-pc-muted tabular-nums" data-cur>0:00</span>
    <input type="range" class="flex-1 block h-[16px] min-w-0 cursor-pointer appearance-none bg-transparent [--f:var(--p)] focus-visible:outline-none [&::-webkit-slider-runnable-track]:h-[6px] [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-[linear-gradient(to_right,var(--color-pc-accent)_0_calc(8px_+_(100%_-_16px)_*_var(--f)),var(--color-pc-track)_0)] [&::-moz-range-track]:h-[6px] [&::-moz-range-track]:rounded-full [&::-moz-range-track]:bg-[linear-gradient(to_right,var(--color-pc-accent)_0_calc(8px_+_(100%_-_16px)_*_var(--f)),var(--color-pc-track)_0)] [&::-webkit-slider-thumb]:-mt-[5px] [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-[16px] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-[color:var(--color-pc-accent)] [&::-webkit-slider-thumb]:border-0 [&::-webkit-slider-thumb]:shadow-[0_0_0_3px_#fff] focus-visible:[&::-webkit-slider-thumb]:shadow-[0_0_0_3px_#fff,0_0_0_5px_var(--color-pc-accent)] [&::-moz-range-thumb]:size-[16px] [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-[color:var(--color-pc-accent)] [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:shadow-[0_0_0_3px_#fff] focus-visible:[&::-moz-range-thumb]:shadow-[0_0_0_3px_#fff,0_0_0_5px_var(--color-pc-accent)]" data-seek min="0" max="30" step="any" value="0" aria-label="Seek episode" aria-valuetext="0:00 of 0:30">
    <span class="text-[.8rem] whitespace-nowrap text-pc-muted tabular-nums" data-dur>0:30</span>
  </div>
  <div class="mt-3.5 grid grid-cols-[1fr_auto_1fr] items-center max-[520px]:grid-cols-[auto_1fr] max-[520px]:gap-y-3">
    <button type="button" class="h-[34px] min-w-[58px] cursor-pointer justify-self-start rounded-full border-[1.5px] border-pc-track px-3 text-[.85rem] font-bold tabular-nums hover:border-pc-accent hover:text-pc-accent focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-pc-accent" data-speed aria-label="Playback speed 1×">1×</button>
    <div class="flex items-center gap-4 max-[520px]:col-span-full max-[520px]:row-start-1 max-[520px]:justify-center">
      <button type="button" class="grid flex-none cursor-pointer place-items-center rounded-full text-[#0f172a] hover:bg-pc-soft focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-pc-accent size-11" data-skip="-15" aria-label="Back 15 seconds"><svg class="block size-7" viewBox="0 0 24 24" aria-hidden="true" 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">15</text></svg></button>
      <button type="button" class="grid flex-none cursor-pointer place-items-center rounded-full bg-pc-accent text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-pc-accent size-14 shadow-[0_6px_16px_rgba(15,118,110,.3)]" data-play="episode" aria-label="Play episode"><svg class="block size-6 group-data-playing:hidden" viewBox="0 0 24 24" aria-hidden="true" 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-6 group-data-playing:block" viewBox="0 0 24 24" aria-hidden="true" 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 flex-none cursor-pointer place-items-center rounded-full text-[#0f172a] hover:bg-pc-soft focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-pc-accent size-11" data-skip="30" aria-label="Forward 30 seconds"><svg class="block size-7" viewBox="0 0 24 24" aria-hidden="true" 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">30</text></svg></button>
    </div>
    <span class="text-[.8rem] whitespace-nowrap text-pc-muted tabular-nums justify-self-end" data-left>0:30 left</span>
  </div>
  <div class="mt-[22px] border-t border-[#e2e8f0] pt-3.5">
    <p class="mb-1.5 text-[.75rem] font-bold tracking-[.1em] text-pc-muted uppercase">Chapters</p>
    <ol>
      <li><button type="button" class="flex w-full cursor-pointer gap-3.5 rounded-xl px-3 py-[9px] text-left text-[.92rem] hover:bg-pc-soft focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-pc-accent aria-[current=true]:bg-pc-now aria-[current=true]:font-semibold aria-[current=true]:text-[#115e59]" data-at="0" aria-current="true"><span class="min-w-[2.4em] text-[.8rem] tabular-nums">0:00</span>Cold open</button></li>
      <li><button type="button" class="flex w-full cursor-pointer gap-3.5 rounded-xl px-3 py-[9px] text-left text-[.92rem] hover:bg-pc-soft focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-pc-accent aria-[current=true]:bg-pc-now aria-[current=true]:font-semibold aria-[current=true]:text-[#115e59]" data-at="7.5"><span class="min-w-[2.4em] text-[.8rem] tabular-nums">0:07</span>Why stations hum at night</button></li>
      <li><button type="button" class="flex w-full cursor-pointer gap-3.5 rounded-xl px-3 py-[9px] text-left text-[.92rem] hover:bg-pc-soft focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-pc-accent aria-[current=true]:bg-pc-now aria-[current=true]:font-semibold aria-[current=true]:text-[#115e59]" data-at="15"><span class="min-w-[2.4em] text-[.8rem] tabular-nums">0:15</span>The last bus driver</button></li>
      <li><button type="button" class="flex w-full cursor-pointer gap-3.5 rounded-xl px-3 py-[9px] text-left text-[.92rem] hover:bg-pc-soft focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-pc-accent aria-[current=true]:bg-pc-now aria-[current=true]:font-semibold aria-[current=true]:text-[#115e59]" data-at="22.5"><span class="min-w-[2.4em] text-[.8rem] tabular-nums">0:22</span>Listener letters</button></li>
    </ol>
  </div>
</div></div>

<div><span class="mb-3.5 block text-[.72rem] tracking-[.12em] text-[#5f6f6e] uppercase">Episode list rows</span>
<ul class="divide-y divide-[#e2e8f0] overflow-hidden rounded-[20px] bg-white shadow-[0_1px_2px_rgba(15,23,42,.05),0_14px_34px_rgba(15,118,110,.1)]">
  <li><div class="group flex items-center gap-3.5 px-[18px] py-4 text-[#0f172a] [--p:0] max-[520px]:gap-2.5 max-[520px]:p-3.5" data-pc data-length="24" role="group" aria-label="Episode 41">
    <audio src="../audio/track-1.mp3" preload="metadata"></audio>
    <button type="button" class="grid flex-none cursor-pointer place-items-center rounded-full bg-pc-accent text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-pc-accent size-11" data-play="episode 41" aria-label="Play episode 41"><svg class="block size-5 group-data-playing:hidden" viewBox="0 0 24 24" aria-hidden="true" fill="currentColor"><path d="M8 5.5v13a1 1 0 0 0 1.5.86l10.5-6.5a1 1 0 0 0 0-1.72L9.5 4.64A1 1 0 0 0 8 5.5z"/></svg><svg class="hidden size-5 group-data-playing:block" viewBox="0 0 24 24" aria-hidden="true" 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>
    <div class="min-w-0 flex-1">
      <p class="truncate text-[.95rem] font-bold">Ep. 41 · Night Shift at the Lighthouse</p>
      <p class="mt-[3px] text-[.8rem] text-pc-muted">Field Notes FM · <span data-left>0:24 left</span></p>
      <div class="mt-[9px] h-1 overflow-hidden rounded-full bg-[#d5e3e1]" aria-hidden="true"><i class="block h-full w-[calc(var(--p)*100%)] bg-pc-accent"></i></div>
    </div>
    <div class="flex">
      <button type="button" class="grid flex-none cursor-pointer place-items-center rounded-full text-[#0f172a] hover:bg-pc-soft focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-pc-accent size-[38px]" data-skip="-15" aria-label="Back 15 seconds"><svg class="block size-6" viewBox="0 0 24 24" aria-hidden="true" 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">15</text></svg></button>
      <button type="button" class="grid flex-none cursor-pointer place-items-center rounded-full text-[#0f172a] hover:bg-pc-soft focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-pc-accent size-[38px]" data-skip="30" aria-label="Forward 30 seconds"><svg class="block size-6" viewBox="0 0 24 24" aria-hidden="true" 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">30</text></svg></button>
    </div>
  </div></li>
  <li><div class="group flex items-center gap-3.5 px-[18px] py-4 text-[#0f172a] [--p:0] max-[520px]:gap-2.5 max-[520px]:p-3.5" data-pc data-length="29" role="group" aria-label="Episode 40">
    <audio src="../audio/track-4.mp3" preload="metadata"></audio>
    <button type="button" class="grid flex-none cursor-pointer place-items-center rounded-full bg-pc-accent text-white focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-pc-accent size-11" data-play="episode 40" aria-label="Play episode 40"><svg class="block size-5 group-data-playing:hidden" viewBox="0 0 24 24" aria-hidden="true" fill="currentColor"><path d="M8 5.5v13a1 1 0 0 0 1.5.86l10.5-6.5a1 1 0 0 0 0-1.72L9.5 4.64A1 1 0 0 0 8 5.5z"/></svg><svg class="hidden size-5 group-data-playing:block" viewBox="0 0 24 24" aria-hidden="true" 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>
    <div class="min-w-0 flex-1">
      <p class="truncate text-[.95rem] font-bold">Ep. 40 · A Map of Copper Roofs</p>
      <p class="mt-[3px] text-[.8rem] text-pc-muted">Field Notes FM · <span data-left>0:29 left</span></p>
      <div class="mt-[9px] h-1 overflow-hidden rounded-full bg-[#d5e3e1]" aria-hidden="true"><i class="block h-full w-[calc(var(--p)*100%)] bg-pc-accent"></i></div>
    </div>
    <div class="flex">
      <button type="button" class="grid flex-none cursor-pointer place-items-center rounded-full text-[#0f172a] hover:bg-pc-soft focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-pc-accent size-[38px]" data-skip="-15" aria-label="Back 15 seconds"><svg class="block size-6" viewBox="0 0 24 24" aria-hidden="true" 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">15</text></svg></button>
      <button type="button" class="grid flex-none cursor-pointer place-items-center rounded-full text-[#0f172a] hover:bg-pc-soft focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-pc-accent size-[38px]" data-skip="30" aria-label="Forward 30 seconds"><svg class="block size-6" viewBox="0 0 24 24" aria-hidden="true" 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">30</text></svg></button>
    </div>
  </div></li>
</ul></div>
CSS
@theme {
  --color-pc-accent: #0f766e;
  --color-pc-soft: #f0fdfa;
  --color-pc-now: #ccfbf1;
  --color-pc-muted: #475569;
  --color-pc-track: #7f9493;
}
JavaScript
// Podcast players. Each [data-pc] wraps one <audio>. Besides play and seek it handles skip buttons
// (data-skip="-15" / "30"), a speed button that cycles 1x to 2x, and chapter buttons (data-at="seconds").
(() => {
  const fmt = s => { s = Math.floor(s || 0); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
  const rates = [1, 1.25, 1.5, 2];
  document.querySelectorAll('[data-pc]').forEach(root => {
    const $ = s => root.querySelector(s);
    const audio = $('audio'), play = $('[data-play]'), seek = $('[data-seek]'), cur = $('[data-cur]'), dur = $('[data-dur]');
    const left = $('[data-left]'), speed = $('[data-speed]'), chapters = [...root.querySelectorAll('[data-at]')], name = play.dataset.play;
    let raf = 0;
    const length = () => audio.duration || +root.dataset.length;
    const sync = () => {
      const t = audio.currentTime, d = length(), text = fmt(t) + ' of ' + fmt(d);
      root.style.setProperty('--p', d ? t / d : 0);
      if (cur) cur.textContent = fmt(t);
      if (left) left.textContent = fmt(d - t) + ' left';
      if (seek) {
        seek.value = t;
        if (seek.getAttribute('aria-valuetext') !== text) seek.setAttribute('aria-valuetext', text);
      }
      // the chapter that contains the current time is marked with aria-current
      const now = chapters.filter(c => +c.dataset.at <= t + 0.05).pop();
      chapters.forEach(c => { if (c === now) c.setAttribute('aria-current', 'true'); else c.removeAttribute('aria-current'); });
    };
    const tick = () => { sync(); raf = requestAnimationFrame(tick); };
    const meta = () => { if (seek) seek.max = audio.duration; if (dur) dur.textContent = fmt(audio.duration); sync(); };
    const jump = t => { audio.currentTime = Math.min(Math.max(t, 0), length()); sync(); };
    const start = () => {
      document.querySelectorAll('audio').forEach(a => { if (a !== audio) a.pause(); });
      audio.play().catch(() => {});
    };
    play.addEventListener('click', () => (audio.paused ? start() : audio.pause()));
    audio.addEventListener('play', () => { root.setAttribute('data-playing', ''); play.setAttribute('aria-label', 'Pause ' + name); tick(); });
    audio.addEventListener('pause', () => { root.removeAttribute('data-playing'); play.setAttribute('aria-label', 'Play ' + name); cancelAnimationFrame(raf); sync(); });
    audio.addEventListener('loadedmetadata', meta);
    if (audio.readyState > 0) meta();
    root.querySelectorAll('[data-skip]').forEach(b => b.addEventListener('click', () => jump(audio.currentTime + +b.dataset.skip)));
    chapters.forEach(c => c.addEventListener('click', () => { jump(+c.dataset.at); start(); }));
    if (speed) speed.addEventListener('click', () => {
      const rate = rates[(rates.indexOf(audio.playbackRate) + 1) % rates.length];
      audio.playbackRate = audio.defaultPlaybackRate = rate;
      speed.textContent = rate + '×';
      speed.setAttribute('aria-label', 'Playback speed ' + rate + '×');
    });
    if (seek) {
      seek.addEventListener('input', () => jump(+seek.value));
      // Arrow keys jump 5 seconds; Home and End work natively.
      seek.addEventListener('keydown', e => {
        const step = { ArrowLeft: -5, ArrowDown: -5, ArrowRight: 5, ArrowUp: 5 }[e.key];
        if (!step) return;
        e.preventDefault();
        jump(audio.currentTime + step);
      });
    }
  });
})();

About this set

Two players with the controls people expect from a podcast app. The episode player with chapters shows the show art, the show and episode name and the hosts, a seek slider with times, a speed button, buttons that skip back 15 seconds and forward 30 seconds, the time left and a list of four chapters. The speed button cycles through 1x, 1.25x, 1.5x and 2x by setting playbackRate, and its label says the current speed. Each chapter is a button with a data-at value in seconds; clicking it seeks there and starts playback, and as the episode plays the chapter that contains the current time is marked with aria-current and a tint. The episode list rows give each episode its own audio element, with a play button, the title, the show name, the time left, a thin progress bar and the skip pair, which the Bootstrap version groups in a .btn-group. The demo audio is a 30 second music loop, so the chapters sit at 0:00, 0:07, 0:15 and 0:22; set data-at to your real chapter times.

What’s included

  • Back 15 seconds and forward 30 seconds buttons
  • Speed button cycling 1x, 1.25x, 1.5x and 2x
  • Chapter list that seeks and tracks the current chapter with aria-current
  • Episode rows with their own audio, progress bar and time left
  • Chapters as a Bootstrap .list-group and skips as a .btn-group
  • Plain CSS, Bootstrap and Tailwind versions of the same design

Accessibility

The skip buttons are labelled Back 15 seconds and Forward 30 seconds, the speed button's label reads Playback speed 1.5x and changes with each press, and the play button names the episode. Chapters are buttons in an ordered list and the current one carries aria-current="true". The seek slider reports text such as 0:15 of 0:30, and keyboard focus draws a 2px outline on buttons and a ring on the slider thumb. The players use no motion.

Customise it

Change --pc-accent, --pc-soft, --pc-now and --pc-track on .pc; --pc-now is the current-chapter tint. Edit the rates array at the top of the script for other speeds, and data-skip on a button for other skip lengths. In Tailwind edit the --color-pc-* theme colours.