Puneet Sharma - Frontend Developer & UI Engineer
Puneet Sharma
Frontend Dev & UI Engineer · 16+ yrs · pixel-perfect HTML, React & WordPress

Building a Music Player Card: Why UI State Should Follow the & Element, Not Guess Ahead of It

Build a music player card in HTML/CSS/JS and learn why animation-play-state beats toggling classes so a paused vinyl resumes instead of resetting.
Building a Music Player Card

The naive version of a play button sets a playing variable to true the instant it's clicked, then updates the icon and the spinning vinyl to match. It looks right in every manual test, because a click and a successful play basically always happen together — until they don't. A track finishes on its own. A browser blocks autoplay before the user has interacted with the page. A file 404s. In every one of those cases, the UI already committed to "playing" before the browser confirmed anything, and now the record is spinning over audio that isn't making a sound. The Music Player Card snippet plays real audio through a native <audio> element and fixes this by inverting the order: the UI never assumes — it waits for the audio element's own play, pause, and ended events and updates from those. Here's the finished card, with real sample tracks wired in — press play, let a track run out, and watch it advance on its own:

Grab the code, or open the full editor with live HTML/CSS/JS panels: Music Player Card on FWD Tools. It's plain HTML, CSS and JavaScript with zero dependencies — a native <audio> element, no Web Audio API, no external player library — and the same editor exports one-click to React, Vue, Angular and React + Tailwind.

In this post I'll build it piece by piece: why the vinyl's spin state is toggled rather than swapped, why the play/pause icon and the vinyl are updated from the audio element's own events instead of inside the click handler, how track switching wraps around cleanly with modulo arithmetic, how real progress comes from timeupdate instead of a timer, and how a track ending on its own advances to the next one without the UI falling out of sync.

What the component actually is

Four moving parts, all ultimately answerable to one thing — the real <audio> element's own state:

  1. A playing boolean that mirrors the audio element's actual play state — it's written in exactly one place, and never optimistically.
  2. A tracks array of plain objects (name, artist, an MP3 src) that next()/prev() walk with wraparound.
  3. A CSS keyframe animation that's always running, just paused — the vinyl spin exists on the element from the first paint; play/pause only flips whether it advances.
  4. A set of listeners on the <audio> elementplay, pause, ended, timeupdate, loadedmetadata — that are the only things allowed to update the visible playback state.

The structural idea worth carrying into other projects is the fourth one: when you're wrapping a native element that already has its own well-defined state and events — <audio>, <video>, a <dialog>, a native <details> — resist the urge to track that state a second time in a variable you update by hand at the point of interaction. Read it back out of the element's own events instead. The element becomes the single source of truth, and your UI is a reflection of it rather than a parallel guess that can drift.

Where you'd actually use this

  • An embedded podcast or audio widget. Point the tracks array at your own episode files and the play/pause, seek, and progress bar all work immediately.
  • A "now playing" card on a portfolio or landing page. Ambient background music for a creative site, with a player that matches the aesthetic instead of a bare browser control.
  • Any UI with a toggle that drives a looping animation. A "live" indicator, a pulsing recording dot, a spinning loader you want to pause without resetting — the play-state pattern generalizes past music entirely.
  • A reference for wrapping a native element's state correctly. The same event-driven pattern used here for <audio> applies just as directly to a custom <video> UI or any component built on top of a browser element that already manages its own state.

The markup: one card, one hidden audio element

<div class="player">
  <div class="album" id="album">
    <div class="vinyl" id="vinyl"></div>
  </div>
  <div class="meta">
    <div class="track-info">
      <div class="track-name">Midnight City</div>
      <div class="artist">M83</div>
    </div>
    <button class="like-btn" id="like" onclick="toggleLike()">...</button>
  </div>
  <div class="progress-wrap">
    <span class="time" id="cur">1:24</span>
    <div class="progress-track" id="track" onclick="seek(event)">
      <div class="progress-fill" id="fill" style="width:35%">
        <div class="thumb"></div>
      </div>
    </div>
    <span class="time">3:53</span>
  </div>
  <div class="controls">
    <button class="ctrl-btn prev" onclick="prev()">...</button>
    <button class="play-btn" id="play" onclick="togglePlay()">
      <svg id="play-icon">...</svg>
    </button>
    <button class="ctrl-btn next" onclick="next()">...</button>
  </div>
  <div class="volume-row">
    ...
    <input type="range" class="vol-slider" value="70" oninput="setVol(this.value)" />
    ...
  </div>
  <audio id="audio" preload="metadata"></audio>
</div>

The <audio> element has no src in the markup and no visible controls — it's a plain, unstyled playback engine that load() points at whichever track is current. There's exactly one <svg id="play-icon"> element too, not a play icon and a pause icon layered on top of each other with one hidden — that's deliberate, and Step 2 explains why. The progress bar is a track with a fill inside it and a thumb inside that — three nested elements so the fill's width can be animated with a plain CSS width transition while the thumb rides along at its edge for free, with no separate position calculation.

Step 1 — The vinyl: an animation that's always running, just paused

.vinyl {
  width: 60px; height: 60px; border-radius: 50%;
  background: radial-gradient(circle at 50% 50%, #1e293b 12px, transparent 12px),
              repeating-conic-gradient(rgba(255,255,255,0.04) 0deg, rgba(0,0,0,0.08) 10deg);
  animation: spin 4s linear infinite paused;
}
@keyframes spin { to { transform: rotate(360deg); } }
.vinyl.spinning { animation-play-state: running; }
vinyl.classList.toggle('spinning', playing);

The animation shorthand on .vinyl ends with the keyword paused — the fifth value in the shorthand, easy to miss if you're used to writing name/duration/timing/iteration and stopping there. That keyword sets animation-play-state: paused from the very first paint. The animation isn't absent; it's frozen at whatever rotation it happens to be sitting at, which at load time is 0°.

.spinning does exactly one thing: it flips that same property to running. The keyframes, duration, and iteration count are untouched — the browser simply resumes advancing the rotation from wherever it left off. Pause at 137° and press play again, and it continues from 137°, not from 0°.

Compare that to the version most people write first: add a .spinning class that itself declares animation: spin 4s linear infinite, and remove the class to stop. That works for the first play. But removing a CSS animation from an element and re-adding the same animation later doesn't resume it — it restarts it, because as far as the browser is concerned a fresh animation declaration is a fresh animation instance with its own clock at zero. The visible symptom is a record that jumps back to the label-up position every time you press play after a pause. animation-play-state is the one property designed specifically to avoid that: it pauses and resumes the same running instance instead of destroying and recreating it.

The general rule worth keeping: if an animation needs to start and stop repeatedly while preserving position — a spinner, a marquee, a pulsing dot, anything you can pause mid-cycle — toggle animation-play-state, not the animation declaration itself. Reserve adding/removing the animation for effects that are supposed to restart from the top every time they're triggered, like a shake or a one-shot entrance.

Step 2 — Let the audio element's own events drive the UI

function togglePlay() {
  if (audio.paused) { audio.play(); } else { audio.pause(); }
}

function setPlayingUI(isPlaying) {
  playing = isPlaying;
  vinyl.classList.toggle('spinning', playing);
  document.getElementById('play-icon').innerHTML = playing
    ? '<rect x="6" y="4" width="4" height="16"/><rect x="14" y="4" width="4" height="16"/>'
    : '<polygon points="5 3 19 12 5 21 5 3"/>';
}

audio.addEventListener('play', () => setPlayingUI(true));
audio.addEventListener('pause', () => setPlayingUI(false));
audio.addEventListener('ended', () => next(true));

Notice what togglePlay() doesn't do: it never sets playing, never touches classList, never writes to the icon. All it does is ask the audio element to play or pause — the same instruction a user gives by hand. Every visible change is made by setPlayingUI(), and the only things allowed to call it are the audio element's own play and pause events. That's a subtle but load-bearing distinction: audio.play() is asynchronous and can be rejected (autoplay policies, a file that fails to load), so writing playing = true right after calling it would be recording an intention, not a fact. Waiting for the play event to actually fire means the UI only ever reflects what genuinely happened.

This is also what fixes auto-advance. ended fires when a track finishes on its own, with no click involved at all, and it calls next(true) to load and immediately start the next track. Because the vinyl and icon are driven by play/pause rather than by whatever code path happened to trigger the change, a track ending naturally updates the UI exactly the same way a manual pause would — there's no separate "did the track end on its own" branch that has to remember to also flip the icon back. One function, three unrelated-looking events, and no way for them to drift apart.

classList.toggle('spinning', playing) still uses the two-argument force form rather than plain toggle('spinning') — passing a boolean makes the class match it exactly, add when true, remove when false, regardless of what it was a moment ago. That matters even more here than in a hand-toggled version, because setPlayingUI() can now be invoked from three different listeners; a one-argument flip would have no way to guarantee it lands in the right state if two of those ever fired close together.

The icon swap takes the same idea further: instead of a play <svg> and a pause <svg> both sitting in the DOM with one given display: none, there's a single <svg id="play-icon"> whose inner markup is replaced outright via innerHTML. There's no second element's visibility to keep synchronized — just one piece of state, written in one function, called from one place.

Step 3 — Track switching with modulo wraparound

const tracks = [
  { name: 'Sunset Drive', artist: 'SoundHelix', src: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3' },
  { name: 'Night Runner', artist: 'SoundHelix', src: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3' },
  { name: 'Open Road', artist: 'SoundHelix', src: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-3.mp3' },
];
let idx = 0, playing = false;

function load(i, autoplay) {
  idx = i;
  const t = tracks[i];
  document.querySelector('.track-name').textContent = t.name;
  document.querySelector('.artist').textContent = t.artist;
  document.querySelectorAll('.time')[1].textContent = '0:00';
  fill.style.width = '0%';
  cur.textContent = '0:00';
  audio.src = t.src;
  if (autoplay) { audio.play(); }
}

function next(autoplay) { load((idx + 1) % tracks.length, autoplay !== undefined ? autoplay : playing); }
function prev() { load((idx - 1 + tracks.length) % tracks.length, playing); }

load() is the only function that touches the track-display DOM or the audio element's src, and every caller funnels through it. It resets the fill, the elapsed label, and the duration label to zero before assigning the new src — otherwise the old track's numbers would sit on screen for a moment, stale, until the new file's own events start firing. The autoplay parameter is what lets one function serve two different callers with two different intentions: a manual click on "next" while paused shouldn't start playback, but next(true) from the ended listener should. prev() and a plain click on "next" fall back to whatever playing currently is, so switching tracks mid-playback keeps playing, and switching while paused stays paused.

The modulo arithmetic underneath is the part worth internalizing rather than pattern-matching. (idx + 1) % tracks.length wraps forward cleanly: at the last index, idx + 1 equals tracks.length, and anything modulo its own length is 0 — back to the start. Going backward needs the extra + tracks.length that's easy to leave out: at idx === 0, plain (idx - 1) % tracks.length evaluates to -1 % 3, which in JavaScript is -1, not 2 — JavaScript's % keeps the sign of the dividend rather than always returning a positive remainder. Adding tracks.length before the modulo shifts -1 up to tracks.length - 1 first, so the result lands back in range. Any time you're wrapping an index downward in JavaScript, that's the line to remember.

Step 4 — Real progress, from timeupdate and loadedmetadata

audio.addEventListener('loadedmetadata', () => {
  document.querySelectorAll('.time')[1].textContent = format(audio.duration);
});

audio.addEventListener('timeupdate', () => {
  if (!audio.duration) return;
  fill.style.width = (audio.currentTime / audio.duration * 100) + '%';
  cur.textContent = format(audio.currentTime);
});

function format(sec) {
  sec = Math.max(0, Math.floor(sec));
  return Math.floor(sec / 60) + ':' + String(sec % 60).padStart(2, '0');
}

There's no timer anywhere in this version. timeupdate is an event the browser fires on its own as a track plays — roughly four times a second, though the spec leaves the exact cadence up to the browser — and both the progress fill and the elapsed-time label are derived straight from audio.currentTime and audio.duration each time it fires. That's a strictly more honest number than a hand-rolled setInterval incrementing a percentage by a fixed amount: it can't drift from the actual file, and it doesn't need to parse a duration string like "3:53" back into seconds, because audio.duration already is the real number of seconds.

The duration label has its own event because it isn't known at all until the browser has read the file's metadata — that's what loadedmetadata signals. Until then it reads 0:00, which is why load() resets it explicitly rather than leaving the previous track's duration on screen while the new one loads.

Step 5 — Click-to-seek writes straight to currentTime

function seek(e) {
  if (!audio.duration) return;
  const rect = e.currentTarget.getBoundingClientRect();
  const pct = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
  audio.currentTime = pct * audio.duration;
}

The geometry is the same conversion that shows up in any draggable or clickable bar: a click's page-relative clientX is meaningless on its own, so it's translated into a position relative to the bar itself by subtracting the bar's left edge (from getBoundingClientRect()), then normalized into a 0–1 fraction by dividing by the bar's own width. What's different from a simulated player is the last line — the fraction is multiplied by audio.duration and written directly to audio.currentTime, which actually moves playback to that instant. The fill doesn't need to be set here at all; the next timeupdate event, which fires almost immediately after a seek, catches it up to the new position on its own. The if (!audio.duration) return guard matters for the same reason the duration label starts blank: clicking the bar before metadata has loaded would otherwise try to multiply a fraction by NaN.

Customizing it for your own project

  • Point it at your own audio. Replace each track's src with your own hosted MP3 (same-origin, or a CORS-enabled CDN). Nothing else in the code needs to change — load(), seek(), and the progress bar all read entirely from the audio element.
  • Change the vinyl's spin speed or the album gradient's shift speed independently. They're two separate animation declarations (spin on .vinyl, albumShift on .album) with no shared timing, so retuning one doesn't touch the other.
  • Add a shuffle button that actually shuffles. Give it an onclick that calls load(Math.floor(Math.random() * tracks.length), playing) — it only needs to produce a valid index and the current playback intent; load() handles the rest.
  • Add a repeat mode. The ended listener is already the single place a track's natural end is handled — a repeat-one mode is a check there that calls load(idx, true) instead of next(true).
  • Add a real like/favorites list. toggleLike() currently only flips a CSS class; persist liked track indices to an array or localStorage alongside it if you want the state to survive a track switch or reload.

The things deliberately left out

The Web Audio API and any real audio decoding. No AudioContext, no analyser node, no waveform. This is a plain <audio> element, which is the right tool for "play this file with standard controls" — a full Web Audio pipeline (gain nodes, an analyser-driven visualizer) is a different, much larger project layered on top, only worth it if you actually need frequency data for a visualizer.

Drag-to-seek. The progress bar responds to a click but not to a mousedown-drag-mouseup sequence across it. Real scrubbing needs mousedown/touchstart to start tracking, a mousemove/touchmove listener on document (not just the bar, so the drag doesn't break if the pointer leaves it), and cleanup on mouseup. It's a natural next step but a genuinely separate piece of event-handling logic from the single-click case here.

Handling a failed load. The audio element has an error event for a file that 404s or fails to decode, and it's not wired up here — a production version should listen for it and show something other than a record that silently never starts spinning.

A playlist/queue panel. The tracks array and load(i, autoplay) already have everything a queue UI needs — render each track as a row, call load(i, true) on click, highlight whichever row matches the current idx — but it's a second UI surface, not a change to the player card itself.

Using it in React, Vue, or Angular

The editor exports all four, and the pattern maps cleanly because the source of truth is already an element's events rather than a hand-maintained variable. playing and idx still map to useState, but they should only ever be set from inside the <audio> element's event handlers — never optimistically inside the click handler that calls .play() or .pause(), for the same reason described in Step 2. Keep a ref to the audio element, attach play/pause/ended/timeupdate/loadedmetadata listeners in a useEffect with an empty dependency array, and return a cleanup function that removes all five — an audio element that outlives its component and keeps firing timeupdate into state that no longer renders anywhere is a real, if quiet, memory leak.

The vinyl's classList.toggle becomes a conditional class driven straight from the playing state variable (className={playing ? 'vinyl spinning' : 'vinyl'}), and the play icon's inner markup swaps via a ternary in JSX rather than an innerHTML write — but both should still be downstream of the same state, updated in the same place, for the same reason they're centralized in setPlayingUI() here.

Build, understand, optimize, and extend it with AI

This snippet is small enough to hold in your head, which makes it a good one to pressure-test rather than just read. Paste the HTML, CSS and JS into an assistant like Claude and ask it to rewrite togglePlay() so it sets playing = true and toggles the vinyl class directly, right before calling audio.play(), instead of waiting for the play event — then have it describe exactly what the UI would show if that play() call were rejected by the browser's autoplay policy. Getting the assistant to state the desync in its own words is worth more than being told the rule up front. From there, ask it to explain why routing the ended event through the same next(autoplay) function that the click handlers use, rather than writing separate logic for "track finished on its own," is what keeps the icon and vinyl correct in both cases. For extension, in roughly increasing order of effort: wire up the error event with a visible failure state; add a repeat-one mode; add drag-to-seek instead of click-only; and build a queue panel listing every track with the currently playing one highlighted.

Prompt to recreate it

Copy this into your AI assistant of choice to build the component from scratch, or as a jumping-off point for your own variant:

Build a "music player card" UI in plain HTML, CSS, and JavaScript that plays real audio through a native <audio> element — no Web Audio API, no external player library.

Requirements:
- A card showing a square album-art area with a continuously animated background gradient, a smaller circular "vinyl" disc centered on it that only spins while audio is actually playing (using CSS animation-play-state toggled between paused and running, not by adding/removing the animation itself, so a pause preserves the current rotation angle rather than resetting it).
- Track metadata (name, artist, and a src URL to an MP3 file) driven from an array of track objects; a like/heart button that toggles a filled state independent of playback.
- A hidden <audio> element whose src is set from the current track. A single function must drive the vinyl spin state, the play/pause icon, and an internal "playing" flag together, and that function must be called FROM the audio element's own play/pause events — never set that state optimistically right before calling audio.play()/audio.pause(), so the UI can never desync from what the browser is actually doing (e.g. if playback is blocked or a track fails to load).
- A clickable progress bar with a draggable-looking thumb, driven entirely by the audio element's timeupdate event (not a timer) — the fill width and an elapsed mm:ss label should both be computed from audio.currentTime and audio.duration. Clicking anywhere on the bar must set audio.currentTime based on the click's position relative to the bar's own bounding rect, guarded against a click before duration is known.
- The total-duration label should be read from the audio element's real duration once the loadedmetadata event fires, not hardcoded per track, and should reset to 0:00 immediately when a track switch begins.
- Previous/next buttons that move to another track in the array with wraparound at both ends using modulo arithmetic (remember plain negative modulo doesn't wrap correctly in JavaScript), loading the new track's src and metadata into the DOM through one shared load(index, autoplay) function that both buttons call, where autoplay controls whether the new track starts playing immediately or only loads.
- When a track ends (the audio element's native "ended" event), call the same next() function used by the manual next button, passing true for autoplay, so playback continues seamlessly into the next track and the play/pause icon and vinyl update correctly without any separate code path.
- A volume slider wired to audio.volume (0–1 range, converted from the slider's 0–100 value) so it's a real, functional control.

Final thought

The line worth keeping from this build is about where state is allowed to be written. It's tempting to update the UI at the moment of interaction — the click happened, so set playing = true right there and move on. That works until the thing you're wrapping has any asynchrony or any way to change state on its own, and a native <audio> element has both: .play() can be rejected, and ended fires with no click involved at all. The fix generalizes past music players: when you're building UI on top of something that already has real state and fires real events — audio, video, a WebSocket connection, a form's own validity — write your UI updates inside listeners for that thing's events, not inside the code that asks it to change. The element becomes the one place truth lives, and your UI just reads it back.

The second thing worth keeping is smaller but shows up even more often: when two or three things are supposed to change together — a class, an icon, a boolean — put them in one function and call that function from every path that can trigger the change, rather than duplicating the update at each call site. That's what makes a track ending on its own look identical, from the UI's perspective, to a user clicking pause.

About the author

Puneet Sharma
Puneet Sharma is a freelance web developer and the creator of FWD Tools and WebDevPuneet. Follow him on X/Twitter

Post a Comment