Section
Vinyl Player
An artist page with a half-cropped record that actually plays: it spins up and runs down, a red arc tracks the song, and the track list swaps what is on the platter.
02.BORROWED SUMMER
0:00 / 4:08
Pick a track, or tap the label to stop the platter
import { VinylPlayer, type VinylTrack } from "@/components/ui/vinyl-player";
// A made-up band, so the demo does not ship anyone's real discography.
const tracks: VinylTrack[] = [
{ title: "Glass Weather", album: "Low Satellites", duration: 216, cover: "https://picsum.photos/seed/vinyl-glass/120/120" },
{ title: "Borrowed Summer", album: "Slow Currents", duration: 248, cover: "https://picsum.photos/seed/vinyl-summer/120/120" },
{ title: "Echo Parade", album: "Low Satellites", duration: 192, cover: "https://picsum.photos/seed/vinyl-echo/120/120" },
{ title: "Paper Moons", album: "Slow Currents", duration: 221, cover: "https://picsum.photos/seed/vinyl-moons/120/120" },
];
export default function VinylPlayerDemo({ fullscreen }: { fullscreen?: boolean }) {
return (
<div className="w-full min-w-0 space-y-3">
{/* The grey mat the card sits on in the reference. */}
<div
className={
fullscreen
? "grid min-h-dvh place-items-center bg-[#dcdbd6] p-6 sm:p-12"
: "rounded-card bg-[#dcdbd6] p-4 sm:p-8"
}
>
<VinylPlayer
artist="Neon Harbor"
genres="Dream pop, neo-psych, disco, synth-pop"
tracks={tracks}
initialTrack={1}
className="mx-auto w-full max-w-6xl"
/>
</div>
{!fullscreen && (
<p className="text-center text-xs text-faint">
Pick a track, or tap the label to stop the platter
</p>
)}
</div>
);
}Installation
1. Install the dependencies.
terminal npm install gsap clsx tailwind-merge2. Add the
cnhelper, if you don't already have it.lib/utils.ts import { clsx, type ClassValue } from "clsx"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }3. Copy the component into your project.
components/ui/vinyl-player.tsx "use client"; import gsap from "gsap"; import { useEffect, useId, useRef, useState } from "react"; import { cn } from "@/lib/utils"; export type VinylTrack = { title: string; album: string; /** Length in seconds. */ duration: number; cover: string; }; type VinylPlayerProps = { /** Set one word per line, very large. */ artist: string; genres?: string; tracks: VinylTrack[]; /** Mark in the top-left corner. */ brand?: string; links?: { label: string; href: string }[]; /** Progress arc, playhead, equaliser and section label. */ accent?: string; /** Which track is on the platter to begin with. */ initialTrack?: number; autoPlay?: boolean; className?: string; }; /** 33⅓ rpm, so the record turns at the speed a real one would. */ const SECONDS_PER_TURN = 60 / (100 / 3); /** The playhead ticks this often; small enough that the arc reads as smooth. */ const TICK = 0.25; const PAPER = "#f3f1ea"; /* * Grooves are concentric, so spinning them is invisible on its own — the * printed ring on the label is what shows the record turning. The sheen sits * on a separate, non-rotating layer, the way a fixed light source reflects off * a real record while it spins underneath. */ const DISC = [ // Outer rim catching the light. "radial-gradient(circle, transparent 0 68.6%, #2b2b2b 69.6%, #0f0f0f 70.4%)", // Dead wax between the label and the first groove. "radial-gradient(circle, transparent 0 24%, #161616 24.3% 27%, transparent 27.3%)", "repeating-radial-gradient(circle, #0d0d0d 0 0.55%, #1b1b1b 0.8%, #0d0d0d 1.1%)", ].join(", "); const SHEEN = "conic-gradient(from 210deg, transparent 0deg, rgb(255 255 255 / 0.1) 24deg, transparent 52deg, transparent 180deg, rgb(255 255 255 / 0.07) 204deg, transparent 232deg)"; const EQ_KEYFRAMES = "@keyframes vinyl-eq { 0%, 100% { transform: scaleY(0.35); } 50% { transform: scaleY(1); } }"; const EQ_BARS = [ { height: 55, speed: 0.9 }, { height: 85, speed: 0.62 }, { height: 40, speed: 1.08 }, { height: 70, speed: 0.74 }, ]; const clock = (seconds: number) => `${Math.floor(seconds / 60)}:${String(Math.floor(seconds % 60)).padStart(2, "0")}`; const pad = (value: number) => String(value).padStart(2, "0"); export function VinylPlayer({ artist, genres, tracks, brand = "J/U", links = [ { label: "Artists", href: "#artists" }, { label: "Mixtapes", href: "#mixtapes" }, { label: "Awards", href: "#awards" }, ], accent = "#c63b36", initialTrack = 0, autoPlay = true, className, }: VinylPlayerProps) { const [index, setIndex] = useState(() => Math.max(0, Math.min(initialTrack, tracks.length - 1)), ); const [elapsed, setElapsed] = useState(0); const [playing, setPlaying] = useState(autoPlay); const [still, setStill] = useState(false); const [scrubbing, setScrubbing] = useState(false); const [seekFocused, setSeekFocused] = useState(false); const discRef = useRef<HTMLDivElement>(null); const spinRef = useRef<gsap.core.Tween | null>(null); const seekRef = useRef<SVGSVGElement>(null); // Read inside the ticking interval, which would otherwise see a stale value. const scrubbingRef = useRef(false); const ringId = useId().replace(/[^a-zA-Z0-9_-]/g, ""); const track = tracks[index]; const progress = track ? Math.min(1, elapsed / track.duration) : 0; // One endless turn, created stopped. Play and pause ease its timeScale // rather than toggling it, so the platter spins up and runs down. useEffect(() => { const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches; setStill(reduce); if (reduce || !discRef.current) return; const tween = gsap.to(discRef.current, { rotation: "+=360", duration: SECONDS_PER_TURN, ease: "none", repeat: -1, }); tween.timeScale(0); spinRef.current = tween; return () => { tween.kill(); spinRef.current = null; }; }, []); // Holding the playhead stops the platter under your hand, the way a DJ // holds a record, and letting go lets it run up to speed again. useEffect(() => { const tween = spinRef.current; if (!tween) return; const turning = playing && !scrubbing; gsap.to(tween, { timeScale: turning ? 1 : 0, duration: scrubbing ? 0.25 : turning ? 0.7 : 1.2, ease: turning ? "power2.in" : "power2.out", overwrite: true, }); }, [playing, scrubbing]); useEffect(() => { if (!playing) return; const id = window.setInterval( () => setElapsed((value) => (scrubbingRef.current ? value : value + TICK)), TICK * 1000, ); return () => window.clearInterval(id); }, [playing]); // Rolls on to the next side once this one runs out. useEffect(() => { if (!track || elapsed < track.duration) return; setIndex((value) => (value + 1) % tracks.length); setElapsed(0); }, [elapsed, track, tracks.length]); if (!track) return null; const choose = (next: number) => { if (next === index) { setPlaying((value) => !value); return; } setIndex(next); setElapsed(0); setPlaying(true); }; // Stops a tick short of the end, so dragging to the bottom of the arc does // not skip the track out from under your hand. const seekTo = (seconds: number) => setElapsed(Math.max(0, Math.min(track.duration - TICK, seconds))); /** Angle round the record from the top, clockwise, mapped onto the song. */ const seekFromPointer = (clientX: number, clientY: number) => { const box = seekRef.current?.getBoundingClientRect(); if (!box) return; const dx = clientX - (box.left + box.width / 2); const dy = clientY - (box.top + box.height / 2); let angle = Math.atan2(dx, -dy); // The left half is cropped off, so a pointer there pins to the nearer end. if (angle < 0) angle = angle < -Math.PI / 2 ? Math.PI : 0; seekTo((angle / Math.PI) * track.duration); }; const startScrub = (event: React.PointerEvent<SVGPathElement>) => { event.currentTarget.setPointerCapture(event.pointerId); scrubbingRef.current = true; setScrubbing(true); seekFromPointer(event.clientX, event.clientY); }; const endScrub = (event: React.PointerEvent<SVGPathElement>) => { if (event.currentTarget.hasPointerCapture(event.pointerId)) { event.currentTarget.releasePointerCapture(event.pointerId); } scrubbingRef.current = false; setScrubbing(false); }; const SEEK_KEYS: Record<string, number> = { ArrowRight: 5, ArrowDown: 5, ArrowLeft: -5, ArrowUp: -5, PageDown: 15, PageUp: -15, }; // Standard transport: back restarts the song unless you are already at // its start, in which case it steps to the one before. const previous = () => { if (elapsed > 3) { setElapsed(0); return; } setIndex((value) => (value - 1 + tracks.length) % tracks.length); setElapsed(0); }; const next = () => { setIndex((value) => (value + 1) % tracks.length); setElapsed(0); }; return ( <section aria-label={`${artist} — now playing ${track.title}`} style={{ containerType: "inline-size", backgroundColor: PAPER }} className={cn( "relative grid overflow-hidden rounded-[3px] text-[#141414] shadow-[0_30px_80px_-40px_rgb(0_0_0/0.35)] md:grid-cols-[minmax(0,0.58fr)_minmax(0,1fr)]", className, )} > <style>{EQ_KEYFRAMES}</style> <span className="absolute top-5 left-6 z-20 text-[13px] font-medium tracking-tight"> {brand} </span> <nav className="absolute top-5 right-6 z-20 hidden gap-5 text-[11px] font-medium tracking-wide uppercase sm:flex"> {links.map((link) => ( <a key={link.href} href={link.href} className="transition-opacity hover:opacity-60"> {link.label} </a> ))} </nav> {/* The record ------------------------------------------------------ */} <div className="relative h-[420px] md:h-auto"> {/* Anchored to the column's right edge and taller than the column is wide, so it runs off the card's left edge and gets cropped. */} <div className="absolute top-[46%] right-[30%] aspect-square h-[82%] -translate-y-1/2 md:right-[5%]" style={{ containerType: "size" }} > <div className="absolute inset-[3.6%]"> <div ref={discRef} className="size-full rounded-full" style={{ background: DISC }}> <div className="absolute top-1/2 left-1/2 size-[23%] -translate-x-1/2 -translate-y-1/2 rounded-full" style={{ backgroundColor: PAPER }} > <svg viewBox="0 0 100 100" aria-hidden className="absolute inset-0 size-full"> <defs> <path id={ringId} d="M50,50 m-38,0 a38,38 0 1,1 76,0 a38,38 0 1,1 -76,0" /> </defs> <circle cx={50} cy={50} r={47} fill="none" stroke="#e3dfd6" strokeWidth={1} /> <text fill="#aaa59b" style={{ fontSize: 8, letterSpacing: 1.2 }} className="font-mono"> <textPath href={`#${ringId}`}>{`SIDE A · 33⅓ RPM · STEREO · ${brand} ·`}</textPath> </text> </svg> </div> </div> <div aria-hidden className="pointer-events-none absolute inset-0 rounded-full" style={{ background: SHEEN, maskImage: "radial-gradient(circle, transparent 0 16.6%, #000 17.2%)", }} /> <button type="button" onClick={() => setPlaying((value) => !value)} aria-label={playing ? `Pause ${track.title}` : `Play ${track.title}`} style={{ outlineColor: accent }} className="absolute top-1/2 left-1/2 grid size-[23%] -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full focus-visible:outline-2 focus-visible:outline-offset-2" > <span className="flex h-[34%] w-[38%] items-end gap-[8%]"> {EQ_BARS.map((bar, bar_index) => ( <span key={bar_index} className="flex-1 origin-bottom rounded-[1px]" style={{ height: `${bar.height}%`, backgroundColor: accent, animation: still ? "none" : `vinyl-eq ${bar.speed}s ease-in-out ${bar_index * -0.2}s infinite`, animationPlayState: playing ? "running" : "paused", }} /> ))} </span> </button> <p aria-live="polite" className="pointer-events-none absolute top-1/2 left-[66%] max-w-[30%] -translate-y-1/2 truncate text-[3.2cqw] font-medium tracking-tight text-white/90" > <span className="font-mono text-[0.72em] text-white/55">{pad(index + 1)}.</span> {track.title.toUpperCase()} </p> </div> {/* The timeline. It runs down the visible half only, top to bottom, so the playhead never slides round behind the crop. It renders after the disc on purpose: the disc sits in a square box that covers most of the arc, and would otherwise take the pointer. */} <svg ref={seekRef} viewBox="0 0 100 100" role="slider" tabIndex={0} aria-label="Song position" aria-valuemin={0} aria-valuemax={Math.round(track.duration)} aria-valuenow={Math.round(elapsed)} aria-valuetext={`${clock(elapsed)} of ${clock(track.duration)}`} onKeyDown={(event) => { const step = SEEK_KEYS[event.key]; if (step !== undefined) { event.preventDefault(); seekTo(elapsed + step); } else if (event.key === "Home") { event.preventDefault(); seekTo(0); } else if (event.key === "End") { event.preventDefault(); seekTo(track.duration); } }} onFocus={(event) => setSeekFocused(event.currentTarget.matches(":focus-visible"))} onBlur={() => setSeekFocused(false)} className="pointer-events-none absolute inset-0 size-full overflow-visible outline-none" > <path d="M50 0.8 A49.2 49.2 0 0 1 50 99.2" fill="none" stroke="#dcd7cd" strokeWidth={0.45} /> <path d="M50 0.8 A49.2 49.2 0 0 1 50 99.2" fill="none" stroke={accent} strokeWidth={0.6} strokeLinecap="round" pathLength={1} strokeDasharray="1 1" strokeDashoffset={1 - progress} /> <g transform={`rotate(${progress * 180} 50 50)`}> {seekFocused ? ( <circle cx={50} cy={0.8} r={4.4} fill={accent} fillOpacity={0.16} /> ) : null} <circle cx={50} cy={0.8} r={scrubbing ? 3 : 2.3} fill={PAPER} stroke={accent} strokeWidth={0.4} /> <circle cx={50} cy={0.8} r={scrubbing ? 1.4 : 1.05} fill={accent} /> </g> {/* Invisible and wide: the grab area for the whole arc, handle included. Pressing anywhere on it jumps there, then drags. */} <path d="M50 0.8 A49.2 49.2 0 0 1 50 99.2" fill="none" stroke="transparent" strokeWidth={7} onPointerDown={startScrub} onPointerMove={(event) => { if (scrubbingRef.current) seekFromPointer(event.clientX, event.clientY); }} onPointerUp={endScrub} onPointerCancel={endScrub} style={{ pointerEvents: "stroke", cursor: scrubbing ? "grabbing" : "grab", touchAction: "none", }} /> </svg> </div> {/* Transport, in the band left free under the record. */} <div className="absolute inset-x-0 bottom-[3%] flex items-center justify-center gap-5"> <button type="button" onClick={previous} aria-label="Previous track" style={{ outlineColor: accent }} className="grid size-9 place-items-center rounded-full border border-[#141414]/15 transition-colors hover:bg-black/5 focus-visible:outline-2 focus-visible:outline-offset-2" > <svg viewBox="0 0 24 24" aria-hidden className="size-3.5" fill="currentColor"> <path d="M6 5h2v14H6zM20 5.5v13a.6.6 0 0 1-.9.5L9.6 12.5a.6.6 0 0 1 0-1L19.1 5a.6.6 0 0 1 .9.5Z" /> </svg> </button> <p className="font-mono text-[clamp(1.1rem,2.1cqw,1.6rem)] leading-none font-semibold tabular-nums"> {clock(elapsed)} <span className="text-[#141414]/50"> / {clock(track.duration)}</span> </p> <button type="button" onClick={next} aria-label="Next track" style={{ outlineColor: accent }} className="grid size-9 place-items-center rounded-full border border-[#141414]/15 transition-colors hover:bg-black/5 focus-visible:outline-2 focus-visible:outline-offset-2" > <svg viewBox="0 0 24 24" aria-hidden className="size-3.5" fill="currentColor"> <path d="M16 5h2v14h-2zM4 5.5v13a.6.6 0 0 0 .9.5l9.5-6.5a.6.6 0 0 0 0-1L4.9 5a.6.6 0 0 0-.9.5Z" /> </svg> </button> </div> </div> {/* Artist and tracks ---------------------------------------------- */} <div className="relative flex flex-col px-6 pt-10 pb-6 md:pt-[4.5rem] md:pr-10 md:pl-8"> <h2 className="font-[family-name:var(--font-condensed)] text-[clamp(3.5rem,12.5cqw,10rem)] leading-[0.84] font-[450] tracking-[-0.035em] uppercase" style={{ fontStretch: "62%", fontVariationSettings: '"wdth" 62' }} > {artist .trim() .split(/\s+/) .map((word) => ( <span key={word} className="block"> {word} </span> ))} </h2> {genres ? ( <p className="mt-3 font-mono text-[10px] font-semibold tracking-wide uppercase"> {genres} </p> ) : null} <p className="mt-8 text-[12px] font-medium uppercase" style={{ color: accent }}> Popular </p> <ol className="mt-2"> {tracks.map((item, item_index) => { const current = item_index === index; return ( <li key={`${item.title}-${item_index}`}> <button type="button" onClick={() => choose(item_index)} aria-current={current ? "true" : undefined} className="grid w-full grid-cols-[1.75rem_2rem_minmax(0,1fr)_auto] items-center gap-3 rounded-[2px] px-1.5 py-1 text-left transition-colors hover:bg-black/[0.04]" > <span className="font-mono text-[10px] tabular-nums" style={{ color: current ? accent : "#8f8b83" }} > {pad(item_index + 1)} </span> <img src={item.cover} alt="" loading="lazy" className="size-8 rounded-[2px] object-cover" /> <span className="min-w-0"> <span className="block truncate font-mono text-[11px] font-semibold tracking-wide uppercase"> {item.title} </span> <span className="block truncate font-mono text-[10px] tracking-wide text-[#9a968e] uppercase"> {item.album} </span> </span> <span className="font-mono text-[12px] font-semibold tabular-nums">{clock(item.duration)}</span> </button> </li> ); })} </ol> <a href="#tracks" className="mt-4 self-end font-mono text-[10px] font-semibold tracking-wide uppercase underline underline-offset-4" > View more </a> </div> </section> ); }
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| artist | string | — | Set one word per line, very large. |
| genres | string | — | Mono line under the artist name. |
| tracks | VinylTrack[] | — | Each needs a `title`, `album`, `duration` in seconds, and `cover`. |
| brand | string | "J/U" | Mark in the top-left corner, also printed round the label. |
| links | { label, href }[] | 3 links | Navigation in the top right. |
| accent | string | "#c63b36" | Progress arc, playhead, equaliser and section label. |
| initialTrack | number | 0 | Which track is on the platter to begin with. |
| autoPlay | boolean | true | Start spinning as soon as it mounts. |