Open any playlist on Spotify, Apple Music or YouTube Music and you meet the same object: a big cover up top, then a grid of rows — number, song, album, date, length. It looks like a data table, but there is no <table> anywhere in it. I rebuilt that track table in plain HTML/CSS/JS — sticky header, hover controls, a now-playing state with animated equalizer bars, keyboard navigation, and a mobile fold — and the whole thing rests on one honest idea. Here is how it works.
The layout is CSS Grid, not a table
The header row and every song row are display:grid sharing the same grid-template-columns. Because they share the template, column 3 of the header (Album) lines up perfectly with column 3 of each row — no <td>, no widths guessed by hand. I store that template in a single CSS variable, so the responsive fold later is a one-line change.
.pl { --gtc: 16px 4fr 2.6fr 2fr minmax(96px,auto); } /* one source of truth */
.track-headrow,
.track-row {
display: grid;
grid-template-columns: var(--gtc); /* header + rows share it -> aligned */
gap: 16px; align-items: center;
}
The signature move: # ↔ ▶ in one cell
The famous Spotify trick is that the track number becomes a play button when you hover the row. The mechanism is delightfully dumb: the number and the play button occupy the same index cell, the button hidden by default, and a single :hover rule flips which one shows. No JavaScript touches it, so it is instant.
.t-play { display: none; } /* hidden until hover */
.track-row:not(.playing):hover .t-num { display: none; } /* number out */
.track-row:not(.playing):hover .t-play { display: inline-flex; } /* ▶ in */
I scope it to :not(.playing) so the now-playing row keeps its equalizer instead of reverting to a play triangle. The like heart and the ⋯ menu in the last column play the same game, but fade with opacity (not display) so nothing shifts in the layout when they appear — a resting list stays calm, only the hovered row shows its actions.
Now playing is one class, everything else is derived
Clicking a row makes it the current track. In JavaScript that is three lines: strip .playing off whichever row had it, add it to the clicked one, remember the index. Every visual is downstream CSS keyed off that single class — the title goes green, the number becomes an equalizer. State lives in one place, the look is derived from it, so nothing can drift out of sync.
function play(i){
rows.forEach(r => r.classList.remove("playing","paused"));
rows[i].classList.add("playing");
playingIndex = i;
render(); // the read-out follows the state
}
The equalizer is four spans and a staggered delay
The dancing bars are four <span>s in a flex row, each animating its height on the same @keyframes loop — but with a different negative animation-delay. That offset is the whole illusion: it makes the bars look independent instead of marching in unison. Under prefers-reduced-motion they freeze, so the effect is honest but never nauseating.
.t-eq span { width:3px; background:#1db954; animation: eq .9s ease-in-out infinite; }
.t-eq span:nth-child(1){ animation-delay:-.20s; } /* staggered starts */
.t-eq span:nth-child(2){ animation-delay:-.55s; }
.t-eq span:nth-child(3){ animation-delay:-.05s; }
@keyframes eq { 0%,100%{ height:25%; } 50%{ height:100%; } }
Sticky header, roving tabindex, one-line fold
Three small finishers make it feel real. position:sticky; top:0 inside the scrolling playlist pins the column labels with no JS and no scroll listener. A roving tabindex keeps exactly one row focusable at a time, so Tab jumps into and out of the list in one press while ↑/↓ walk the songs — the pattern the ARIA grid guidelines recommend. And because every row reads the same --gtc variable, the phone layout is one media query that rewrites it and hides two columns.
@media (max-width:640px){
.pl { --gtc: 16px 1fr auto; } /* 5 columns -> 3 */
.pl .col-album, .pl .col-date { display: none; }
}
The takeaway that surprised me: a surface this busy is almost entirely CSS. One shared column template does the alignment, one :hover rule does the play-button swap, one class does now-playing, and a handful of accessibility touches (roving tabindex, an aria-live region announcing each change) finish it. No framework, no dependencies.
Hover the rows, click to play, then shrink it to a phone:
https://dev48v.infy.uk/design/day40-spotify-playlist.html
Top comments (0)