A music player card built around a small turntable. Pressing play swings the tonearm onto the record and the platter spins up with weight; pausing lets it wind down while the equalizer sinks. Thirty bars move like a spectrum, each with a thin cap that hangs at its last peak and falls slowly. The seek bar can be dragged or moved with the arrow keys, and the tonearm tracks across the record as the song goes on. Changing track swaps the title, the record label and the colours. Nothing plays sound: it is the interface, ready for your audio. On phones the turntable sits above the controls.
import { useEffect, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "framer-motion";
const album = { artist: "Lumen Harbor", title: "Night Ferry", label: "Tidewater Tapes", side: "Side A" };
/** `length` is in seconds. `a` and `b` colour the record label, the glow and the bars for that track. */
const tracks = [
{ title: "Low Tide Radio", length: 214, a: "#fb7185", b: "#fbbf24" },
{ title: "Salt on the Windows", length: 187, a: "#e879f9", b: "#fb7185" },
{ title: "Night Ferry", length: 243, a: "#a78bfa", b: "#f472b6" },
{ title: "Harbour Lights, Slow", length: 201, a: "#fb923c", b: "#fde047" },
];
/** Degrees per second at full speed (a 33 rpm record), where the demo starts in the first track, and the bar count. */
const RPM_SPEED = 200;
const START_AT = 42;
const BARS = 30;
const BAR_HEIGHT = 64;
/** Tonearm angles in degrees: parked, at the start of a side and at the end of it. */
const ARM = { parked: -3, first: 19, last: 32 };
const clock = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
const control = "flex items-center justify-center rounded-full text-white transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white";
export default function MusicPlayerCardWithSpinningVinylAndEqualizer() {
const reduce = useReducedMotion();
const still = Boolean(reduce);
const [index, setIndex] = useState(0);
const [playing, setPlaying] = useState(true);
const [liked, setLiked] = useState(false);
const rootRef = useRef<HTMLElement>(null);
const discRef = useRef<HTMLDivElement>(null);
const armRef = useRef<HTMLDivElement>(null);
const fillRef = useRef<HTMLSpanElement>(null);
const thumbRef = useRef<HTMLSpanElement>(null);
const nowRef = useRef<HTMLSpanElement>(null);
const seekRef = useRef<HTMLInputElement>(null);
const barsRef = useRef<HTMLDivElement>(null);
// What the animation loop reads. React state only changes on a click or at the end of a track.
const live = useRef({ index: 0, playing: true, time: 0, intro: true });
const paintRef = useRef<() => void>(() => undefined);
const track = tracks[index];
const choose = (i: number) => {
live.current.index = i;
live.current.time = 0;
live.current.intro = false;
setIndex(i);
paintRef.current();
};
const toggle = () => {
live.current.playing = !live.current.playing;
setPlaying(live.current.playing);
};
useEffect(() => {
const root = rootRef.current;
const disc = discRef.current;
const arm = armRef.current;
const barBox = barsRef.current;
if (!root || !disc || !arm || !barBox) return;
const bars = Array.from(barBox.querySelectorAll<HTMLElement>("[data-bar]"));
const caps = Array.from(barBox.querySelectorAll<HTMLElement>("[data-cap]"));
const levels = bars.map(() => 0.08);
const peaks = bars.map(() => 0.08);
const state = live.current;
let raf = 0;
let visible = true;
let previous = performance.now();
let angle = 0;
let speed = 0;
let armAngle = ARM.parked;
let shownSecond = -1;
let beat = 0;
const paint = () => {
const length = tracks[state.index].length;
const progress = Math.min(1, state.time / length);
disc.style.transform = `rotate(${angle.toFixed(2)}deg)`;
arm.style.transform = `rotate(${armAngle.toFixed(2)}deg)`;
if (fillRef.current) fillRef.current.style.transform = `scaleX(${progress.toFixed(4)})`;
if (thumbRef.current) thumbRef.current.style.transform = `translateX(${(progress * 100).toFixed(2)}%)`;
const second = Math.floor(state.time);
if (second !== shownSecond) {
shownSecond = second;
if (nowRef.current) nowRef.current.textContent = clock(state.time);
if (seekRef.current) {
seekRef.current.value = String(second);
seekRef.current.setAttribute("aria-valuetext", `${clock(state.time)} of ${clock(length)}`);
}
}
for (let i = 0; i < bars.length; i++) {
bars[i].style.transform = `scaleY(${levels[i].toFixed(3)})`;
caps[i].style.transform = `translateY(${(-peaks[i] * BAR_HEIGHT).toFixed(1)}px)`;
}
};
paintRef.current = paint;
const frame = (now: number) => {
raf = 0;
const dt = Math.min(0.05, (now - previous) / 1000);
previous = now;
const length = tracks[state.index].length;
// The platter has weight: it spins up and winds down instead of snapping.
speed += ((state.playing ? RPM_SPEED : 0) - speed) * Math.min(1, dt * 3.2);
const energy = speed / RPM_SPEED;
angle = (angle + speed * dt) % 360;
if (state.intro) {
// On load the needle is dropped part way into the first track.
state.time += (START_AT - state.time) * Math.min(1, dt * 5);
if (START_AT - state.time < 0.3) state.intro = false;
} else {
state.time += dt * energy;
}
if (state.time >= length) {
state.time = 0;
state.index = (state.index + 1) % tracks.length;
setIndex(state.index);
}
const armTarget = state.playing ? ARM.first + (ARM.last - ARM.first) * (state.time / length) : ARM.parked;
armAngle += (armTarget - armAngle) * Math.min(1, dt * 4.5);
// A made-up spectrum: slow waves per bar, heavier at the low end, with a kick on the beat.
beat += dt * energy;
const kick = Math.pow(Math.max(0, Math.sin(beat * Math.PI * 3.6)), 8);
for (let i = 0; i < bars.length; i++) {
const x = i / (bars.length - 1);
const wave = 0.5 + 0.5 * Math.sin(beat * (3.1 + (i % 7) * 0.9) + i * 1.7) * Math.sin(beat * (1.3 + (i % 5) * 0.45) + i * 0.6);
const target = 0.06 + energy * (wave * (0.92 - x * 0.5) + kick * Math.max(0, 0.55 - x * 1.1)) * 0.94;
levels[i] += (Math.min(1, target) - levels[i]) * Math.min(1, dt * 16);
peaks[i] = Math.max(levels[i], peaks[i] - dt * 0.42);
}
paint();
schedule();
};
const schedule = () => {
if (!raf && visible && !document.hidden && !still) {
previous = performance.now();
raf = requestAnimationFrame(frame);
}
};
const observer = new IntersectionObserver(([entry]) => {
visible = entry.isIntersecting;
schedule();
});
observer.observe(root);
document.addEventListener("visibilitychange", schedule);
if (still) {
// No motion: show a finished frame, mid-song, with the needle down.
state.intro = false;
if (state.time === 0) state.time = START_AT;
armAngle = ARM.first + (ARM.last - ARM.first) * (state.time / tracks[state.index].length);
angle = 24;
for (let i = 0; i < levels.length; i++) {
levels[i] = 0.2 + 0.6 * Math.abs(Math.sin(i * 1.3) * Math.cos(i * 0.37)) * (1 - (i / levels.length) * 0.5);
peaks[i] = Math.min(1, levels[i] + 0.08);
}
}
paint();
schedule();
return () => {
cancelAnimationFrame(raf);
observer.disconnect();
document.removeEventListener("visibilitychange", schedule);
};
}, [still]);
return (
<section ref={rootRef} className="relative overflow-hidden bg-stone-950 px-5 py-14 text-white sm:px-6 sm:py-20">
<span aria-hidden="true" className="pointer-events-none absolute left-1/2 top-1/2 h-[460px] w-[760px] -translate-x-1/2 -translate-y-1/2 rounded-full opacity-25 blur-3xl transition-colors duration-1000" style={{ backgroundColor: track.a }} />
<span aria-hidden="true" className="pointer-events-none absolute left-[12%] top-[12%] h-72 w-72 rounded-full opacity-20 blur-3xl transition-colors duration-1000" style={{ backgroundColor: track.b }} />
<motion.div
className="relative mx-auto grid max-w-4xl items-center gap-8 rounded-[36px] bg-white/[0.06] p-5 ring-1 ring-inset ring-white/10 backdrop-blur-xl sm:p-8 md:grid-cols-[auto_minmax(0,1fr)] md:gap-10"
initial={still ? false : { opacity: 0, y: 40, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ type: "spring", stiffness: 120, damping: 18 }}
>
{/* Turntable. It is built at 320px and scaled down on small screens. */}
<div className="mx-auto h-[262px] w-[262px] sm:h-[320px] sm:w-[320px]">
<div aria-hidden="true" className="relative h-[320px] w-[320px] origin-top-left scale-[0.82] rounded-[30px] bg-gradient-to-br from-stone-700 to-stone-900 shadow-2xl shadow-black/60 ring-1 ring-inset ring-white/15 sm:scale-100">
<span className="absolute left-5 top-5 text-[10px] font-semibold uppercase tracking-[0.25em] text-white/50">33 rpm</span>
<span className={`absolute bottom-5 right-6 h-2 w-2 rounded-full transition-colors duration-500 ${playing ? "bg-emerald-400" : "bg-white/25"}`} />
<div className="absolute left-[20px] top-[44px] h-[256px] w-[256px] rounded-full bg-stone-950 shadow-xl shadow-black/70">
<div ref={discRef} className="absolute inset-1 rounded-full" style={{ willChange: "transform", backgroundImage: "repeating-radial-gradient(circle at center, #0c0a09 0 1.4px, #292524 1.4px 2.6px)" }}>
{/* Uneven shine that turns with the record, so the spin is visible. */}
<span className="absolute inset-0 rounded-full" style={{ backgroundImage: "conic-gradient(from 0deg, transparent 0 8%, rgba(255,255,255,0.09) 13%, transparent 19% 46%, rgba(255,255,255,0.06) 52%, transparent 58% 78%, rgba(255,255,255,0.05) 82%, transparent 88%)" }} />
<span className="absolute inset-[13%] rounded-full ring-1 ring-black/70" />
<span className="absolute inset-[23%] rounded-full ring-1 ring-black/70" />
<span className="absolute inset-[31%] flex flex-col items-center justify-between overflow-hidden rounded-full py-[9%] text-stone-950" style={{ backgroundImage: `linear-gradient(140deg, ${track.a}, ${track.b})` }}>
<span className="text-[8px] font-bold uppercase tracking-[0.18em]">{album.title}</span>
<span className="text-[7px] font-semibold uppercase tracking-[0.2em]">{album.side}</span>
<span className="absolute left-0 top-1/2 h-px w-full bg-stone-950/25" />
<span className="absolute left-1/2 top-1/2 h-3 w-3 -translate-x-1/2 -translate-y-1/2 rounded-full bg-stone-950 ring-2 ring-white/40" />
</span>
</div>
{/* Light from the room: this layer does not turn. */}
<span className="pointer-events-none absolute inset-1 rounded-full mix-blend-screen" style={{ backgroundImage: "conic-gradient(from 20deg, transparent 0deg, rgba(255,255,255,0.22) 22deg, transparent 50deg, transparent 180deg, rgba(255,255,255,0.16) 204deg, transparent 232deg)" }} />
</div>
{/* Tonearm, pivoting at the top right. */}
<span className="absolute right-[14px] top-[22px] h-11 w-11 rounded-full bg-gradient-to-br from-stone-500 to-stone-800 shadow-lg shadow-black/50 ring-1 ring-inset ring-white/25" />
<div ref={armRef} className="absolute right-[33px] top-[44px] h-[200px] w-[6px]" style={{ transformOrigin: "50% 0%", transform: `rotate(${ARM.parked}deg)`, willChange: "transform" }}>
<span className="absolute inset-x-0 top-0 h-[178px] rounded-full bg-gradient-to-r from-stone-300 via-white to-stone-400 shadow-md shadow-black/60" />
<span className="absolute -left-[7px] top-[168px] h-[34px] w-[20px] rounded-md shadow-md shadow-black/60" style={{ backgroundColor: track.a }} />
<span className="absolute -left-[5px] -top-[5px] h-4 w-4 rounded-full bg-stone-200 ring-1 ring-black/40" />
</div>
</div>
</div>
<div className="min-w-0">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-[0.22em] text-white/60">
{playing ? "Now spinning" : "Paused"} ยท {album.label}
</p>
<div className="relative mt-2 h-10 overflow-hidden">
<AnimatePresence initial={false} mode="popLayout">
<motion.h2 key={track.title} className="truncate text-3xl font-semibold leading-10 tracking-tight" initial={still ? false : { y: 34, opacity: 0, filter: "blur(6px)" }} animate={{ y: 0, opacity: 1, filter: "blur(0px)" }} exit={still ? { opacity: 0 } : { y: -34, opacity: 0, filter: "blur(6px)" }} transition={{ type: "spring", stiffness: 260, damping: 28 }}>
{track.title}
</motion.h2>
</AnimatePresence>
</div>
<p className="mt-1 text-base text-white/70">
{album.artist}, <span className="italic">{album.title}</span>
</p>
</div>
<button type="button" aria-pressed={liked} aria-label="Save to favourites" onClick={() => setLiked((v) => !v)} className={`${control} h-11 w-11 shrink-0`}>
<motion.svg viewBox="0 0 24 24" aria-hidden="true" className="h-6 w-6" animate={still ? undefined : { scale: liked ? [1, 1.35, 1] : 1 }} transition={{ duration: 0.4 }}>
<path d="M12 20.5s-7.5-4.6-7.5-10A4.5 4.5 0 0 1 12 7.6a4.5 4.5 0 0 1 7.5 2.9c0 5.4-7.5 10-7.5 10Z" fill={liked ? track.a : "none"} stroke={liked ? track.a : "currentColor"} strokeWidth="1.8" strokeLinejoin="round" />
</motion.svg>
</button>
</div>
{/* Equalizer: each bar has a cap that hangs at its last peak and falls slowly. */}
<div ref={barsRef} aria-hidden="true" className="mt-6 flex items-end gap-[3px]" style={{ height: BAR_HEIGHT }}>
{Array.from({ length: BARS }, (_, i) => (
<span key={i} className="relative h-full flex-1">
<span data-bar="" className="absolute inset-0 origin-bottom rounded-sm" style={{ transform: "scaleY(0.08)", willChange: "transform", backgroundImage: `linear-gradient(to top, ${track.a}, ${track.b})` }} />
<span data-cap="" className="absolute inset-x-0 bottom-0 h-[2px] rounded-full bg-white/80" style={{ transform: "translateY(-5px)", willChange: "transform" }} />
</span>
))}
</div>
<div className="relative mt-5 flex h-10 items-center">
<input
ref={seekRef}
type="range"
aria-label="Seek"
min={0}
max={track.length}
step={1}
defaultValue={0}
onInput={(e) => {
live.current.time = Number(e.currentTarget.value);
live.current.intro = false;
paintRef.current();
}}
className="peer absolute inset-0 z-10 h-full w-full cursor-pointer opacity-0"
/>
<div className="relative h-1.5 w-full rounded-full bg-white/15 peer-focus-visible:ring-2 peer-focus-visible:ring-white peer-focus-visible:ring-offset-4 peer-focus-visible:ring-offset-stone-900">
<span ref={fillRef} className="absolute inset-0 origin-left rounded-full" style={{ transform: "scaleX(0)", backgroundImage: `linear-gradient(to right, ${track.a}, ${track.b})` }} />
<span ref={thumbRef} className="absolute inset-0" style={{ transform: "translateX(0%)" }}>
<span className="absolute -left-2 -top-[5px] h-4 w-4 rounded-full bg-white shadow-md shadow-black/50" />
</span>
</div>
</div>
<div className="flex justify-between text-xs tabular-nums text-white/60">
<span ref={nowRef}>0:00</span>
<span>{clock(track.length)}</span>
</div>
<div className="mt-2 flex items-center justify-center gap-4 md:justify-start">
<button type="button" aria-label="Previous track" onClick={() => choose((index + tracks.length - 1) % tracks.length)} className={`${control} h-12 w-12`}>
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-6 w-6">
<path fill="currentColor" d="M6 5h2.2v14H6V5Zm13 .5v13L9.5 12 19 5.5Z" />
</svg>
</button>
<button type="button" aria-label={playing ? "Pause" : "Play"} onClick={toggle} className="flex h-14 w-14 items-center justify-center rounded-full bg-white text-stone-950 shadow-lg shadow-black/40 transition hover:scale-105 focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-stone-900 active:scale-95">
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-6 w-6">
{playing ? <path fill="currentColor" d="M6.5 5h4v14h-4V5Zm7 0h4v14h-4V5Z" /> : <path fill="currentColor" d="M8 4.5 19.5 12 8 19.5v-15Z" />}
</svg>
</button>
<button type="button" aria-label="Next track" onClick={() => choose((index + 1) % tracks.length)} className={`${control} h-12 w-12`}>
<svg viewBox="0 0 24 24" aria-hidden="true" className="h-6 w-6">
<path fill="currentColor" d="M15.8 5H18v14h-2.2V5ZM5 5.5 14.5 12 5 18.5v-13Z" />
</svg>
</button>
</div>
<ol className="mt-5 border-t border-white/10 pt-3">
{tracks.map((t, i) => (
<li key={t.title}>
<button type="button" aria-current={i === index ? "true" : undefined} onClick={() => choose(i)} className={`flex h-10 w-full items-center gap-3 rounded-lg px-2 text-left text-sm transition hover:bg-white/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-white ${i === index ? "text-white" : "text-white/60"}`}>
<span className="w-4 text-xs tabular-nums" style={i === index ? { color: track.a } : undefined}>{i + 1}</span>
<span className={`min-w-0 flex-1 truncate ${i === index ? "font-semibold" : ""}`}>{t.title}</span>
<span className="text-xs tabular-nums">{clock(t.length)}</span>
</button>
</li>
))}
</ol>
</div>
</motion.div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/music-player-card-with-spinning-vinyl-and-equalizer.json
The loop keeps a current speed and a target speed: full speed when playing, zero when paused. Every frame the current speed moves a fraction of the way toward the target, and the record's angle advances by that speed. That gives the heavy start and the slow stop of a real platter, and the song position advances at the same rate, so the time slows with it.
No. Each bar follows two slow sine waves multiplied together, weighted toward the low end, with a short kick on a steady beat. It looks like a spectrum without needing audio. To use real data, create an analyser node from the Web Audio API and write its frequency values into the `levels` array in place of the `target` line.
With CSS only. The grooves are a `repeating-radial-gradient`, the label is a circle with a two-colour gradient, and two conic gradients add shine: one turns with the record so the spin is visible, and one stays still on top like light from the room.
A native range input gives dragging, touch, keyboard steps and screen reader support for free. It is stretched over the styled track at zero opacity, and the visible fill and thumb are moved with transforms. The focus ring is shown on the track through a `peer-focus-visible` class.
No. An intersection observer stops the loop when the card leaves the viewport and it also stops when the tab is hidden.
Related: Voice Input Bar with Live Waveform, Activity Rings Card with Day Switcher, Dynamic Island with Morphing Live Activities.
More animated cards or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.