Components / Spotify
Spotify Now Playing
A live now-playing card built with React, TypeScript and Motion. A CD disc spins in the corner while collapsed, then flies to the top as the card springs open. Presence comes from Lanyard, with a localStorage fallback that keeps the last played track for a week.
Visual only — the disc spins and the bars animate, no audio is played. Open Song links to the Spotify track.
Installation
- 1
Install dependencies
The card only needs Motion for the spring animation and Tailwind CSS for styling.
npm install motion - 2
Add the presence source
Join the Lanyard Discord server (discord.gg/lanyard) with the account you listen on, then copy your Discord user ID. Lanyard exposes your Spotify activity as a public JSON endpoint — no OAuth, no server.
https://api.lanyard.rest/v1/users/<YOUR_DISCORD_ID> - 3
Copy the files
Drop the three files below into your project, keeping the paths so the imports resolve.
src/ components/spotify/Spotify.tsx components/icons/SpotifyIcon.tsx hooks/useSpotify.ts - 4
Render it
Pass your Discord ID. The card reserves a fixed slot and grows upward on click, so the surrounding layout never shifts.
<Spotify discordId="YOUR_DISCORD_ID" />
Download
Three TypeScript files. Copy or download each one — no CLI, no registry.
src/components/spotify/Spotify.tsx
"use client";
import { useState, useRef, useEffect } from "react";
import { motion as Motion, AnimatePresence } from "motion/react";
import { useSpotify } from "@/hooks/useSpotify";
import SpotifyIcon from "@/components/icons/SpotifyIcon";
const DISC_SIZE = 260;
const DISC_COLLAPSED = 100;
const DISC_SCALE_COLLAPSED = DISC_COLLAPSED / DISC_SIZE;
const CARD = {
collapsed: { w: 270, h: 88, r: 22 },
expanded: { w: 250, h: 284, r: 28 },
} as const;
/** Content starts below the disc bowl — disc bottom sits at ~y130 */
const EXPANDED_TEXT_TOP = 152;
/** Shared spring — every layer uses the same curve so nothing drifts apart */
const SPRING = {
type: "spring" as const,
stiffness: 380,
damping: 46,
mass: 0.9,
};
const FADE = {
duration: 0.2,
ease: [0.25, 1, 0.5, 1] as [number, number, number, number],
};
const SoundBars = ({ compact = false }: { compact?: boolean }) => {
const bars = compact
? [
{ max: 8, delay: 0 },
{ max: 12, delay: 0.15 },
{ max: 7, delay: 0.08 },
{ max: 11, delay: 0.22 },
]
: [
{ max: 10, delay: 0 },
{ max: 14, delay: 0.12 },
{ max: 8, delay: 0.06 },
{ max: 12, delay: 0.18 },
];
return (
<span className={`flex items-end gap-[2.5px] ${compact ? "h-3" : "h-3.5"}`}>
{bars.map((bar, i) => (
<Motion.span
key={i}
className="inline-block w-[2.5px] rounded-full bg-[#1DB954]"
animate={{ height: [3, bar.max, 3] }}
transition={{
duration: 0.65 + i * 0.05,
repeat: Infinity,
delay: bar.delay,
ease: "easeInOut",
}}
/>
))}
</span>
);
};
const CdDisc = ({
albumArt,
title,
isPlaying,
isOffline,
}: {
albumArt: string | null;
title: string;
isPlaying: boolean;
isOffline: boolean;
}) => (
<div className="relative size-full overflow-hidden rounded-full border border-black/10 shadow-[0_8px_24px_-4px_rgba(0,0,0,0.35),0_2px_6px_rgba(0,0,0,0.12)] dark:border-white/10">
{albumArt ? (
<img
src={albumArt}
alt={title}
className={`size-full rounded-full object-cover ${
isPlaying
? "animate-[spin_8s_linear_infinite]"
: "animate-[spin_18s_linear_infinite]"
}`}
style={{
filter: isOffline ? "saturate(65%) brightness(0.9)" : undefined,
}}
/>
) : (
<div className="flex size-full items-center justify-center bg-neutral-800">
<SpotifyIcon className="size-1/3 text-[#1DB954]" />
</div>
)}
<div
className="pointer-events-none absolute inset-0 rounded-full"
style={{
background: `
radial-gradient(circle at center, transparent 28%, rgba(0,0,0,0.06) 29%, transparent 30%),
radial-gradient(circle at center, transparent 42%, rgba(0,0,0,0.05) 43%, transparent 44%),
radial-gradient(circle at center, transparent 56%, rgba(0,0,0,0.04) 57%, transparent 58%),
radial-gradient(circle at center, transparent 70%, rgba(0,0,0,0.04) 71%, transparent 72%)
`,
}}
/>
<div
className="pointer-events-none absolute inset-0 rounded-full"
style={{
boxShadow:
"inset 0 0 0 1px rgba(255,255,255,0.18), inset 0 0 12px rgba(0,0,0,0.15)",
}}
/>
<div className="pointer-events-none absolute inset-0 m-auto flex size-[22%] items-center justify-center rounded-full border-2 border-[#A8ACBA] bg-gradient-to-b from-[#E8EBF5] via-[#9EA2B4] to-[#6B6F82] shadow-md">
<div className="size-[42%] rounded-full border-[1.5px] border-[#3a3a3a] bg-[#111111] shadow-inner" />
</div>
</div>
);
export interface SpotifyProps {
/** Discord user ID tracked by Lanyard (join discord.gg/lanyard once). */
discordId: string;
/** Optional override — renders a fake "playing" track (visual only, no audio). */
demoTrack?: {
title: string;
artist: string;
albumArt: string | null;
songUrl: string;
} | null;
}
const Spotify = ({ discordId, demoTrack }: SpotifyProps) => {
const { data, loading } = useSpotify({ discordId });
const [isExpanded, setIsExpanded] = useState(false);
const cardRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isExpanded) return;
const handleOutsideClick = (e: MouseEvent) => {
if (cardRef.current && !cardRef.current.contains(e.target as Node)) {
setIsExpanded(false);
}
};
document.addEventListener("mousedown", handleOutsideClick);
return () => document.removeEventListener("mousedown", handleOutsideClick);
}, [isExpanded]);
if (!discordId) return null;
if (loading && !demoTrack) {
return (
<div className="relative z-40 my-6 h-14 w-full select-none">
<div className="absolute right-0 bottom-0 left-0 mx-auto flex w-fit items-center justify-center">
<div className="flex h-[88px] w-[270px] items-center gap-3 overflow-hidden rounded-[22px] border border-neutral-200/80 bg-white p-3.5 shadow-[0_1px_2px_rgba(0,0,0,0.04),0_8px_24px_-6px_rgba(0,0,0,0.08)] dark:border-neutral-700/60 dark:bg-[#1A1715]">
<div className="flex min-w-0 flex-1 flex-col gap-2.5 pl-1">
<div className="h-2.5 w-20 animate-pulse rounded-full bg-neutral-200 dark:bg-neutral-700/50" />
<div className="h-2.5 w-16 animate-pulse rounded-full bg-neutral-100 dark:bg-neutral-700/30" />
<div className="h-3.5 w-28 animate-pulse rounded-full bg-neutral-200 dark:bg-neutral-700/50" />
</div>
<div className="size-[72px] shrink-0 animate-pulse rounded-full bg-neutral-100 dark:bg-neutral-800" />
</div>
</div>
</div>
);
}
const isOffline = !demoTrack && !data;
const displayData = demoTrack
? { ...demoTrack, isPlaying: true }
: data || {
isPlaying: false,
title: "Offline",
artist: "Not listening right now",
albumArt: null,
songUrl: "https://open.spotify.com",
};
const statusLabel = displayData.isPlaying
? "Now Playing"
: isOffline
? "Offline"
: "Last Played";
return (
/* Fixed slot — expanded card overflows upward, page never shifts */
<div className="relative z-40 my-6 h-14 w-full select-none">
<div className="absolute right-0 bottom-0 left-0 mx-auto flex w-fit items-end justify-center">
<Motion.div
ref={cardRef}
onClick={() => setIsExpanded((v) => !v)}
initial={false}
whileTap={{ scale: 0.985 }}
transition={SPRING}
animate={{
width: isExpanded ? CARD.expanded.w : CARD.collapsed.w,
height: isExpanded ? CARD.expanded.h : CARD.collapsed.h,
borderRadius: isExpanded ? CARD.expanded.r : CARD.collapsed.r,
}}
style={{ transformOrigin: "bottom center" }}
className="group relative z-50 cursor-pointer overflow-hidden border border-neutral-200/90 bg-white text-neutral-800 shadow-[0_1px_2px_rgba(0,0,0,0.04),0_8px_24px_-6px_rgba(0,0,0,0.1)] dark:border-[#38332F] dark:bg-[#1A1715] dark:text-white dark:shadow-[0_1px_2px_rgba(0,0,0,0.2),0_8px_24px_-6px_rgba(0,0,0,0.4)]"
>
{/*
Disc stays at full 260 px — only scale + translate animate (GPU path).
Collapsed center ≈ (248, 44), expanded center ≈ (125, 0).
*/}
<Motion.div
initial={false}
style={{ width: DISC_SIZE, height: DISC_SIZE }}
animate={
isExpanded
? { scale: 1, x: -5, y: -130 }
: { scale: DISC_SCALE_COLLAPSED, x: 118, y: -86 }
}
transition={SPRING}
className="pointer-events-none absolute top-0 left-0 z-20"
>
<CdDisc
albumArt={displayData.albumArt}
title={displayData.title}
isPlaying={displayData.isPlaying}
isOffline={isOffline}
/>
</Motion.div>
{/* Edge soften — always mounted; delayed fade-in on collapse to avoid white flash */}
<Motion.div
initial={false}
animate={{ opacity: isExpanded ? 0 : 1 }}
transition={{
duration: 0.18,
delay: isExpanded ? 0 : 0.32,
ease: FADE.ease,
}}
className="pointer-events-none absolute inset-y-0 right-[68px] z-30 w-8 bg-gradient-to-r from-white to-transparent dark:from-[#1A1715]"
/>
<AnimatePresence>
{displayData.isPlaying && !isExpanded && (
<Motion.div
key="glow"
initial={{ opacity: 0 }}
animate={{ opacity: 0.4 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.25 }}
className="pointer-events-none absolute -right-4 top-1/2 size-28 -translate-y-1/2 rounded-full blur-2xl"
style={{
background:
"radial-gradient(circle, rgba(29,185,84,0.45) 0%, transparent 70%)",
}}
/>
)}
</AnimatePresence>
{/* Collapsed / expanded content */}
<AnimatePresence mode="wait">
{!isExpanded ? (
<Motion.div
key="collapsed-content"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
className="absolute inset-y-0 left-0 z-10 flex w-[calc(100%-100px)] flex-col justify-center gap-1.5 pl-4 pr-2"
>
<div className="flex items-center gap-1.5">
<SpotifyIcon className="size-3 shrink-0 text-[#1DB954]" />
<span className="text-[11px] font-medium tracking-wide text-neutral-400 dark:text-neutral-500">
{statusLabel}
</span>
{displayData.isPlaying && <SoundBars compact />}
</div>
<div className="flex min-w-0 flex-col gap-0.5">
<span className="truncate text-xs font-semibold leading-snug tracking-tight text-neutral-900 dark:text-white">
{displayData.title}
</span>
<span className="truncate text-[11px] font-medium leading-snug text-neutral-500 dark:text-neutral-400">
{displayData.artist}
</span>
</div>
</Motion.div>
) : (
<Motion.div
key="expanded-content"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 10 }}
transition={{ duration: 0.2 }}
style={{ top: EXPANDED_TEXT_TOP, height: `calc(100% - ${EXPANDED_TEXT_TOP}px)` }}
className="absolute left-0 z-10 flex w-full flex-col items-center px-5 pb-8 pt-1 text-center"
>
{displayData.isPlaying && (
<div className="mb-4 flex items-center justify-center">
<SoundBars />
</div>
)}
<div className="flex w-full flex-col items-center gap-1 text-center">
<span
className="block w-full truncate text-sm font-semibold leading-snug tracking-tight text-neutral-900 dark:text-white"
style={{ maxWidth: 210 }}
>
{displayData.title}
</span>
<span
className="block w-full truncate text-xs font-medium leading-snug text-neutral-500 dark:text-neutral-400"
style={{ maxWidth: 210 }}
>
{displayData.artist}
</span>
</div>
<div className="mt-auto flex w-full flex-col items-center pt-4">
<div className="mb-4 h-[2px] w-6 rounded-full bg-neutral-400/40 dark:bg-neutral-600/40" />
<a
href={displayData.songUrl || "https://open.spotify.com"}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="group/link flex cursor-pointer items-center gap-1.5 pb-0.5 text-xs font-bold text-[#1DB954] transition-colors hover:text-[#1ed760]"
>
<span>Open Song</span>
<SpotifyIcon className="size-3.5 text-[#1DB954] transition-transform duration-150 group-hover/link:scale-110" />
</a>
</div>
</Motion.div>
)}
</AnimatePresence>
</Motion.div>
</div>
</div>
);
};
export default Spotify;
Usage
import Spotify from "@/components/spotify/Spotify"; export default function Page() {
return (
<section className="mx-auto max-w-sm">
<Spotify discordId="707371655380664321" />
</section>
);
}Props
| Name | Type | Default | Description |
|---|---|---|---|
| discordId | string | — | Discord user ID tracked by Lanyard. Required; the component renders nothing without it. |
| demoTrack | { title, artist, albumArt, songUrl } | null | null | Overrides presence with a fake playing track — visual only, no audio playback. |
| pollInterval | number | 15000 | useSpotify option — how often the presence endpoint is polled, in ms. |
| storageKey | string | "spotify_last_played" | useSpotify option — localStorage key used to show the last played track while offline. |