Blinding LightsThe Weeknd

Music Cover Deck

A music carousel whose current artwork paints the page — covers turn on a 3D deck while a WebGL field bleeds each record's colour into the next.

A free, copy-paste sliders component for React, Next.js — built with Tailwind CSS, installable with the shadcn CLI.

Configuration

Tweak these live from theConfigurecontrol in the preview.

blur2–8 — default 5.2.
spread0.3–1 — default 0.56.
tilt0–60deg — default 34deg.
ease0.04–0.4 — default 0.12.
accentcolour — default #ffffff.
showPlayeron / off — default on.

Interaction Type

  • Drag, scroll or tap a cover to bring it forward
  • The background bleeds between the two nearest covers as you swipe
  • Arrow keys step tracks; the player bar drives playback

Dependency

No dependencies — plain React. Just drop the file in.

Point the CLI here once

A namespace is a local alias, so the CLI has to be told what @harshdev-ui resolves to. One command, once per project:

npx shadcn registry add "@harshdev-ui=https://ui.harshpandav.dev/r/{name}.json"

After that every component installs by name. To skip it entirely, pass the URL straight to the CLI — shadcn add https://ui.harshpandav.dev/r/music-cover-deck.json — or copy the file from Source code below.

[ shadcn CLI 3.0 registry ]…or add the component in one command

Install via CLI

pnpm dlx shadcn add @harshdev-ui/music-cover-deck

How to use

Give Music Cover Deck a list of tracks — title, artist and a square cover each — and it lays them on a 3D deck: the focused record square to the viewer, its neighbours turned away and pushed back. Drag, scroll, press the arrow keys, or tap a cover to bring it forward.

Behind the deck a WebGL field samples the two nearest covers at a deep mip level and mixes them by the exact swipe position, so the whole surface bleeds from one record's colour into the next while your finger is still moving. Swipe velocity smears that field sideways and a slow warp keeps it drifting. Where WebGL is unavailable the same idea falls back to blurred layers.

Basic usage

import { MusicCoverDeck } from "@/components/harsh-ui/music-cover-deck";

const tracks = [
  { title: "Midnight Drive", artist: "Neon Atlas", cover: "/covers/midnight.jpg" },
  { title: "Ember Season", artist: "Hana Ito", cover: "/covers/ember.jpg" },
  { title: "Violet Hour", artist: "Sable Coast", cover: "/covers/violet.jpg" },
];

const Demo = () => <MusicCoverDeck tracks={tracks} className="h-screen w-full" />;

Tighter deck, deeper field

// covers closer together, turned harder, and a softer background
<MusicCoverDeck tracks={tracks} spread={96} tilt={46} blur={6.4} ease={0.08} />

Artwork only

// drop the transport bar and let the deck fill the frame
<MusicCoverDeck tracks={tracks} showPlayer={false} />

Demo

import { MusicCoverDeckDemo } from "@/components/demos/music-cover-deck-demo";

const Demo = () => (
  <div className="h-screen w-full">
    <MusicCoverDeckDemo />
  </div>
);

Props

tracks{ title, artist, cover }[] — square artwork reads best. Required.
defaultIndexWhich track is focused on first render. Default 0.
blurHow deep the background samples the mip chain — higher is softer. Default 5.2.
spread / tiltDistance between covers as a fraction of the card width, and how far each neighbour turns from the viewer. Defaults 0.56 / 34. The card itself is sized from the measured box, so the deck keeps its proportions on a phone, a tablet and a full page.
easeChase per frame, 0–1. Lower is heavier and slower to settle. Default 0.12.
accentColour of the progress line in the player bar.
showPlayerShow the transport bar. Default true.
autoPlayStep the deck on a timer with no pointer — for previews.

Notes

  • The background blur is a mip lookup, not a kernel: the covers are uploaded with a full mip chain and the shader samples a deep level through texture2D's LOD bias, so a heavy blur costs three texture reads a fragment instead of dozens.
  • It mixes the two nearest covers by the fractional deck position, which is why the colour keeps moving while you drag rather than cutting over on release. Swipe velocity offsets those samples sideways for a smear, and a slow sine warp drifts the field while the deck is in motion.
  • The deck itself is CSS 3D, not WebGL — perspective and rotateY keep the artwork crisp, the titles selectable and the covers real images, which a shader would only make worse.
  • One eased float drives everything: the card transforms are written straight to the nodes and the same value becomes the shader's mix, so React re-renders only when the focused track changes.
  • The field is drawn at roughly a third of the layout size and stretched by CSS. It is a heavy blur, so the difference is invisible, and it cuts the shader's fragment work by an order of magnitude — which is what keeps a full-bleed background cheap. The loop also parks once the deck settles, and an IntersectionObserver stops it entirely off-screen.
  • Falls back to blurred <img> layers where WebGL is unavailable, and prefers-reduced-motion snaps to the selection instead of easing.
  • Inspired by the cover carousel in Apple Music's now-playing view. The deck-with-colour-bleed idea is theirs; the mip-sampled field, the velocity smear, the CSS 3D deck and the fully configurable geometry are this version's.

Source code

The complete music-cover-deck.tsx — toggle TSX / JSX to copy it in either language.

Source code
"use client";

import { useCallback, useEffect, useRef, useState } from "react";

/**
 * Harsh Dev UI · Developed by Harsh Pandav
 * https://harshpandav.dev · hello@harshpandav.dev
 *
 * MusicCoverDeck
 * A music carousel where the artwork you are on paints the whole surface. The
 * covers sit on a CSS 3D deck — the focused one square to the viewer, its
 * neighbours turned away and pushed back — while behind them a WebGL field
 * samples the two nearest covers at a deep mip level and mixes them by the exact
 * swipe position, so the background bleeds from one record's colour into the
 * next as you drag rather than cutting between them. Swipe velocity smears that
 * field sideways and a slow warp keeps it breathing.
 *
 * Drag, wheel, arrow keys, or click a cover to bring it forward. The deck runs
 * off one eased float written straight to the nodes, so React only re-renders
 * when the selection changes. Falls back to blurred <img> layers where WebGL is
 * unavailable, and drops the motion under prefers-reduced-motion.
 */

export type MusicTrack = {
  title: string;
  artist: string;
  cover: string;
};

export type MusicCoverDeckProps = {
  tracks: MusicTrack[];
  defaultIndex?: number;
  /** how deep the background blur reads, roughly a mip level */
  blur?: number;
  /** distance between covers as a fraction of the card width */
  spread?: number;
  /** degrees each neighbour turns away from the viewer */
  tilt?: number;
  /** 0–1 chase per frame; lower is heavier */
  ease?: number;
  accent?: string;
  showPlayer?: boolean;
  autoPlay?: boolean;
  className?: string;
};

const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));



const VERT = `attribute vec2 p;varying vec2 v;void main(){v=p*0.5+0.5;gl_Position=vec4(p,0.,1.);}`;

// Blur comes from sampling a deep mip rather than a multi-tap kernel: one bias
// per sample, so the whole field costs a handful of texture reads a frame.
const FRAG = `precision mediump float;
varying vec2 v;
uniform sampler2D a,b;
uniform float mixAmt,lod,time,vel,ar;
vec2 cover(vec2 uv){
  vec2 c=uv-0.5;
  if(ar>1.0) c.x*=ar; else c.y/=ar;
  return c*0.62+0.5;
}
vec3 grab(sampler2D t,vec2 uv){
  vec3 s=texture2D(t,uv,lod).rgb;
  s+=texture2D(t,uv+vec2(vel*0.06,0.0),lod).rgb;
  s+=texture2D(t,uv-vec2(vel*0.06,0.0),lod).rgb;
  return s/3.0;
}
void main(){
  vec2 uv=cover(v);
  uv+=vec2(sin(time*0.21+v.y*3.1),cos(time*0.17+v.x*2.7))*0.014;
  vec3 col=mix(grab(a,uv),grab(b,uv),mixAmt);
  col=mix(vec3(dot(col,vec3(0.299,0.587,0.114))),col,1.65);
  col=pow(col,vec3(0.94));
  float d=distance(v,vec2(0.5));
  col*=1.0-d*0.42;
  gl_FragColor=vec4(col,1.0);
}`;

function compile(gl: WebGLRenderingContext, src: string, type: number) {
  const sh = gl.createShader(type)!;
  gl.shaderSource(sh, src);
  gl.compileShader(sh);
  return sh;
}

export function MusicCoverDeck({
  tracks,
  defaultIndex = 0,
  blur = 5.2,
  spread = 0.56,
  tilt = 34,
  ease = 0.12,
  accent = "#ffffff",
  showPlayer = true,
  autoPlay = false,
  className = "",
}: MusicCoverDeckProps) {
  const N = tracks.length;
  const start0 = clamp(Math.round(defaultIndex), 0, Math.max(0, N - 1));
  const [active, setActive] = useState(start0);
  const [playing, setPlaying] = useState(autoPlay);
  // autoPlay can flip on a live instance (a preview card toggles it on hover),
  // so the play state follows it instead of reading it only once at mount.
  const [prevAutoPlay, setPrevAutoPlay] = useState(autoPlay);
  if (prevAutoPlay !== autoPlay) {
    setPrevAutoPlay(autoPlay);
    setPlaying(autoPlay);
  }
  const [reduce, setReduce] = useState(false);
  const [glReady, setGlReady] = useState(false);
  const [box, setBox] = useState({ w: 0, h: 0 });

  const rootRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const cardRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const fadeRefs = useRef<(HTMLDivElement | null)[]>([]);
  const pos = useRef(start0);
  const target = useRef(start0);
  const vel = useRef(0);

  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    const update = () => setReduce(mq.matches);
    update();
    mq.addEventListener("change", update);
    return () => mq.removeEventListener("change", update);
  }, []);

  const goTo = useCallback((i: number) => {
    target.current = clamp(i, 0, N - 1);
  }, [N]);

  const narrow = box.w > 0 && box.w < 640;
  const barSpace = showPlayer ? (narrow ? 96 : 116) : 0;
  const deckH = Math.max(120, box.h - barSpace);
  const card = box.w
    ? narrow
      ? clamp(Math.min(box.w * 0.6, deckH * 0.62), 150, 260)
      : clamp(Math.min(box.w * 0.3, deckH * 0.66), 190, 330)
    : 220;
  const gap = card * clamp(spread, 0.28, 1.1);

  useEffect(() => {
    const root = rootRef.current;
    const canvas = canvasRef.current;
    if (!root) return;

    let w = root.clientWidth;
    let h = root.clientHeight;
    const measure = () => {
      w = root.clientWidth;
      h = root.clientHeight;
      setBox((b) => (b.w === w && b.h === h ? b : { w, h }));
    };

    const gl = canvas?.getContext("webgl", {
      alpha: false,
      antialias: false,
      powerPreference: "low-power",
    }) as WebGLRenderingContext | null;

    let prog: WebGLProgram | null = null;
    let textures: WebGLTexture[] = [];
    let loaded = 0;
    const loc: Record<string, WebGLUniformLocation | null> = {};

    if (gl && canvas) {
      prog = gl.createProgram()!;
      gl.attachShader(prog, compile(gl, VERT, gl.VERTEX_SHADER));
      gl.attachShader(prog, compile(gl, FRAG, gl.FRAGMENT_SHADER));
      gl.linkProgram(prog);
      gl.useProgram(prog);

      const buf = gl.createBuffer();
      gl.bindBuffer(gl.ARRAY_BUFFER, buf);
      gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
      const p = gl.getAttribLocation(prog, "p");
      gl.enableVertexAttribArray(p);
      gl.vertexAttribPointer(p, 2, gl.FLOAT, false, 0, 0);

      for (const k of ["a", "b", "mixAmt", "lod", "time", "vel", "ar"]) {
        loc[k] = gl.getUniformLocation(prog, k);
      }
      gl.uniform1i(loc.a!, 0);
      gl.uniform1i(loc.b!, 1);

      textures = tracks.map((t) => {
        const tex = gl.createTexture()!;
        gl.bindTexture(gl.TEXTURE_2D, tex);
        gl.texImage2D(
          gl.TEXTURE_2D,
          0,
          gl.RGBA,
          1,
          1,
          0,
          gl.RGBA,
          gl.UNSIGNED_BYTE,
          new Uint8Array([20, 20, 24, 255]),
        );
        const img = new Image();
        img.crossOrigin = "anonymous";
        img.onload = () => {
          // WebGL1 only builds mip chains for power-of-two textures, and the mip
          // chain is what the blur is sampled from — so whatever size the artwork
          // arrives at, it gets centre-cropped onto a POT square first.
          const side = 512;
          const pot = document.createElement("canvas");
          pot.width = side;
          pot.height = side;
          const ctx = pot.getContext("2d");
          if (ctx) {
            const c = Math.min(img.width, img.height);
            ctx.drawImage(
              img,
              (img.width - c) / 2,
              (img.height - c) / 2,
              c,
              c,
              0,
              0,
              side,
              side,
            );
          }
          gl.bindTexture(gl.TEXTURE_2D, tex);
          gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1);
          gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, ctx ? pot : img);
          gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
          gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
          gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
          gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
          gl.generateMipmap(gl.TEXTURE_2D);
          if (++loaded === 1) setGlReady(true);
        };
        img.src = t.cover;
        return tex;
      });
    }

    let raf = 0;
    let clock = 0;

    const paint = () => {
      const p = pos.current;
      for (let i = 0; i < N; i++) {
        const d = i - p;
        const ad = Math.abs(d);
        const node = cardRefs.current[i];
        if (node) {
          node.style.transform =
            `translate3d(${d * gap}px,0,${-ad * 190}px) rotateY(${-d * tilt}deg) scale(${1 - Math.min(ad, 3) * 0.06})`;
          node.style.opacity = String(clamp(1 - ad * 0.28, 0, 1));
          node.style.zIndex = String(100 - Math.round(ad * 10));
          node.style.filter = ad > 0.35 ? `brightness(${clamp(1 - (ad - 0.35) * 0.42, 0.42, 1)}) saturate(${clamp(1 - (ad - 0.35) * 0.3, 0.55, 1)})` : "none";
        }
        const fade = fadeRefs.current[i];
        if (fade) fade.style.opacity = String(clamp(1 - ad, 0, 1));
      }

      if (gl && prog && textures.length) {
        const lo = clamp(Math.floor(p), 0, N - 1);
        const hi = clamp(lo + 1, 0, N - 1);
        gl.activeTexture(gl.TEXTURE0);
        gl.bindTexture(gl.TEXTURE_2D, textures[lo]);
        gl.activeTexture(gl.TEXTURE1);
        gl.bindTexture(gl.TEXTURE_2D, textures[hi]);
        gl.uniform1f(loc.mixAmt!, p - lo);
        gl.uniform1f(loc.lod!, blur);
        gl.uniform1f(loc.time!, clock);
        gl.uniform1f(loc.vel!, clamp(vel.current, -1.4, 1.4));
        gl.uniform1f(loc.ar!, w / Math.max(1, h));
        gl.drawArrays(gl.TRIANGLES, 0, 3);
      }
    };

    const resize = () => {
      measure();
      if (!canvas || !gl) return;
      const scale = 0.3;
      canvas.width = Math.max(2, Math.round(w * scale));
      canvas.height = Math.max(2, Math.round(h * scale));
      gl.viewport(0, 0, canvas.width, canvas.height);
    };

    const ro = new ResizeObserver(resize);
    ro.observe(root);
    resize();

    let idle = 0;
    const tick = () => {
      raf = 0;
      clock += 1 / 60;
      const d = target.current - pos.current;
      const step = d * (reduce ? 1 : clamp(ease, 0.02, 1));
      pos.current += step;
      vel.current += (step * 6 - vel.current) * 0.2;
      if (Math.abs(d) < 0.0004) pos.current = target.current;
      paint();
      const next = Math.round(pos.current);
      setActive((prev) => (prev === next ? prev : next));
      // the field keeps drifting while it is on screen, so it never looks frozen
      idle = Math.abs(d) < 0.0004 && Math.abs(vel.current) < 0.002 ? idle + 1 : 0;
      if (visible && idle < 90) raf = requestAnimationFrame(tick);
    };

    let visible = true;
    const start = () => {
      idle = 0;
      if (!raf) raf = requestAnimationFrame(tick);
    };
    const io = new IntersectionObserver(
      ([e]) => {
        visible = e.isIntersecting;
        if (visible) start();
        else if (raf) {
          cancelAnimationFrame(raf);
          raf = 0;
        }
      },
      { threshold: 0 },
    );
    io.observe(root);
    start();

    let dragging = false;
    let lastX = 0;
    let from = 0;
    const onDown = (e: PointerEvent) => {
      dragging = true;
      lastX = e.clientX;
      from = target.current;
      root.setPointerCapture?.(e.pointerId);
    };
    const onMove = (e: PointerEvent) => {
      if (!dragging) return;
      target.current = clamp(from - (e.clientX - lastX) / gap, 0, N - 1);
      start();
    };
    const onUp = () => {
      if (!dragging) return;
      dragging = false;
      goTo(Math.round(target.current));
      start();
    };
    const onWheel = (e: WheelEvent) => {
      const ax = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : e.deltaY;
      if (Math.abs(ax) < 2) return;
      e.preventDefault();
      goTo(Math.round(target.current) + (ax > 0 ? 1 : -1));
      start();
    };
    const onKey = (e: KeyboardEvent) => {
      const t = e.target as HTMLElement | null;
      if (t && (/^(INPUT|SELECT|TEXTAREA)$/.test(t.tagName) || t.isContentEditable)) return;
      if (e.key === "ArrowRight") {
        goTo(Math.round(target.current) + 1);
        start();
      }
      if (e.key === "ArrowLeft") {
        goTo(Math.round(target.current) - 1);
        start();
      }
    };
    const onResizeWin = () => measure();

    // autoPlay lives here so each step can wake the loop — the loop parks when
    // the deck settles, and goTo alone would only move the target.
    const cycle = autoPlay
      ? setInterval(() => {
          goTo((Math.round(target.current) + 1) % N);
          start();
        }, 2600)
      : undefined;

    root.addEventListener("pointerdown", onDown);
    window.addEventListener("pointermove", onMove);
    window.addEventListener("pointerup", onUp);
    root.addEventListener("wheel", onWheel, { passive: false });
    window.addEventListener("keydown", onKey);
    window.addEventListener("scroll", onResizeWin, { passive: true, capture: true });

    return () => {
      if (raf) cancelAnimationFrame(raf);
      if (cycle) clearInterval(cycle);
      ro.disconnect();
      io.disconnect();
      root.removeEventListener("pointerdown", onDown);
      window.removeEventListener("pointermove", onMove);
      window.removeEventListener("pointerup", onUp);
      root.removeEventListener("wheel", onWheel);
      window.removeEventListener("keydown", onKey);
      window.removeEventListener("scroll", onResizeWin, { capture: true });
      if (gl) {
        textures.forEach((t) => gl.deleteTexture(t));
        if (prog) gl.deleteProgram(prog);
      }
    };
  }, [N, tracks, gap, tilt, ease, blur, reduce, goTo, autoPlay]);

  const current = tracks[clamp(active, 0, N - 1)];

  return (
    <div
      ref={rootRef}
      className={`relative select-none overflow-hidden bg-neutral-950 text-white ${className}`}
      style={{ touchAction: "pan-y", cursor: "grab" }}
    >
      <canvas ref={canvasRef} className="absolute inset-0 h-full w-full" aria-hidden />

      {/* Where WebGL is unavailable the same idea runs on blurred layers. */}
      {!glReady && (
        <div aria-hidden className="absolute inset-0">
          {tracks.map((t, i) => (
            <div
              key={t.cover}
              ref={(el) => void (fadeRefs.current[i] = el)}
              className="absolute inset-0 bg-cover bg-center"
              style={{ backgroundImage: `url(${t.cover})`, filter: "blur(64px) saturate(1.3)", transform: "scale(1.4)" }}
            />
          ))}
        </div>
      )}

      <div aria-hidden className="absolute inset-0 bg-gradient-to-b from-black/25 via-transparent to-black/55" />

      <div
        className="absolute inset-x-0 top-0 flex items-center justify-center"
        style={{ bottom: barSpace, perspective: 1200 }}
      >
        <div className="relative" style={{ transformStyle: "preserve-3d" }}>
          {tracks.map((t, i) => (
            <button
              key={t.cover}
              type="button"
              ref={(el) => void (cardRefs.current[i] = el)}
              onClick={() => goTo(i)}
              aria-label={`${t.title}${t.artist}`}
              aria-current={i === active}
              className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 will-change-transform"
              style={{ transformStyle: "preserve-3d", width: card }}
            >
              <span
                className="block w-full rounded-[1.5rem] p-2 backdrop-blur-xl transition-shadow"
                style={{
                  background: i === active ? "rgba(255,255,255,0.2)" : "rgba(255,255,255,0.1)",
                  boxShadow:
                    i === active
                      ? `0 26px 60px -18px rgba(0,0,0,0.75), inset 0 0 0 1.5px ${accent}`
                      : "0 18px 40px -22px rgba(0,0,0,0.6), inset 0 0 0 1px rgba(255,255,255,0.22)",
                }}
              >
                <img
                  src={t.cover}
                  alt=""
                  draggable={false}
                  className="block aspect-square w-full rounded-[1.05rem] object-cover"
                />
                <span className="flex flex-col gap-0.5 px-1 pb-1 pt-2.5 text-center">
                  <span className="truncate text-[0.95rem] font-semibold leading-tight">{t.title}</span>
                  <span className="truncate text-xs text-white/70">{t.artist}</span>
                </span>
              </span>
            </button>
          ))}
        </div>
      </div>

      {showPlayer && current && (
        <div className="absolute inset-x-0 bottom-0 flex justify-center p-4 sm:p-6">
          <div
            className="flex w-full max-w-2xl items-center gap-2 rounded-[1.6rem] px-3 py-2.5 ring-1 ring-white/20 backdrop-blur-2xl sm:gap-3 sm:px-4"
            style={{ background: "rgba(255,255,255,0.13)" }}
          >
            <Ctl label="Previous" onClick={() => goTo(Math.round(target.current) - 1)}>
              <svg viewBox="0 0 24 24" className="size-4" fill="currentColor">
                <path d="M11 12 20 6v12zM2 12l9-6v12z" />
              </svg>
            </Ctl>
            <Ctl label={playing ? "Pause" : "Play"} onClick={() => setPlaying((v) => !v)}>
              {playing ? (
                <svg viewBox="0 0 24 24" className="size-4" fill="currentColor">
                  <path d="M7 5h3v14H7zM14 5h3v14h-3z" />
                </svg>
              ) : (
                <svg viewBox="0 0 24 24" className="size-4" fill="currentColor">
                  <path d="M7 5l12 7-12 7z" />
                </svg>
              )}
            </Ctl>
            <Ctl label="Next" onClick={() => goTo(Math.round(target.current) + 1)}>
              <svg viewBox="0 0 24 24" className="size-4" fill="currentColor">
                <path d="M13 12 4 18V6zM22 12l-9 6V6z" />
              </svg>
            </Ctl>

            <div className="mx-1 flex min-w-[7rem] flex-1 items-center gap-2.5 rounded-2xl px-1 py-1.5 sm:gap-3 sm:px-2">
              <img src={current.cover} alt="" className="size-9 shrink-0 rounded-xl object-cover" />
              <span className="min-w-0 flex-1">
                <span className="block truncate text-[0.8rem] font-medium leading-tight">{current.title}</span>
                <span className="block truncate text-[0.7rem] text-white/65">{current.artist}</span>
                <span className="mt-1.5 block h-[3px] overflow-hidden rounded-full bg-white/25">
                  <span
                    className="block h-full rounded-full"
                    style={{
                      background: accent,
                      width: playing && !reduce ? undefined : "34%",
                      animation:
                        playing && !reduce ? "deck-progress 14s linear infinite" : undefined,
                    }}
                  />
                </span>
              </span>
            </div>

            <span className="hidden shrink items-center gap-1 sm:flex">
              {["speaker", "lyrics", "queue"].map((k) => (
                <Ctl key={k} label={k} onClick={() => {}}>
                  <svg viewBox="0 0 24 24" className="size-4" fill="currentColor">
                    {k === "speaker" && <path d="M4 9v6h4l5 4V5L8 9zm12.5 3a4.5 4.5 0 0 0-2.5-4v8a4.5 4.5 0 0 0 2.5-4z" />}
                    {k === "lyrics" && <path d="M4 4h16v12H7l-3 3z" />}
                    {k === "queue" && <path d="M3 6h13v2H3zm0 5h13v2H3zm0 5h9v2H3zm15-9v8.2a3 3 0 1 0 2 2.8V8h2V6h-4z" />}
                  </svg>
                </Ctl>
              ))}
            </span>
          </div>
        </div>
      )}

      <style>{`@keyframes deck-progress{from{width:0}to{width:100%}}`}</style>
    </div>
  );
}

function Ctl({
  label,
  onClick,
  children,
}: {
  label: string;
  onClick: () => void;
  children: React.ReactNode;
}) {
  return (
    <button
      type="button"
      aria-label={label}
      onClick={onClick}
      className="grid size-9 shrink-0 place-items-center rounded-full text-white/85 transition-colors hover:bg-white/15 hover:text-white"
    >
      {children}
    </button>
  );
}

Keep in mind

Some components here are recreations of the best interactions out there. I don't claim to be the original creator — this is my attempt to study, replicate, and often add a few extra features. I've tried to credit everyone; if I missed something, let me know.

Contact

Found a bug or an issue? Drop a mail — hello@harshpandav.dev

License & Usage

  • Free to use and modify in personal and commercial projects.
  • Attribution to Harsh Dev UI is appreciated but not required.
  • MIT licensed.

Command Palette

Search for a command to run...