DEV Community

Devanshu Biswas
Devanshu Biswas

Posted on

Building Spotify's track table in pure CSS Grid: one column template, a hover # ▶ swap, and a now-playing equalizer

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;
}
Enter fullscreen mode Exit fullscreen mode

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       */
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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%; } }
Enter fullscreen mode Exit fullscreen mode

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; }
}
Enter fullscreen mode Exit fullscreen mode

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)