Music — Waveform / Progress Scrubber
A SoundCloud-style waveform progress scrubber built from CSS-drawn vertical bars. The played portion fills with the accent color while the rest stays muted, and a glowing draggable playhead tracks position with a time tooltip. Hover shows a preview line, click or drag seeks instantly, and a playback-speed selector changes how fast the bars fill. Includes a compact reusable variant inside a now-playing queue, all simulated with timers and no audio.
MCP
الكود
: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 rgba(0, 0, 0, 0.55);
--display: "Space Grotesk", "Inter", system-ui, sans-serif;
--body: "Inter", system-ui, sans-serif;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
background:
radial-gradient(1100px 620px at 78% -8%, rgba(139, 92, 246, 0.16), transparent 60%),
radial-gradient(900px 540px at 8% 108%, rgba(29, 185, 84, 0.12), transparent 58%),
var(--bg);
color: var(--text);
font-family: var(--body);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
padding: clamp(16px, 4vw, 48px);
display: flex;
justify-content: center;
}
.ico {
width: 18px;
height: 18px;
fill: currentColor;
flex: none;
}
/* ---------------- stage ---------------- */
.stage {
width: 100%;
max-width: 760px;
}
.stage__head {
margin-bottom: 26px;
}
.kicker {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--accent);
background: rgba(29, 185, 84, 0.12);
border: 1px solid rgba(29, 185, 84, 0.28);
padding: 5px 12px;
border-radius: var(--r-full);
}
.stage__title {
font-family: var(--display);
font-weight: 700;
font-size: clamp(28px, 6vw, 46px);
letter-spacing: -0.02em;
margin: 14px 0 8px;
}
.stage__sub {
color: var(--muted);
max-width: 56ch;
margin: 0;
font-size: 15px;
}
/* ---------------- player ---------------- */
.player {
position: relative;
background: linear-gradient(180deg, var(--surface), var(--bg-2));
border: 1px solid var(--line);
border-radius: var(--r-lg);
box-shadow: var(--shadow);
padding: clamp(18px, 3vw, 28px);
overflow: hidden;
}
.player::before {
content: "";
position: absolute;
inset: 0 0 auto 0;
height: 3px;
background: linear-gradient(90deg, var(--accent), var(--accent-2), var(--accent-3));
opacity: 0.8;
}
.player__top {
display: flex;
align-items: center;
gap: 18px;
margin-bottom: 24px;
}
/* CSS-drawn album cover */
.cover {
position: relative;
width: 92px;
height: 92px;
border-radius: var(--r-md);
overflow: hidden;
flex: none;
background: linear-gradient(135deg, #1f2937, #0f172a);
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
}
.cover[data-theme="reservoir"] {
background: linear-gradient(140deg, #0f2a3f, #143b5a 45%, #1db98a);
}
.cover__shape {
position: absolute;
border-radius: 50%;
filter: blur(2px);
mix-blend-mode: screen;
}
.cover__shape--1 {
width: 70px;
height: 70px;
left: -16px;
bottom: -20px;
background: radial-gradient(circle, rgba(29, 185, 84, 0.95), transparent 70%);
}
.cover__shape--2 {
width: 56px;
height: 56px;
right: -10px;
top: -12px;
background: radial-gradient(circle, rgba(139, 92, 246, 0.9), transparent 70%);
}
.cover__ring {
position: absolute;
inset: 30%;
border-radius: 50%;
border: 2px solid rgba(255, 255, 255, 0.35);
box-shadow: inset 0 0 0 6px rgba(255, 255, 255, 0.08);
}
.meta {
min-width: 0;
flex: 1;
}
.meta__badge {
display: inline-block;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
border: 1px solid var(--line);
border-radius: var(--r-full);
padding: 3px 9px;
margin-bottom: 8px;
}
.meta__title {
font-family: var(--display);
font-weight: 700;
font-size: clamp(20px, 4vw, 26px);
margin: 0 0 2px;
letter-spacing: -0.01em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.meta__artist {
margin: 0 0 8px;
color: var(--accent);
font-weight: 600;
font-size: 14px;
}
.meta__stats {
display: flex;
flex-wrap: wrap;
gap: 14px;
color: var(--muted);
font-size: 12.5px;
}
.stat {
display: inline-flex;
align-items: center;
gap: 5px;
}
.stat .ico {
width: 13px;
height: 13px;
opacity: 0.8;
}
.like {
flex: none;
width: 44px;
height: 44px;
border-radius: var(--r-full);
border: 1px solid var(--line);
background: var(--surface-2);
color: var(--muted);
cursor: pointer;
display: grid;
place-items: center;
transition: transform 0.15s ease, color 0.2s ease, border-color 0.2s ease, background 0.2s ease;
}
.like:hover {
color: var(--text);
border-color: var(--line-2);
}
.like[aria-pressed="true"] {
color: var(--accent-3);
border-color: rgba(255, 61, 113, 0.5);
background: rgba(255, 61, 113, 0.12);
}
.like:active {
transform: scale(0.9);
}
/* ---------------- scrubber ---------------- */
.scrub {
position: relative;
height: 86px;
margin: 6px 0 18px;
cursor: pointer;
touch-action: none;
outline: none;
}
.scrub:focus-visible {
box-shadow: 0 0 0 2px var(--bg), 0 0 0 4px var(--accent-2);
border-radius: var(--r-sm);
}
.scrub__bars {
position: absolute;
inset: 0;
display: flex;
align-items: center;
gap: 2px;
height: 100%;
}
.bar {
flex: 1 1 0;
min-width: 2px;
border-radius: 2px;
background: var(--surface-2);
position: relative;
transition: background 0.12s linear, transform 0.12s ease;
}
/* played portion */
.bar.is-played {
background: linear-gradient(180deg, var(--accent), #15863e);
}
/* the bar currently passing under the playhead pulses */
.bar.is-cursor {
background: linear-gradient(180deg, #4cf08a, var(--accent));
transform: scaleY(1.06);
}
.scrub:hover .bar:not(.is-played) {
background: #2c2c39;
}
.scrub__head {
position: absolute;
top: 0;
bottom: 0;
width: 2px;
background: #eafff2;
box-shadow: 0 0 10px rgba(29, 185, 84, 0.9);
transform: translateX(-50%);
left: 0;
pointer-events: none;
z-index: 3;
}
.scrub__head::after {
content: "";
position: absolute;
top: 50%;
left: 50%;
width: 12px;
height: 12px;
border-radius: 50%;
background: #eafff2;
transform: translate(-50%, -50%);
box-shadow: 0 0 10px rgba(29, 185, 84, 0.9);
}
.scrub__hover {
position: absolute;
top: 0;
bottom: 0;
width: 1px;
background: var(--line-2);
left: 0;
transform: translateX(-50%);
opacity: 0;
pointer-events: none;
z-index: 2;
}
.scrub.is-hovering .scrub__hover {
opacity: 1;
}
.scrub__tip {
position: absolute;
bottom: calc(100% + 8px);
left: 0;
transform: translateX(-50%);
background: var(--text);
color: var(--bg);
font-size: 11px;
font-weight: 700;
font-variant-numeric: tabular-nums;
padding: 3px 7px;
border-radius: var(--r-sm);
opacity: 0;
transition: opacity 0.12s ease;
pointer-events: none;
white-space: nowrap;
z-index: 4;
}
.scrub__tip::after {
content: "";
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: var(--text);
}
.scrub.is-hovering .scrub__tip,
.scrub.is-dragging .scrub__tip {
opacity: 1;
}
/* ---------------- controls ---------------- */
.controls {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
}
.controls__left {
display: flex;
align-items: center;
gap: 10px;
}
.iconbtn {
width: 40px;
height: 40px;
border-radius: var(--r-full);
border: 1px solid var(--line);
background: transparent;
color: var(--muted);
cursor: pointer;
display: grid;
place-items: center;
transition: color 0.18s ease, border-color 0.18s ease, transform 0.12s ease;
}
.iconbtn:hover {
color: var(--text);
border-color: var(--line-2);
}
.iconbtn:active {
transform: scale(0.92);
}
.playbtn {
width: 56px;
height: 56px;
border-radius: var(--r-full);
border: none;
cursor: pointer;
background: linear-gradient(135deg, var(--accent), #16a34a);
color: #03130a;
display: grid;
place-items: center;
box-shadow: 0 8px 24px rgba(29, 185, 84, 0.4);
transition: transform 0.14s ease, box-shadow 0.2s ease;
}
.playbtn:hover {
transform: scale(1.05);
}
.playbtn:active {
transform: scale(0.95);
}
.playbtn__icon {
width: 26px;
height: 26px;
fill: currentColor;
}
.playbtn__pause {
display: none;
}
.playbtn[aria-pressed="true"] .playbtn__play {
display: none;
}
.playbtn[aria-pressed="true"] .playbtn__pause {
display: block;
}
.controls__time {
display: flex;
align-items: center;
gap: 6px;
font-variant-numeric: tabular-nums;
font-weight: 600;
font-size: 14px;
}
.time--current {
color: var(--text);
}
.time--total,
.time__sep {
color: var(--muted);
}
.controls__speed {
margin-left: auto;
display: flex;
align-items: center;
gap: 12px;
}
/* equalizer */
.eq {
display: inline-flex;
align-items: flex-end;
gap: 3px;
height: 20px;
}
.eq i {
width: 3px;
height: 6px;
border-radius: 2px;
background: var(--muted);
transition: background 0.2s ease;
}
.eq.is-on i {
background: var(--accent);
animation: eq 0.9s ease-in-out infinite;
}
.eq.is-on i:nth-child(2) {
animation-delay: 0.18s;
}
.eq.is-on i:nth-child(3) {
animation-delay: 0.36s;
}
.eq.is-on i:nth-child(4) {
animation-delay: 0.54s;
}
@keyframes eq {
0%,
100% {
height: 5px;
}
50% {
height: 18px;
}
}
.speed {
display: inline-flex;
align-items: center;
gap: 8px;
}
.speed__label {
font-size: 12px;
color: var(--muted);
font-weight: 600;
}
.speed select {
appearance: none;
background: var(--surface-2);
color: var(--text);
border: 1px solid var(--line);
border-radius: var(--r-sm);
padding: 6px 26px 6px 10px;
font-family: inherit;
font-weight: 600;
font-size: 13px;
cursor: pointer;
background-image: linear-gradient(45deg, transparent 50%, var(--muted) 50%),
linear-gradient(135deg, var(--muted) 50%, transparent 50%);
background-position: calc(100% - 14px) 52%, calc(100% - 9px) 52%;
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
}
.speed select:hover {
border-color: var(--line-2);
}
.speed select:focus-visible {
outline: 2px solid var(--accent-2);
outline-offset: 1px;
}
/* ---------------- mini queue ---------------- */
.queue {
margin-top: 28px;
}
.queue__head {
font-family: var(--display);
font-weight: 600;
font-size: 14px;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--muted);
margin: 0 0 14px;
}
.queue__list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 10px;
}
.qitem {
display: flex;
align-items: center;
gap: 14px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 12px 14px;
transition: border-color 0.2s ease, background 0.2s ease;
}
.qitem:hover {
border-color: var(--line-2);
background: var(--surface-2);
}
.qitem.is-active {
border-color: rgba(29, 185, 84, 0.45);
background: rgba(29, 185, 84, 0.06);
}
.qitem__play {
width: 38px;
height: 38px;
flex: none;
border-radius: var(--r-sm);
border: none;
cursor: pointer;
display: grid;
place-items: center;
color: var(--text);
position: relative;
overflow: hidden;
}
.qitem__play[aria-pressed="true"] {
color: #03130a;
}
.qitem__play .pause {
display: none;
}
.qitem__play[aria-pressed="true"] .play {
display: none;
}
.qitem__play[aria-pressed="true"] .pause {
display: block;
}
.qitem__main {
min-width: 0;
flex: 1;
}
.qitem__name {
font-weight: 600;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.qitem__artist {
color: var(--muted);
font-size: 12.5px;
}
/* compact waveform inside queue item */
.qwave {
flex: 1 1 90px;
min-width: 70px;
max-width: 200px;
height: 30px;
display: flex;
align-items: center;
gap: 1.5px;
cursor: pointer;
}
.qwave .bar {
min-width: 1.5px;
}
.qitem__dur {
font-variant-numeric: tabular-nums;
color: var(--muted);
font-size: 12.5px;
font-weight: 600;
flex: none;
}
/* ---------------- toast ---------------- */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translateX(-50%) translateY(20px);
background: var(--surface-2);
color: var(--text);
border: 1px solid var(--line-2);
border-radius: var(--r-full);
padding: 10px 18px;
font-size: 13px;
font-weight: 600;
box-shadow: var(--shadow);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease, transform 0.25s ease;
z-index: 50;
}
.toast.is-shown {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* ---------------- responsive ---------------- */
@media (max-width: 520px) {
body {
padding: 14px;
}
.player__top {
gap: 12px;
}
.cover {
width: 64px;
height: 64px;
}
.meta__stats {
gap: 10px;
}
.controls {
gap: 10px;
}
.controls__speed {
margin-left: 0;
width: 100%;
justify-content: space-between;
}
.speed__label {
display: none;
}
.scrub {
height: 70px;
}
.qwave {
display: none;
}
}/* ============================================================
Music — Waveform / Progress Scrubber
Vanilla JS, no audio. Playback is simulated with timers.
============================================================ */
(function () {
"use strict";
/* ---------- helpers ---------- */
function fmt(sec) {
sec = Math.max(0, Math.floor(sec));
var m = Math.floor(sec / 60);
var s = sec % 60;
return m + ":" + (s < 10 ? "0" : "") + s;
}
function clamp(n, lo, hi) {
return Math.min(hi, Math.max(lo, n));
}
/* Deterministic pseudo-random bar pattern from a seed (mulberry32). */
function makeBars(seed, count) {
var t = seed >>> 0;
function rnd() {
t += 0x6d2b79f5;
var x = t;
x = Math.imul(x ^ (x >>> 15), x | 1);
x ^= x + Math.imul(x ^ (x >>> 7), x | 61);
return ((x ^ (x >>> 14)) >>> 0) / 4294967296;
}
var out = [];
for (var i = 0; i < count; i++) {
// blend a slow sine envelope with noise so it looks like a real waveform
var env = 0.45 + 0.4 * Math.sin((i / count) * Math.PI * 3.2 + seed);
var h = clamp(Math.abs(env) * 0.7 + rnd() * 0.45, 0.12, 1);
out.push(h);
}
return out;
}
/* toast helper */
var toastEl = document.getElementById("toast");
var toastTimer;
function toast(msg) {
if (!toastEl) return;
toastEl.textContent = msg;
toastEl.classList.add("is-shown");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("is-shown");
}, 1800);
}
/* ============================================================
Reusable Waveform Scrubber controller
============================================================ */
function Scrubber(opts) {
var self = this;
this.root = opts.root;
this.barsEl = opts.barsEl;
this.headEl = opts.headEl || null;
this.hoverEl = opts.hoverEl || null;
this.tipEl = opts.tipEl || null;
this.duration = opts.duration;
this.barCount = opts.barCount;
this.onSeek = opts.onSeek || function () {};
this.bars = [];
this.progress = 0; // 0..1
var heights = makeBars(opts.seed, this.barCount);
var frag = document.createDocumentFragment();
heights.forEach(function (h) {
var b = document.createElement("span");
b.className = "bar";
b.style.height = Math.round(h * 100) + "%";
frag.appendChild(b);
self.bars.push(b);
});
this.barsEl.appendChild(frag);
/* ---- pointer geometry ---- */
function ratioFromEvent(e) {
var rect = self.root.getBoundingClientRect();
var x = (e.touches ? e.touches[0].clientX : e.clientX) - rect.left;
return clamp(x / rect.width, 0, 1);
}
this.dragging = false;
function down(e) {
self.dragging = true;
self.root.classList.add("is-dragging");
apply(ratioFromEvent(e), true);
window.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
e.preventDefault();
}
function move(e) {
if (!self.dragging) return;
apply(ratioFromEvent(e), true);
}
function up() {
self.dragging = false;
self.root.classList.remove("is-dragging");
window.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up);
}
function apply(ratio, fromUser) {
self.set(ratio);
if (fromUser) self.onSeek(ratio * self.duration);
}
/* ---- hover preview ---- */
this.root.addEventListener("pointermove", function (e) {
if (e.pointerType === "touch") return;
var r = ratioFromEvent(e);
self.root.classList.add("is-hovering");
if (self.hoverEl) self.hoverEl.style.left = r * 100 + "%";
if (self.tipEl) {
self.tipEl.style.left = r * 100 + "%";
self.tipEl.textContent = fmt(r * self.duration);
}
});
this.root.addEventListener("pointerleave", function () {
self.root.classList.remove("is-hovering");
});
this.root.addEventListener("pointerdown", down);
/* ---- keyboard (role=slider) ---- */
this.root.addEventListener("keydown", function (e) {
var step = e.shiftKey ? 0.1 : 0.02;
var r = self.progress;
if (e.key === "ArrowRight" || e.key === "ArrowUp") r += step;
else if (e.key === "ArrowLeft" || e.key === "ArrowDown") r -= step;
else if (e.key === "Home") r = 0;
else if (e.key === "End") r = 1;
else return;
e.preventDefault();
r = clamp(r, 0, 1);
self.set(r);
self.onSeek(r * self.duration);
});
this.set(0);
}
Scrubber.prototype.set = function (ratio) {
this.progress = clamp(ratio, 0, 1);
var cursorIdx = Math.round(this.progress * (this.bars.length - 1));
for (var i = 0; i < this.bars.length; i++) {
var played = i / (this.bars.length - 1) <= this.progress;
this.bars[i].classList.toggle("is-played", played);
this.bars[i].classList.toggle("is-cursor", i === cursorIdx && this.progress > 0 && this.progress < 1);
}
if (this.headEl) this.headEl.style.left = this.progress * 100 + "%";
if (this.root.getAttribute("role") === "slider") {
var pct = Math.round(this.progress * 100);
this.root.setAttribute("aria-valuenow", String(pct));
this.root.setAttribute(
"aria-valuetext",
fmt(this.progress * this.duration) + " of " + fmt(this.duration)
);
}
};
/* ============================================================
Primary player — simulated playback engine
============================================================ */
var PRIMARY_DURATION = 222; // 3:42
var scrub = new Scrubber({
root: document.getElementById("scrub"),
barsEl: document.getElementById("scrubBars"),
headEl: document.getElementById("scrubHead"),
hoverEl: document.getElementById("scrubHover"),
tipEl: document.getElementById("scrubTip"),
duration: PRIMARY_DURATION,
barCount: 96,
seed: 1337,
onSeek: function (sec) {
player.current = sec;
player.render();
}
});
var player = {
current: 0,
duration: PRIMARY_DURATION,
playing: false,
speed: 1,
raf: null,
last: 0,
render: function () {
curTime.textContent = fmt(this.current);
if (!scrub.dragging) scrub.set(this.current / this.duration);
},
tick: function (ts) {
if (!this.playing) return;
if (!this.last) this.last = ts;
var dt = (ts - this.last) / 1000;
this.last = ts;
this.current += dt * this.speed;
if (this.current >= this.duration) {
this.current = this.duration;
this.render();
this.pause();
toast("Track finished");
// auto-reset shortly after, like a real player ending
return;
}
this.render();
this.raf = requestAnimationFrame(this.tick.bind(this));
},
play: function () {
if (this.current >= this.duration) this.current = 0;
this.playing = true;
this.last = 0;
playBtn.setAttribute("aria-pressed", "true");
playBtn.setAttribute("aria-label", "Pause");
eq.classList.add("is-on");
this.raf = requestAnimationFrame(this.tick.bind(this));
// pause any queue item that was acting as the active source
stopAllQueue();
},
pause: function () {
this.playing = false;
cancelAnimationFrame(this.raf);
playBtn.setAttribute("aria-pressed", "false");
playBtn.setAttribute("aria-label", "Play");
eq.classList.remove("is-on");
},
toggle: function () {
this.playing ? this.pause() : this.play();
},
seekBy: function (delta) {
this.current = clamp(this.current + delta, 0, this.duration);
this.render();
}
};
var playBtn = document.getElementById("play");
var curTime = document.getElementById("curTime");
var totTime = document.getElementById("totTime");
var eq = document.getElementById("eq");
totTime.textContent = fmt(PRIMARY_DURATION);
playBtn.addEventListener("click", function () {
player.toggle();
});
document.getElementById("rewind").addEventListener("click", function () {
player.seekBy(-10);
toast("◀ 10s");
});
document.getElementById("forward").addEventListener("click", function () {
player.seekBy(10);
toast("10s ▶");
});
document.getElementById("speed").addEventListener("change", function (e) {
player.speed = parseFloat(e.target.value);
toast("Speed " + e.target.value + "×");
});
/* like toggle */
var likeBtn = document.querySelector(".like");
likeBtn.addEventListener("click", function () {
var on = likeBtn.getAttribute("aria-pressed") === "true";
likeBtn.setAttribute("aria-pressed", String(!on));
toast(on ? "Removed from Liked" : "Added to Liked Songs");
});
player.render();
/* ============================================================
Mini queue — each row has its own compact scrubber
============================================================ */
var TRACKS = [
{ name: "Paper Lanterns", artist: "Velvet Static", dur: 198, seed: 21, theme: "#8b5cf6" },
{ name: "Glass Harbor", artist: "Neon Tides", dur: 245, seed: 88, theme: "#1db954" },
{ name: "Cobalt Hour", artist: "Halcyon Drift", dur: 176, seed: 305, theme: "#ff3d71" },
{ name: "Slow Static", artist: "Velvet Static", dur: 212, seed: 412, theme: "#38bdf8" }
];
var queueEl = document.getElementById("queue");
var queueControllers = [];
function stopAllQueue() {
queueControllers.forEach(function (c) {
if (c.playing) c.stop();
});
}
TRACKS.forEach(function (track, idx) {
var li = document.createElement("li");
li.className = "qitem";
var btn = document.createElement("button");
btn.className = "qitem__play";
btn.type = "button";
btn.setAttribute("aria-pressed", "false");
btn.setAttribute("aria-label", "Play " + track.name);
btn.style.background = track.theme;
btn.innerHTML =
'<svg class="play ico" viewBox="0 0 24 24"><path d="M8 5v14l11-7z"/></svg>' +
'<svg class="pause ico" viewBox="0 0 24 24"><path d="M7 5h3v14H7zM14 5h3v14h-3z"/></svg>';
var main = document.createElement("div");
main.className = "qitem__main";
main.innerHTML =
'<div class="qitem__name">' + track.name + "</div>" +
'<div class="qitem__artist">' + track.artist + "</div>";
var wave = document.createElement("div");
wave.className = "qwave";
wave.setAttribute("role", "slider");
wave.setAttribute("tabindex", "0");
wave.setAttribute("aria-label", "Seek " + track.name);
wave.setAttribute("aria-valuemin", "0");
wave.setAttribute("aria-valuemax", "100");
wave.setAttribute("aria-valuenow", "0");
var dur = document.createElement("span");
dur.className = "qitem__dur";
dur.textContent = fmt(track.dur);
li.appendChild(btn);
li.appendChild(main);
li.appendChild(wave);
li.appendChild(dur);
queueEl.appendChild(li);
var ctrl = {
playing: false,
current: 0,
timer: null,
scrub: null,
stop: function () {
this.playing = false;
clearInterval(this.timer);
btn.setAttribute("aria-pressed", "false");
li.classList.remove("is-active");
},
start: function () {
// exclusive playback: stop primary + other queue items
player.pause();
stopAllQueue();
this.playing = true;
if (this.current >= track.dur) this.current = 0;
btn.setAttribute("aria-pressed", "true");
li.classList.add("is-active");
var c = this;
this.timer = setInterval(function () {
c.current += 0.25;
if (c.current >= track.dur) {
c.current = track.dur;
c.scrub.set(1);
dur.textContent = fmt(track.dur);
c.stop();
return;
}
c.scrub.set(c.current / track.dur);
dur.textContent = fmt(track.dur - c.current);
}, 100);
}
};
ctrl.scrub = new Scrubber({
root: wave,
barsEl: wave,
duration: track.dur,
barCount: 48,
seed: track.seed,
onSeek: function (sec) {
ctrl.current = sec;
dur.textContent = fmt(track.dur - sec);
}
});
btn.addEventListener("click", function () {
if (ctrl.playing) {
ctrl.stop();
} else {
ctrl.start();
toast("Playing · " + track.name);
}
});
queueControllers.push(ctrl);
});
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Music — Waveform / Progress Scrubber</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">
<header class="stage__head">
<span class="kicker">Now Playing</span>
<h1 class="stage__title">Waveform Scrubber</h1>
<p class="stage__sub">A SoundCloud-style progress scrubber with draggable playhead, hover preview, and variable playback speed.</p>
</header>
<!-- ============ PRIMARY PLAYER ============ -->
<section class="player" aria-label="Now playing track">
<div class="player__top">
<div class="cover" data-theme="reservoir" aria-hidden="true">
<span class="cover__shape cover__shape--1"></span>
<span class="cover__shape cover__shape--2"></span>
<span class="cover__ring"></span>
</div>
<div class="meta">
<span class="meta__badge">Single · Lossless</span>
<h2 class="meta__title">Midnight Reservoir</h2>
<p class="meta__artist">Neon Tides</p>
<div class="meta__stats">
<span class="stat"><svg viewBox="0 0 24 24" class="ico"><path d="M8 5v14l11-7z"/></svg> 1,284,902 plays</span>
<span class="stat"><svg viewBox="0 0 24 24" class="ico"><path d="M12 21s-7.5-4.6-10-9.2C.7 9.1 1.6 5.7 4.6 4.7 7 3.9 9 5.1 12 8c3-2.9 5-4.1 7.4-3.3 3 .9 3.9 4.4 2.6 7.1C19.5 16.4 12 21 12 21z"/></svg> 42.1k</span>
</div>
</div>
<button class="like" type="button" aria-pressed="false" aria-label="Like this track">
<svg viewBox="0 0 24 24" class="ico"><path d="M12 21s-7.5-4.6-10-9.2C.7 9.1 1.6 5.7 4.6 4.7 7 3.9 9 5.1 12 8c3-2.9 5-4.1 7.4-3.3 3 .9 3.9 4.4 2.6 7.1C19.5 16.4 12 21 12 21z"/></svg>
</button>
</div>
<!-- The scrubber: tooltip + hover preview + bars are injected by JS -->
<div
class="scrub"
id="scrub"
role="slider"
tabindex="0"
aria-label="Seek through track"
aria-valuemin="0"
aria-valuemax="100"
aria-valuenow="0"
aria-valuetext="0:00 of 3:42"
>
<div class="scrub__hover" id="scrubHover" aria-hidden="true"></div>
<div class="scrub__tip" id="scrubTip" aria-hidden="true">0:00</div>
<div class="scrub__bars" id="scrubBars"></div>
<div class="scrub__head" id="scrubHead" aria-hidden="true"></div>
</div>
<div class="controls">
<div class="controls__left">
<button class="iconbtn" id="rewind" type="button" aria-label="Rewind 10 seconds">
<svg viewBox="0 0 24 24" class="ico"><path d="M12 5V1L7 6l5 5V7a5 5 0 1 1-5 5H5a7 7 0 1 0 7-7z"/></svg>
</button>
<button class="playbtn" id="play" type="button" aria-pressed="false" aria-label="Play">
<svg class="playbtn__icon" viewBox="0 0 24 24" aria-hidden="true">
<path class="playbtn__play" d="M8 5v14l11-7z"/>
<path class="playbtn__pause" d="M7 5h3v14H7zM14 5h3v14h-3z"/>
</svg>
</button>
<button class="iconbtn" id="forward" type="button" aria-label="Forward 10 seconds">
<svg viewBox="0 0 24 24" class="ico"><path d="M12 5V1l5 5-5 5V7a5 5 0 1 0 5 5h2a7 7 0 1 1-7-7z"/></svg>
</button>
</div>
<div class="controls__time">
<span class="time time--current" id="curTime">0:00</span>
<span class="time__sep">/</span>
<span class="time time--total" id="totTime">3:42</span>
</div>
<div class="controls__speed">
<span class="eq" id="eq" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
<label class="speed">
<span class="speed__label">Speed</span>
<select id="speed" aria-label="Playback speed">
<option value="0.5">0.5×</option>
<option value="0.75">0.75×</option>
<option value="1" selected>1×</option>
<option value="1.25">1.25×</option>
<option value="1.5">1.5×</option>
<option value="2">2×</option>
</select>
</label>
</div>
</div>
</section>
<!-- ============ MINI QUEUE — compact scrubbers ============ -->
<section class="queue" aria-label="Up next">
<h3 class="queue__head">Up next in the queue</h3>
<ul class="queue__list" id="queue"></ul>
</section>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
</main>
<script src="script.js"></script>
</body>
</html>Waveform / Progress Scrubber
A reusable, SoundCloud-style waveform scrubber drawn entirely from CSS bars — no canvas, no images, no audio files. A deterministic generator blends a slow sine envelope with seeded noise so each track gets its own stable, real-looking waveform. The played portion fills with the accent color while the remainder stays muted; a glowing playhead with a tabular-numerals time tooltip marks the current position, and hovering anywhere over the bars reveals a thin preview line plus a timestamp bubble for the spot under the cursor.
Playback is simulated with requestAnimationFrame: the bars fill in real time, the play/pause button morphs between glyphs, an equalizer animates while playing, and the speed selector (0.5×–2×) changes the fill rate directly. You can seek by clicking or dragging the playhead, jump ±10s with the skip buttons, or drive everything from the keyboard — the scrubber exposes role="slider" with arrow-key, Shift, Home and End support and live aria-valuetext.
The same Scrubber controller powers a compact variant inside the now-playing queue, where each row carries its own miniature waveform, independent play/pause, and a live countdown duration. Starting any source pauses the others, so the demo behaves like a single coherent player. A small toast() helper surfaces actions like seeking, liking, and track completion.
Illustrative UI only — fictional artists, albums, tracks, and data. No real audio playback.