Skip to content
jswnthui

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.

J/U

02.BORROWED SUMMER

0:00 / 4:08

NeonHarbor

Dream pop, neo-psych, disco, synth-pop

Popular

View more

Pick a track, or tap the label to stop the platter

Installation

  1. 1. Install the dependencies.

    terminal
    npm install gsap clsx tailwind-merge
  2. 2. Add the cn helper, 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. 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

PropTypeDefaultDescription
artiststring—Set one word per line, very large.
genresstring—Mono line under the artist name.
tracksVinylTrack[]—Each needs a `title`, `album`, `duration` in seconds, and `cover`.
brandstring"J/U"Mark in the top-left corner, also printed round the label.
links{ label, href }[]3 linksNavigation in the top right.
accentstring"#c63b36"Progress arc, playhead, equaliser and section label.
initialTracknumber0Which track is on the platter to begin with.
autoPlaybooleantrueStart spinning as soon as it mounts.