// Animated players. Each [data-an] wraps one <audio>; the script wires whichever controls it contains
// and toggles [data-playing], which is what starts and stops the CSS animations.
(() => {
const fmt = s => { s = Math.floor(s || 0); return Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0'); };
document.querySelectorAll('[data-an]').forEach(root => {
const $ = s => root.querySelector(s);
const audio = $('audio'), play = $('[data-play]'), seek = $('[data-seek]'), cur = $('[data-cur]'), dur = $('[data-dur]'), left = $('[data-left]');
const mute = $('[data-mute]'), 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);
}
};
const tick = () => { sync(); raf = requestAnimationFrame(tick); };
const meta = () => { if (seek) seek.max = audio.duration; if (dur) dur.textContent = fmt(audio.duration); sync(); };
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);
if (audio.readyState > 0) meta();
if (seek) seek.addEventListener('input', () => { audio.currentTime = seek.value; sync(); });
// Arrow keys jump 5 seconds; Home and End work natively.
if (seek) 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 (mute) {
mute.addEventListener('click', () => { audio.muted = !audio.muted; });
audio.addEventListener('volumechange', () => mute.setAttribute('aria-pressed', String(audio.muted)));
}
});
})();
About this set
Three players with motion that follows the audio. The spinning record is a CSS turntable: a vinyl disc drawn with a repeating radial gradient for the grooves, the cover art as its centre label, a fixed sheen on top and a tonearm that swings onto the record when playback starts. The disc runs a linear rotation whose animation-play-state switches between paused and running, so it stops where it is when the music stops. The equaliser bars card shows five bars beside the cover and title; at rest they hold a fixed pattern, and while the track plays each bar scales on its own duration and delay. The pulse button is an 84 pixel play button with two rings that grow and fade out from behind it, half a cycle apart. Every animation is keyed to a data-playing attribute that the script sets on the play event and removes on pause, so motion never runs while the audio is silent. Under prefers-reduced-motion: reduce the animations are switched off, the record stays still and the tonearm moves without a transition, while play, seek and time keep working.
What’s included
Turntable with a turning record and a tonearm that swings in
Equaliser bars that rest in a pattern and bounce while playing
Play button with two rippling rings
Motion runs only while audio plays, keyed to data-playing
All animation removed under prefers-reduced-motion
Tailwind version uses @theme keyframes and motion-safe: variants
Accessibility
The turntable, bars and rings are hidden from screen readers; each player is a group named after its track, with a play button labelled Play or Pause and a seek slider that reads as 0:09 of 0:24. Keyboard focus draws a 2px outline 3px off the button and a ring on the slider thumb. With prefers-reduced-motion set, nothing spins, bounces or ripples, and the tonearm moves without a transition.
Customise it
Change --an-accent and --an-track on .an, the 2.4s duration on .an-disc for a faster or slower record, and the per-bar animation-duration values for the equaliser. In Tailwind the --animate-an-* values and keyframes live in the @theme block.