spark-ui
componentspotify-cardsource: ok · hit
Spotify Card
A premium Spotify card with real-time metadata and morphing animations.
npx shadcn add "https://spark-ui-olive.vercel.app/r/spotify-card.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationframer-motion
iconslucide-react
Files2 · source cached 10/8/2026, 1:40:50 PM
registry/spark-ui/spotify/spotify-card.tsx
/* eslint-disable @next/next/no-img-element */
"use client";
import { cn } from "@/lib/utils";
import { motion, useAnimation } from "framer-motion";
import { Loader2 } from "lucide-react";
import {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState,
} from "react";
export interface SpotifyCardProps {
trackUrl: string;
className?: string;
onPlayingChange?: (isPlaying: boolean) => void;
}
export interface SpotifyCardRef {
togglePlayback: () => void;
}
const SpotifyLogo = ({
className,
isPlaying,
}: {
className?: string;
isPlaying?: boolean;
}) => (
<motion.svg
viewBox="0 0 24 24"
className={cn(
"fill-current opacity-40 transition-colors duration-500",
className,
)}
animate={
isPlaying
? {
scale: [1, 1.1, 1],
opacity: [0.4, 1, 0.4],
color: ["#ffffff", "#1DB954", "#ffffff"],
}
: { color: "currentColor" }
}
transition={{ duration: 2.5, repeat: Infinity, ease: "easeInOut" }}
xmlns="http://www.w3.org/2000/svg"
>
<path d="M12 0C5.373 0 0 5.373 0 12s5.373 12 12 12 12-5.373 12-12S18.627 0 12 0zm5.49 17.306c-.216.353-.674.464-1.027.249-2.846-1.74-6.427-2.133-10.645-1.17-.404.093-.81-.157-.903-.561-.093-.404.157-.81.561-.903 4.613-1.055 8.567-.611 11.76 1.343.353.216.464.674.249 1.027zm1.464-3.256c-.272.44-.848.58-1.288.308-3.258-1.998-8.224-2.58-12.074-1.41-.497.15-1.022-.13-1.173-.627-.151-.498.13-1.023.627-1.174 4.406-1.34 9.89-.687 13.6 1.587.441.27.581.847.308 1.287zm.126-3.393c-3.906-2.32-10.334-2.533-14.072-1.398-.6.182-1.23-.163-1.412-.763-.182-.599.163-1.23.763-1.412 4.293-1.303 11.395-1.043 15.9 1.631.538.319.715 1.015.396 1.554-.319.539-1.015.716-1.555.397z" />
</motion.svg>
);
export const SpotifyCard = forwardRef<SpotifyCardRef, SpotifyCardProps>(
function SpotifyCard({ trackUrl, className, onPlayingChange }, ref) {
const [isPlaying, setIsPlaying] = useState(false);
const [mounted, setMounted] = useState(false);
const [metadata, setMetadata] = useState<{
title: string;
artist: string;
albumArt: string;
previewUrl: string;
} | null>(null);
const [loading, setLoading] = useState(true);
const [colors, setColors] = useState<string[]>(["#1DB954", "#191414"]);
const audioRef = useRef<HTMLAudioElement | null>(null);
const discControls = useAnimation();
useEffect(() => {
onPlayingChange?.(isPlaying);
}, [isPlaying, onPlayingChange]);
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect -- hydration gate
setMounted(true);
// The API accepts only a validated track ID (never a raw URL) to prevent
// server-side request forgery.
const trackId = trackUrl.match(/\/track\/([A-Za-z0-9]+)/)?.[1];
const fetchMetadata = async () => {
if (!trackId) {
console.error("Spotify Card Error: invalid Spotify track URL");
setLoading(false);
return;
}
setLoading(true);
try {
const response = await fetch(
`/api/spotify/metadata?trackId=${encodeURIComponent(trackId)}`,
);
if (!response.ok) throw new Error("Failed to fetch");
const data = await response.json();
setMetadata(data);
// Extract palette from image
if (data.albumArt) {
const img = new Image();
img.crossOrigin = "Anonymous";
img.src = data.albumArt;
img.onload = () => {
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d");
if (ctx) {
canvas.width = 10;
canvas.height = 10;
ctx.drawImage(img, 0, 0, 10, 10);
const pixels = ctx.getImageData(0, 0, 10, 10).data;
const uniqueColors = new Set<string>();
for (let i = 0; i < pixels.length; i += 12) {
const r = pixels[i];
const g = pixels[i + 1];
const b = pixels[i + 2];
uniqueColors.add(`rgb(${r}, ${g}, ${b})`);
if (uniqueColors.size >= 3) break;
}
setColors(Array.from(uniqueColors));
}
};
}
// Cleanup previous audio if any
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
if (data.previewUrl) {
const audio = new Audio(data.previewUrl);
audio.crossOrigin = "anonymous";
audio.onended = () => {
setIsPlaying(false);
};
audioRef.current = audio;
} else {
console.warn("No preview URL found for this track");
}
} catch (err) {
console.error("Spotify Card Error:", err);
} finally {
setLoading(false);
}
};
fetchMetadata();
return () => {
if (audioRef.current) {
audioRef.current.pause();
audioRef.current = null;
}
};
}, [trackUrl]);
useEffect(() => {
if (isPlaying) {
discControls.start({
rotate: 360,
transition: { duration: 4, repeat: Infinity, ease: "linear" },
});
} else {
discControls.stop();
discControls.start({
rotate: 0,
transition: { duration: 0.8, ease: "backOut" },
});
}
}, [isPlaying, discControls]);
const togglePlayback = () => {
if (!audioRef.current) return;
if (isPlaying) {
audioRef.current.pause();
setIsPlaying(false);
} else {
// Use a then/catch to handle potential play request interruptions
audioRef.current
.play()
.then(() => {
setIsPlaying(true);
})
.catch((err) => {
console.warn("Playback interrupted:", err);
setIsPlaying(false);
});
}
};
useImperativeHandle(ref, () => ({ togglePlayback }), [isPlaying]);
if (loading || !mounted) {
return (
<div
className={cn(
"w-95 h-32.5 rounded-3xl bg-black/5 dark:bg-white/5 flex items-center justify-center border border-black/5 dark:border-white/10 backdrop-blur-xl",
className,
)}
>
<Loader2 className="w-5 h-5 text-zinc-500 animate-spin" />
</div>
);
}
const {
title = "Unknown Track",
artist = "Unknown Artist",
albumArt = "",
} = metadata || {};
return (
<div
className={cn(
"group relative w-95 h-32.5 rounded-3xl overflow-hidden shadow-2xl transition-all duration-500 border border-black/5 dark:border-white/10 bg-white/40 dark:bg-black/40 backdrop-blur-2xl",
className,
)}
>
{/* Background Image Layer */}
<div className="absolute inset-0 z-0 pointer-events-none overflow-hidden">
<motion.div
animate={{
scale: [1, 1.05, 1],
rotate: [0, 1, -1, 0],
}}
transition={{
duration: 20,
repeat: Infinity,
ease: "easeInOut",
}}
className="absolute inset-0"
>
<img
src={albumArt}
alt=""
className="w-full h-full object-cover blur-xl scale-125 opacity-50 dark:opacity-50"
/>
</motion.div>
{/* Subtle Overlay Wash */}
<div className="absolute inset-0 bg-white/10 dark:bg-transparent" />
{/* Animated Blobs for depth */}
<motion.div
animate={{
x: [-20, 20, -20],
y: [-10, 10, -10],
scale: [1, 1.2, 1],
}}
transition={{
duration: 10,
repeat: Infinity,
ease: "easeInOut",
}}
className="absolute top-0 left-0 w-64 h-64 rounded-full blur-3xl opacity-20 dark:opacity-30 pointer-events-none"
style={{
background: `radial-gradient(circle, ${colors[0]} 0%, transparent 70%)`,
}}
/>
<motion.div
animate={{
x: [20, -20, 20],
y: [10, -10, 10],
scale: [1.2, 1, 1.2],
}}
transition={{
duration: 12,
repeat: Infinity,
ease: "easeInOut",
}}
className="absolute bottom-0 right-0 w-64 h-64 rounded-full blur-3xl opacity-20 dark:opacity-30 pointer-events-none"
style={{
background: `radial-gradient(circle, ${colors[1] || colors[0]} 0%, transparent 70%)`,
}}
/>
{/* Noise Texture Overlay */}
<div className="absolute inset-0 opacity-[0.03] dark:opacity-[0.05] pointer-events-none mix-blend-overlay bg-[url('https://grainy-gradients.vercel.app/noise.svg')]" />
</div>
{/* Content */}
<div className="relative z-10 flex items-center h-full px-5 gap-6">
{/* Album Cover / Disc */}
<div className="relative flex items-center justify-center w-24 h-24 shrink-0">
<motion.button
type="button"
aria-label={isPlaying ? `Pause ${title}` : `Play ${title}`}
layout
animate={{
borderRadius: isPlaying ? "100%" : "16px",
}}
transition={{ duration: 0.6, ease: "circOut" }}
className="relative w-24 h-24 overflow-hidden shadow-2xl cursor-pointer ring-1 ring-black/5 dark:ring-white/10 z-10 outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={togglePlayback}
>
<motion.div
animate={discControls}
className="w-full h-full origin-center"
>
<img
src={albumArt}
alt={title}
className="w-full h-full object-cover select-none pointer-events-none"
/>
{/* Vinyl Texture */}
<motion.div
initial={false}
animate={{ opacity: isPlaying ? 0.3 : 0 }}
className="absolute inset-0 bg-[repeating-radial-gradient(circle,transparent,transparent_1px,rgba(255,255,255,0.1)_2px)] pointer-events-none"
/>
{/* Disc Center */}
<motion.div
initial={false}
animate={{
opacity: isPlaying ? 1 : 0,
scale: isPlaying ? 1 : 0.5,
}}
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-6 h-6 rounded-full bg-black/90 dark:bg-black/95 border border-white/20 flex items-center justify-center pointer-events-none shadow-xl"
>
<div className="w-1.5 h-1.5 rounded-full bg-white/10" />
</motion.div>
</motion.div>
</motion.button>
</div>
{/* Info Section */}
<div className="flex-1 min-w-0 flex flex-col justify-center gap-1 overflow-hidden pr-8">
<div className="flex flex-col min-w-0">
<h3 className="text-lg font-bold text-zinc-900 dark:text-white tracking-tight leading-tight truncate">
{title}
</h3>
<span className="text-sm font-semibold text-zinc-600 dark:text-zinc-400 truncate opacity-80">
{artist}
</span>
</div>
</div>
{/* Spotify Branding */}
<div className="absolute top-5 right-6">
<SpotifyLogo
className="w-5 h-5 text-zinc-900 dark:text-white"
isPlaying={isPlaying}
/>
</div>
</div>
</div>
);
},
);
registry/spark-ui/spotify/metadata-route.ts
import * as cheerio from "cheerio";
import got from "got";
import { NextRequest, NextResponse } from "next/server";
// SSRF guard: the outbound request destination is always built server-side
// from a validated Spotify track ID — never from a caller-supplied URL.
const TRACK_ID_RE = /^[A-Za-z0-9]{1,64}$/;
const MAX_RESPONSE_BYTES = 1_000_000;
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const trackId = searchParams.get("trackId") ?? "";
if (!TRACK_ID_RE.test(trackId)) {
return NextResponse.json({ error: "Invalid track ID" }, { status: 400 });
}
try {
const request = got(`https://open.spotify.com/track/${trackId}`, {
headers: {
"user-agent": "Mozilla/5.0 (compatible; SparkUI-SpotifyCard/1.0)",
accept: "text/html",
},
followRedirect: false,
retry: { limit: 0 },
timeout: { request: 5000 },
});
request.on("downloadProgress", ({ transferred }) => {
if (transferred > MAX_RESPONSE_BYTES) request.cancel();
});
const { headers, body: html } = await request;
if (!headers["content-type"]?.includes("text/html")) {
return NextResponse.json(
{ error: "Failed to fetch metadata" },
{ status: 502 },
);
}
const $ = cheerio.load(html);
const title =
$('meta[property="og:title"]').attr("content") || "Unknown Track";
const image = $('meta[property="og:image"]').attr("content") || "";
const previewUrl =
$('meta[property="og:audio"]').attr("content") ||
$('meta[name="twitter:audio:src"]').attr("content") ||
"";
// Spotify's og:description is often: "Artist · Album · Song · Year"
const description =
$('meta[property="og:description"]').attr("content") || "";
// Usually the first part is the Artist
const artist = description.split("·")[0]?.trim() || "Unknown Artist";
return NextResponse.json({
title,
artist,
albumArt: image,
previewUrl,
});
} catch {
// Non-2xx, redirect attempt, timeout, or oversized response. Keep the
// client response generic — no upstream details.
return NextResponse.json(
{ error: "Failed to fetch metadata" },
{ status: 502 },
);
}
}