// Each [data-ac] card wraps one <audio>. The script wires its play button, seek slider, mute and like buttons.
(() => {
const fmt = s => { s = Math.floor(s || 0); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
document.querySelectorAll('[data-ac]').forEach(root => {
const $ = s => root.querySelector(s);
const audio = $('audio'), play = $('[data-play]'), seek = $('[data-seek]'), cur = $('[data-cur]'), dur = $('[data-dur]');
const vol = $('[data-vol]'), mute = $('[data-mute]'), name = play.dataset.play;
let raf = 0;
const length = () => audio.duration || +seek.max;
const sync = () => {
const t = audio.currentTime, d = length(), text = fmt(t) + ' of ' + fmt(d);
seek.value = t;
root.style.setProperty('--p', d ? t / d : 0);
if (cur) cur.textContent = fmt(t);
if (seek.getAttribute('aria-valuetext') !== text) seek.setAttribute('aria-valuetext', text);
};
const tick = () => { sync(); raf = requestAnimationFrame(tick); };
const meta = () => { seek.max = audio.duration; if (dur) dur.textContent = fmt(audio.duration); sync(); };
const sound = () => {
const v = audio.muted ? 0 : audio.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));
};
play.addEventListener('click', () => {
if (!audio.paused) return audio.pause();
document.querySelectorAll('audio').forEach(a => { if (a !== audio) a.pause(); });
audio.play().catch(() => {});
});
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);
audio.addEventListener('volumechange', sound);
if (audio.readyState > 0) meta();
seek.addEventListener('input', () => { audio.currentTime = seek.value; sync(); });
// 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();
audio.currentTime = Math.min(Math.max(audio.currentTime + step, 0), length());
sync();
});
if (vol) {
audio.volume = vol.value;
vol.addEventListener('input', () => { audio.volume = vol.value; audio.muted = false; });
}
if (mute) mute.addEventListener('click', () => {
if (audio.muted || audio.volume === 0) { audio.muted = false; if (audio.volume === 0) audio.volume = 0.6; }
else audio.muted = true;
});
sound();
// Like: a plain toggle button, its state lives in aria-pressed.
root.querySelectorAll('[data-like]').forEach(b => b.addEventListener('click', () => {
b.setAttribute('aria-pressed', String(b.getAttribute('aria-pressed') !== 'true'));
}));
});
})();
About this set
Three card layouts that show the album art first. The vertical album card stacks a square cover, the title, the artist and album name, a heart-shaped like button, a seek slider and a large play button between the elapsed and total time. The cover with overlay controls fills the whole card with the artwork and lays a dark gradient across the bottom, which carries a white title, a white play button and the seek slider. The horizontal card puts the cover on the left and a Now playing label, title, artist, play button, seek slider, times and a mute button on the right; under 480 pixels it stacks and the cover turns into a wide banner. The cover art is drawn with CSS gradients only, a sunset, a kite, a moon and a banded sky, each in one background declaration, so the demo needs no image files; swap it for your own album art as a background image or an img element. Every card wraps a native audio element, and a short script syncs the play button, the seek slider and the time readouts, keeps the like button’s state in aria-pressed and pauses any other player when one starts.
What’s included
Vertical card, full-bleed cover with overlay controls and a horizontal card
Cover art drawn with CSS gradients, no image files
Like button with aria-pressed and a heart that fills
Overlay text kept readable by a dark gradient behind it
Horizontal card stacks under 480px
Bootstrap version built on .card, .btn and .form-range
Accessibility
Each card is a group named after the track and artist, and the cover art is hidden from screen readers because the title sits next to it. The play button's label switches between Play and Pause with the track name, the seek slider reports its position as text such as 0:12 of 0:29, and the like and mute buttons use aria-pressed. Keyboard focus shows a 2px outline or a two-colour ring on the slider thumb, and the hover scale is dropped under prefers-reduced-motion.
Customise it
Set --ac-accent, --ac-muted and --ac-track on .ac, and --art on any cover to change the artwork. In Bootstrap the same variables sit next to --bs-card-border-radius and the --bs-btn-* variables; in Tailwind edit the --color-ac-* theme colours and the background arbitrary value on each cover.