Music — Track List Row
A dense, Spotify-style tracklist component built in vanilla JS. Each row swaps its index number for a play button on hover and an animated equalizer while playing, with the active track highlighted in its cover-pulled accent. Rows carry CSS-drawn album art, artist and album names, like-heart toggles, live play counts, duration timestamps, a more menu, and drag-to-reorder handles. Simulated playback, toasts, and keyboard support included.
MCP
Code
:root {
--bg: #0b0b0f;
--bg-2: #13131a;
--surface: #1a1a22;
--surface-2: #22222c;
--text: #f4f4f7;
--muted: #a0a0ad;
--line: rgba(255, 255, 255, 0.1);
--line-2: rgba(255, 255, 255, 0.18);
--accent: #1db954;
--accent-2: #8b5cf6;
--accent-3: #ff3d71;
--r-sm: 8px;
--r-md: 14px;
--r-lg: 20px;
--r-full: 999px;
--shadow: 0 18px 50px -20px rgba(0, 0, 0, 0.7);
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
background:
radial-gradient(1100px 620px at 78% -10%, rgba(139, 92, 246, 0.22), transparent 60%),
radial-gradient(900px 540px at 8% 108%, rgba(29, 185, 84, 0.16), transparent 55%),
var(--bg);
color: var(--text);
font-family: "Inter", system-ui, -apple-system, Segoe UI, sans-serif;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.stage {
display: flex;
justify-content: center;
padding: 40px 20px 64px;
}
/* ---------- shell ---------- */
.tracklist {
width: 100%;
max-width: 880px;
background: linear-gradient(180deg, var(--surface) 0%, var(--bg-2) 100%);
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--shadow);
overflow: hidden;
/* per-row playing accent, recolored by JS */
--row-accent: var(--accent);
}
/* ---------- header ---------- */
.tl-head {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 22px;
padding: 26px 26px 22px;
background:
linear-gradient(180deg, rgba(139, 92, 246, 0.16), transparent 75%);
border-bottom: 1px solid var(--line);
}
.tl-cover {
position: relative;
width: 104px;
height: 104px;
border-radius: var(--r-md);
background: linear-gradient(150deg, #271a3d, #11131c);
overflow: hidden;
box-shadow: 0 14px 30px -12px rgba(139, 92, 246, 0.6);
flex: none;
}
.tl-cover-shape {
position: absolute;
border-radius: var(--r-full);
filter: blur(2px);
}
.tl-cover-shape.s1 {
inset: -26% 38% 44% -20%;
background: radial-gradient(circle, var(--accent-2), transparent 70%);
}
.tl-cover-shape.s2 {
inset: 30% -24% -28% 28%;
background: radial-gradient(circle, var(--accent-3), transparent 70%);
opacity: 0.85;
}
.tl-cover-shape.s3 {
inset: 44% 30% 18% 30%;
border-radius: 4px;
background: linear-gradient(120deg, var(--accent), transparent);
filter: blur(6px);
}
.tl-kicker {
margin: 0 0 4px;
font-size: 0.72rem;
letter-spacing: 0.14em;
text-transform: uppercase;
font-weight: 600;
color: var(--muted);
}
.tl-title {
margin: 0;
font-family: "Space Grotesk", "Inter", sans-serif;
font-weight: 700;
font-size: clamp(1.7rem, 5vw, 2.5rem);
letter-spacing: -0.01em;
line-height: 1.05;
}
.tl-sub {
margin: 6px 0 0;
color: var(--muted);
font-size: 0.9rem;
}
.tl-sub strong {
color: var(--text);
}
.tl-playall {
display: inline-flex;
align-items: center;
gap: 8px;
border: 0;
cursor: pointer;
padding: 11px 20px;
border-radius: var(--r-full);
background: var(--accent);
color: #04130a;
font-family: inherit;
font-weight: 700;
font-size: 0.92rem;
box-shadow: 0 12px 26px -10px rgba(29, 185, 84, 0.7);
transition: transform 0.16s ease, box-shadow 0.16s ease, filter 0.16s ease;
align-self: end;
}
.tl-playall:hover {
transform: translateY(-2px) scale(1.03);
filter: brightness(1.06);
}
.tl-playall:active {
transform: translateY(0) scale(0.98);
}
.tl-playall svg {
width: 18px;
height: 18px;
fill: currentColor;
}
.tl-playall .ico-pause {
display: none;
}
.tl-playall[aria-pressed="true"] .ico-play {
display: none;
}
.tl-playall[aria-pressed="true"] .ico-pause {
display: block;
}
/* ---------- column legend ---------- */
.tl-columns {
display: grid;
grid-template-columns: 44px 1fr 200px 92px 64px;
align-items: center;
gap: 14px;
padding: 12px 26px 8px;
font-size: 0.72rem;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--muted);
border-bottom: 1px solid var(--line);
}
.tl-columns .c-idx {
text-align: center;
}
.tl-columns .c-dur svg {
width: 15px;
height: 15px;
fill: var(--muted);
display: block;
margin-left: auto;
}
.tl-columns .c-plays,
.tl-columns .c-dur {
text-align: right;
}
/* ---------- rows ---------- */
.tl-rows {
list-style: none;
margin: 0;
padding: 6px;
}
.row {
display: grid;
grid-template-columns: 44px 1fr 200px 92px 64px;
align-items: center;
gap: 14px;
padding: 9px 20px;
border-radius: var(--r-md);
cursor: pointer;
position: relative;
transition: background 0.16s ease;
user-select: none;
}
.row:hover,
.row:focus-within {
background: var(--surface-2);
}
.row.is-playing {
background: linear-gradient(90deg, rgba(255, 255, 255, 0.05), transparent 70%);
}
.row.dragging {
background: var(--surface-2);
box-shadow: 0 10px 30px -12px rgba(0, 0, 0, 0.7);
opacity: 0.92;
}
.row.drag-over {
box-shadow: inset 0 2px 0 0 var(--row-accent);
}
/* index / play / eq cell */
.cell-idx {
position: relative;
display: flex;
align-items: center;
justify-content: center;
height: 34px;
}
.idx-num {
font-variant-numeric: tabular-nums;
font-size: 0.95rem;
color: var(--muted);
transition: opacity 0.14s ease;
}
.row:hover .idx-num,
.row.is-playing .idx-num {
opacity: 0;
}
.idx-play {
position: absolute;
inset: 0;
margin: auto;
width: 30px;
height: 30px;
border: 0;
border-radius: var(--r-full);
background: transparent;
color: var(--text);
cursor: pointer;
display: grid;
place-items: center;
opacity: 0;
transform: scale(0.8);
transition: opacity 0.14s ease, transform 0.14s ease, background 0.14s ease;
}
.idx-play svg {
width: 16px;
height: 16px;
fill: currentColor;
}
.idx-play .ico-pause {
display: none;
}
.row:hover .idx-play {
opacity: 1;
transform: scale(1);
}
.idx-play:hover {
background: rgba(255, 255, 255, 0.12);
}
/* equalizer (visible only when playing) */
.eq {
position: absolute;
inset: 0;
margin: auto;
width: 22px;
height: 18px;
display: none;
align-items: flex-end;
justify-content: space-between;
pointer-events: none;
}
.row.is-playing .idx-num,
.row.is-playing .idx-play {
opacity: 0;
}
.row.is-playing:hover .idx-play {
opacity: 1;
}
.row.is-playing:hover .eq {
opacity: 0;
}
.row.is-playing .eq {
display: flex;
}
.eq span {
width: 3px;
border-radius: 3px;
background: var(--row-accent);
animation: eqbar 0.9s ease-in-out infinite;
}
.eq span:nth-child(1) {
height: 40%;
animation-delay: -0.2s;
}
.eq span:nth-child(2) {
height: 90%;
animation-delay: -0.5s;
}
.eq span:nth-child(3) {
height: 60%;
animation-delay: -0.1s;
}
.eq span:nth-child(4) {
height: 80%;
animation-delay: -0.7s;
}
.row.is-paused .eq span {
animation-play-state: paused;
}
@keyframes eqbar {
0%, 100% { transform: scaleY(0.35); }
50% { transform: scaleY(1); }
}
/* title cell */
.cell-title {
min-width: 0;
display: grid;
grid-template-columns: 38px 1fr;
align-items: center;
gap: 11px;
}
.row-art {
width: 38px;
height: 38px;
border-radius: 7px;
position: relative;
overflow: hidden;
flex: none;
background: var(--surface-2);
}
.row-art::before,
.row-art::after {
content: "";
position: absolute;
border-radius: 50%;
filter: blur(3px);
}
.row-art::before {
inset: -30% 30% 30% -20%;
background: radial-gradient(circle, var(--ca, var(--accent)), transparent 70%);
}
.row-art::after {
inset: 35% -20% -25% 35%;
background: radial-gradient(circle, var(--cb, var(--accent-2)), transparent 70%);
}
.title-text {
min-width: 0;
}
.t-name {
display: block;
font-weight: 600;
font-size: 0.95rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row.is-playing .t-name {
color: var(--row-accent);
}
.t-artist {
display: block;
font-size: 0.82rem;
color: var(--muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.t-explicit {
display: inline-grid;
place-items: center;
width: 16px;
height: 16px;
border-radius: 3px;
background: var(--line-2);
color: var(--text);
font-size: 0.6rem;
font-weight: 700;
margin-right: 5px;
vertical-align: middle;
}
.cell-album {
color: var(--muted);
font-size: 0.88rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cell-album a {
color: inherit;
text-decoration: none;
}
.cell-album a:hover {
color: var(--text);
text-decoration: underline;
}
.cell-plays {
text-align: right;
font-variant-numeric: tabular-nums;
color: var(--muted);
font-size: 0.84rem;
}
/* duration / actions cell */
.cell-dur {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 4px;
}
.icon-btn {
border: 0;
background: transparent;
color: var(--muted);
cursor: pointer;
width: 30px;
height: 30px;
border-radius: var(--r-full);
display: grid;
place-items: center;
transition: color 0.15s ease, background 0.15s ease, transform 0.15s ease;
}
.icon-btn svg {
width: 17px;
height: 17px;
fill: currentColor;
}
.icon-btn:hover {
color: var(--text);
background: rgba(255, 255, 255, 0.08);
}
.icon-btn:active {
transform: scale(0.88);
}
.like-btn {
opacity: 0;
transition: opacity 0.14s ease, color 0.15s ease, transform 0.2s ease;
}
.row:hover .like-btn,
.like-btn[aria-pressed="true"] {
opacity: 1;
}
.like-btn[aria-pressed="true"] {
color: var(--accent-3);
}
.like-btn[aria-pressed="true"] svg {
animation: pop 0.32s ease;
}
@keyframes pop {
0% { transform: scale(0.6); }
55% { transform: scale(1.28); }
100% { transform: scale(1); }
}
.t-dur {
font-variant-numeric: tabular-nums;
color: var(--muted);
font-size: 0.85rem;
width: 42px;
text-align: right;
}
.more-btn {
opacity: 0;
}
.row:hover .more-btn {
opacity: 1;
}
/* drag handle */
.drag-handle {
position: absolute;
left: 2px;
top: 0;
bottom: 0;
width: 16px;
display: grid;
place-items: center;
cursor: grab;
color: var(--muted);
opacity: 0;
transition: opacity 0.14s ease;
}
.drag-handle:active {
cursor: grabbing;
}
.row:hover .drag-handle {
opacity: 0.7;
}
.drag-handle svg {
width: 12px;
height: 12px;
fill: currentColor;
}
/* ---------- popover menu ---------- */
.menu {
position: fixed;
z-index: 30;
min-width: 196px;
background: var(--surface-2);
border: 1px solid var(--line-2);
border-radius: var(--r-md);
padding: 6px;
box-shadow: var(--shadow);
display: none;
}
.menu.open {
display: block;
animation: menuIn 0.13s ease;
}
@keyframes menuIn {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}
.menu button {
width: 100%;
text-align: left;
border: 0;
background: transparent;
color: var(--text);
font: inherit;
font-size: 0.88rem;
padding: 9px 11px;
border-radius: var(--r-sm);
cursor: pointer;
display: flex;
align-items: center;
gap: 10px;
}
.menu button svg {
width: 15px;
height: 15px;
fill: var(--muted);
}
.menu button:hover {
background: rgba(255, 255, 255, 0.08);
}
.menu hr {
border: 0;
border-top: 1px solid var(--line);
margin: 5px 4px;
}
/* ---------- footer ---------- */
.tl-foot {
padding: 14px 26px 22px;
border-top: 1px solid var(--line);
color: var(--muted);
font-size: 0.78rem;
text-align: center;
}
/* ---------- toast ---------- */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 24px);
background: var(--surface-2);
border: 1px solid var(--line-2);
color: var(--text);
padding: 11px 18px;
border-radius: var(--r-full);
font-size: 0.86rem;
font-weight: 500;
box-shadow: var(--shadow);
opacity: 0;
pointer-events: none;
transition: opacity 0.22s ease, transform 0.22s ease;
z-index: 50;
}
.toast.show {
opacity: 1;
transform: translate(-50%, 0);
}
:focus-visible {
outline: 2px solid var(--accent-2);
outline-offset: 2px;
}
/* ---------- responsive ---------- */
@media (max-width: 760px) {
.tl-columns,
.row {
grid-template-columns: 38px 1fr 96px 64px;
}
.tl-columns .c-album,
.cell-album {
display: none;
}
}
@media (max-width: 520px) {
.stage {
padding: 22px 12px 48px;
}
.tl-head {
grid-template-columns: auto 1fr;
gap: 16px;
padding: 20px 16px 18px;
}
.tl-cover {
width: 76px;
height: 76px;
}
.tl-playall {
grid-column: 1 / -1;
justify-self: start;
align-self: auto;
}
.tl-columns,
.row {
grid-template-columns: 32px 1fr 60px;
gap: 8px;
padding-left: 12px;
padding-right: 12px;
}
.tl-columns .c-plays,
.cell-plays {
display: none;
}
.cell-dur {
gap: 0;
}
.like-btn,
.more-btn {
display: none;
}
.row-art {
width: 34px;
height: 34px;
}
}
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
}(function () {
"use strict";
/* ---------- fictional data ---------- */
var TRACKS = [
{ title: "Paper Lanterns", artist: "Neon Tides", album: "Midnight Reservoir", dur: 222, plays: 4823910, liked: true, explicit: false, ca: "#8b5cf6", cb: "#1db954" },
{ title: "Velvet Static", artist: "Halcyon Drift", album: "Glasshouse", dur: 198, plays: 2210458, liked: false, explicit: true, ca: "#ff3d71", cb: "#8b5cf6" },
{ title: "Saltwater Neon", artist: "Neon Tides", album: "Midnight Reservoir", dur: 245, plays: 6740022, liked: false, explicit: false, ca: "#1db954", cb: "#22d3ee" },
{ title: "Low Tide Lullaby", artist: "Marble Coast", album: "Slow Channels", dur: 263, plays: 982304, liked: true, explicit: false, ca: "#f59e0b", cb: "#ff3d71" },
{ title: "Concrete Bloom", artist: "Sable Wren", album: "Quiet Riot Hour", dur: 187, plays: 3315677, liked: false, explicit: true, ca: "#22d3ee", cb: "#8b5cf6" },
{ title: "Half-Light Avenue", artist: "Halcyon Drift", album: "Glasshouse", dur: 231, plays: 1572988, liked: false, explicit: false, ca: "#8b5cf6", cb: "#ff3d71" },
{ title: "After the Reservoir", artist: "Neon Tides", album: "Midnight Reservoir", dur: 274, plays: 5093411, liked: true, explicit: false, ca: "#1db954", cb: "#f59e0b" }
];
/* ---------- helpers ---------- */
function fmtDur(sec) {
var m = Math.floor(sec / 60);
var s = sec % 60;
return m + ":" + (s < 10 ? "0" : "") + s;
}
function fmtPlays(n) {
if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, "") + "M";
if (n >= 1e3) return (n / 1e3).toFixed(1).replace(/\.0$/, "") + "K";
return String(n);
}
var toastEl = document.getElementById("toast");
var toastTimer;
function toast(msg) {
if (!toastEl) return;
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("show");
}, 2000);
}
/* ---------- svg snippets ---------- */
var SVG = {
play: '<svg class="ico-play" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>',
pause: '<svg class="ico-pause" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 5h4v14H7zM13 5h4v14h-4z"/></svg>',
heart: '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 21s-6.7-4.3-9.3-8.3C.8 9.8 2 6.4 5 6c1.9-.2 3.4.9 4 2 .6-1.1 2.1-2.2 4-2 3 .4 4.2 3.8 2.3 6.7C18.7 16.7 12 21 12 21z"/></svg>',
more: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/></svg>',
grip: '<svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="9" cy="6" r="1.6"/><circle cx="15" cy="6" r="1.6"/><circle cx="9" cy="12" r="1.6"/><circle cx="15" cy="12" r="1.6"/><circle cx="9" cy="18" r="1.6"/><circle cx="15" cy="18" r="1.6"/></svg>'
};
/* ---------- state ---------- */
var rowsEl = document.getElementById("rows");
var playAllBtn = document.getElementById("playAll");
var playAllLabel = playAllBtn.querySelector(".tl-playall-label");
var activeIndex = -1; // index into TRACKS of the active track (by id)
var isPlaying = false;
/* build rows */
function buildRow(track, displayNum) {
var li = document.createElement("li");
li.className = "row";
li.setAttribute("role", "listitem");
li.dataset.title = track.title;
li.tabIndex = 0;
var explicit = track.explicit
? '<span class="t-explicit" title="Explicit">E</span>'
: "";
li.innerHTML =
'<span class="drag-handle" draggable="true" title="Drag to reorder" aria-label="Reorder ' + track.title + '">' + SVG.grip + "</span>" +
'<div class="cell-idx">' +
'<span class="idx-num">' + displayNum + "</span>" +
'<button class="idx-play" type="button" aria-label="Play ' + track.title + '" aria-pressed="false">' +
SVG.play + SVG.pause +
"</button>" +
'<span class="eq" aria-hidden="true"><span></span><span></span><span></span><span></span></span>' +
"</div>" +
'<div class="cell-title">' +
'<span class="row-art" style="--ca:' + track.ca + ";--cb:" + track.cb + '"></span>' +
'<span class="title-text">' +
'<span class="t-name">' + track.title + "</span>" +
'<span class="t-artist">' + explicit + track.artist + "</span>" +
"</span>" +
"</div>" +
'<span class="cell-album"><a href="#" tabindex="-1">' + track.album + "</a></span>" +
'<span class="cell-plays">' + fmtPlays(track.plays) + "</span>" +
'<div class="cell-dur">' +
'<button class="icon-btn like-btn" type="button" aria-label="Like ' + track.title + '" aria-pressed="' + (track.liked ? "true" : "false") + '">' + SVG.heart + "</button>" +
'<span class="t-dur">' + fmtDur(track.dur) + "</span>" +
'<button class="icon-btn more-btn" type="button" aria-label="More options" aria-haspopup="true">' + SVG.more + "</button>" +
"</div>";
return li;
}
function render() {
rowsEl.innerHTML = "";
TRACKS.forEach(function (track, i) {
rowsEl.appendChild(buildRow(track, i + 1));
});
}
/* ---------- playback ---------- */
function indexOfRow(rowEl) {
return Array.prototype.indexOf.call(rowsEl.children, rowEl);
}
function syncRow(rowEl, playing) {
var pressed = playing && isPlaying;
rowEl.classList.toggle("is-playing", playing);
rowEl.classList.toggle("is-paused", playing && !isPlaying);
var idxBtn = rowEl.querySelector(".idx-play");
idxBtn.setAttribute("aria-pressed", pressed ? "true" : "false");
idxBtn.querySelector(".ico-play").style.display = pressed ? "none" : "block";
idxBtn.querySelector(".ico-pause").style.display = pressed ? "block" : "none";
}
function refreshAll() {
Array.prototype.forEach.call(rowsEl.children, function (rowEl, i) {
syncRow(rowEl, i === activeIndex);
});
var anyPlaying = activeIndex >= 0 && isPlaying;
playAllBtn.setAttribute("aria-pressed", anyPlaying ? "true" : "false");
playAllLabel.textContent = anyPlaying ? "Pause" : "Play";
}
function playIndex(i) {
if (i === activeIndex) {
isPlaying = !isPlaying;
refreshAll();
toast(isPlaying ? "Resumed · " + TRACKS[i].title : "Paused");
return;
}
activeIndex = i;
isPlaying = true;
refreshAll();
toast("Now playing · " + TRACKS[i].title + " — " + TRACKS[i].artist);
}
/* ---------- delegated clicks ---------- */
rowsEl.addEventListener("click", function (e) {
var rowEl = e.target.closest(".row");
if (!rowEl) return;
var i = indexOfRow(rowEl);
var like = e.target.closest(".like-btn");
if (like) {
e.stopPropagation();
var on = like.getAttribute("aria-pressed") !== "true";
like.setAttribute("aria-pressed", on ? "true" : "false");
TRACKS[i].liked = on;
toast(on ? "Added to Liked Songs ♥" : "Removed from Liked Songs");
return;
}
var more = e.target.closest(".more-btn");
if (more) {
e.stopPropagation();
openMenu(more, i);
return;
}
if (e.target.closest(".cell-album a")) {
e.preventDefault();
e.stopPropagation();
toast("Album · " + TRACKS[i].album);
return;
}
// row body or idx-play button -> toggle playback
playIndex(i);
});
/* keyboard: Enter/Space on a focused row plays it */
rowsEl.addEventListener("keydown", function (e) {
var rowEl = e.target.closest(".row");
if (!rowEl) return;
if (e.target.closest("button") || e.target.closest("a")) return;
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
playIndex(indexOfRow(rowEl));
}
});
/* ---------- play all ---------- */
playAllBtn.addEventListener("click", function () {
if (activeIndex < 0) {
playIndex(0);
} else {
isPlaying = !isPlaying;
refreshAll();
toast(isPlaying ? "Resumed playlist" : "Paused");
}
});
/* ---------- ... menu ---------- */
var menuEl = null;
function closeMenu() {
if (menuEl) {
menuEl.remove();
menuEl = null;
document.removeEventListener("click", onDocClick, true);
document.removeEventListener("keydown", onMenuKey);
}
}
function onDocClick(e) {
if (menuEl && !menuEl.contains(e.target)) closeMenu();
}
function onMenuKey(e) {
if (e.key === "Escape") closeMenu();
}
function openMenu(btn, i) {
closeMenu();
var t = TRACKS[i];
menuEl = document.createElement("div");
menuEl.className = "menu open";
menuEl.setAttribute("role", "menu");
var items = [
{ label: "Add to queue", action: function () { toast("Queued · " + t.title); } },
{ label: "Go to artist", action: function () { toast("Artist · " + t.artist); } },
{ label: "Go to album", action: function () { toast("Album · " + t.album); } },
{ sep: true },
{ label: "Copy song link", action: function () { toast("Link copied to clipboard"); } }
];
items.forEach(function (it) {
if (it.sep) {
menuEl.appendChild(document.createElement("hr"));
return;
}
var b = document.createElement("button");
b.type = "button";
b.setAttribute("role", "menuitem");
b.textContent = it.label;
b.addEventListener("click", function () {
it.action();
closeMenu();
});
menuEl.appendChild(b);
});
document.body.appendChild(menuEl);
var r = btn.getBoundingClientRect();
var mw = menuEl.offsetWidth;
var left = Math.max(8, Math.min(r.right - mw, window.innerWidth - mw - 8));
var top = r.bottom + 6;
if (top + menuEl.offsetHeight > window.innerHeight - 8) {
top = r.top - menuEl.offsetHeight - 6;
}
menuEl.style.left = left + "px";
menuEl.style.top = top + "px";
setTimeout(function () {
document.addEventListener("click", onDocClick, true);
document.addEventListener("keydown", onMenuKey);
}, 0);
}
/* ---------- drag to reorder ---------- */
var dragRow = null;
rowsEl.addEventListener("dragstart", function (e) {
var handle = e.target.closest(".drag-handle");
if (!handle) {
e.preventDefault();
return;
}
dragRow = handle.closest(".row");
dragRow.classList.add("dragging");
e.dataTransfer.effectAllowed = "move";
try {
e.dataTransfer.setData("text/plain", dragRow.dataset.title);
} catch (err) {}
});
rowsEl.addEventListener("dragover", function (e) {
if (!dragRow) return;
e.preventDefault();
e.dataTransfer.dropEffect = "move";
var over = e.target.closest(".row");
Array.prototype.forEach.call(rowsEl.children, function (r) {
r.classList.toggle("drag-over", r === over && r !== dragRow);
});
if (over && over !== dragRow) {
var rect = over.getBoundingClientRect();
var after = e.clientY > rect.top + rect.height / 2;
rowsEl.insertBefore(dragRow, after ? over.nextSibling : over);
}
});
function endDrag() {
if (!dragRow) return;
dragRow.classList.remove("dragging");
Array.prototype.forEach.call(rowsEl.children, function (r) {
r.classList.remove("drag-over");
});
// rebuild TRACKS order from DOM (match by title)
var newOrder = [];
Array.prototype.forEach.call(rowsEl.children, function (r) {
var match = TRACKS.filter(function (t) { return t.title === r.dataset.title; })[0];
if (match) newOrder.push(match);
});
var activeTitle = activeIndex >= 0 ? TRACKS[activeIndex].title : null;
TRACKS.length = 0;
Array.prototype.push.apply(TRACKS, newOrder);
if (activeTitle) {
activeIndex = TRACKS.map(function (t) { return t.title; }).indexOf(activeTitle);
}
// re-number visible indices without full rebuild
Array.prototype.forEach.call(rowsEl.children, function (r, i) {
r.querySelector(".idx-num").textContent = i + 1;
});
refreshAll();
toast("Playlist reordered");
dragRow = null;
}
rowsEl.addEventListener("drop", function (e) {
e.preventDefault();
endDrag();
});
rowsEl.addEventListener("dragend", endDrag);
/* ---------- simulate slowly ticking play counts on active track ---------- */
setInterval(function () {
if (activeIndex >= 0 && isPlaying) {
TRACKS[activeIndex].plays += Math.floor(Math.random() * 4) + 1;
var rowEl = rowsEl.children[activeIndex];
if (rowEl) {
var pc = rowEl.querySelector(".cell-plays");
if (pc) pc.textContent = fmtPlays(TRACKS[activeIndex].plays);
}
}
}, 2500);
/* ---------- init ---------- */
render();
refreshAll();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Music — Track List Row</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=Inter:wght@400;500;600;700;800&display=swap"
rel="stylesheet"
/>
<link rel="stylesheet" href="style.css" />
</head>
<body>
<main class="stage">
<section class="tracklist" aria-label="Now playing tracklist">
<header class="tl-head">
<div class="tl-cover" aria-hidden="true">
<span class="tl-cover-shape s1"></span>
<span class="tl-cover-shape s2"></span>
<span class="tl-cover-shape s3"></span>
</div>
<div class="tl-meta">
<p class="tl-kicker">Playlist · 7 tracks · 26 min</p>
<h1 class="tl-title">Midnight Reservoir</h1>
<p class="tl-sub">Curated by <strong>Neon Tides</strong> · Synthpop after dark</p>
</div>
<button class="tl-playall" id="playAll" type="button" aria-pressed="false">
<svg class="ico-play" viewBox="0 0 24 24" aria-hidden="true"><path d="M8 5v14l11-7z"/></svg>
<svg class="ico-pause" viewBox="0 0 24 24" aria-hidden="true"><path d="M7 5h4v14H7zM13 5h4v14h-4z"/></svg>
<span class="tl-playall-label">Play</span>
</button>
</header>
<div class="tl-columns" aria-hidden="true">
<span class="c-idx">#</span>
<span class="c-title">Title</span>
<span class="c-album">Album</span>
<span class="c-plays">Plays</span>
<span class="c-dur">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm1 10.4 4 2.3-.8 1.4-4.9-2.8V7h1.7z"/></svg>
</span>
</div>
<ul class="tl-rows" id="rows" role="list">
<!-- rows injected by script.js -->
</ul>
<footer class="tl-foot">
<p>Drag the handle to reorder · click a row to play · ♥ to like</p>
</footer>
</section>
</main>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Track List Row
A compact, music-app tracklist built around a single reusable row. Seven rows sit under a glassy header with a CSS-drawn album cover, playlist metadata, and a morphing play/pause button. Each row shows an index number that swaps to a play control on hover, a small gradient album thumbnail, track title and artist, album name, a live play count, a like (heart) toggle, a more (…) menu, and a duration timestamp — laid out on a tidy CSS grid that collapses gracefully on narrow screens.
Clicking a row (or its play button) starts simulated playback: the active row picks up its cover-derived accent color, its index morphs into a four-bar animated equalizer, and any previously playing row resets. Liking a track pops a heart and fires a toast; the … menu opens a keyboard-dismissable popover with queue, artist, album, and copy-link actions. A drag handle lets you reorder the list with live re-numbering, and play counts tick upward while a track plays.
Everything is dark-first and theme-driven from CSS custom properties, with aria-pressed on the
play and like buttons, focusable rows that respond to Enter/Space, AA-contrast body text, and a
prefers-reduced-motion fallback. No frameworks, no images, and no audio — playback is faked with
timers and transforms.
Illustrative UI only — fictional artists, albums, tracks, and data. No real audio playback.