Components / Album Cards

Album Cards

An interactive card component for displaying album artwork with expandable lyrics. Features smooth spring animations, auto-scrolling lyric highlights, and a karaoke-style text effect. Fully responsive — shows one card at a time on mobile with navigation controls.

Preview

gnx
yeezus

Note: Click on a card to expand it. Requires album images at /public/albums/

Usage

tsx
import { AlbumCards, Album } from "@/components/ui/album-cards"

const albums: Album[] = [
  {
    id: "album-1",
    title: "Track Name",
    artist: "Artist Name",
    image: "/albums/cover.png",
    lines: [
      "...",
      "First lyric line",
      "Second lyric line",
      "Third lyric line",
      "...",
    ]
  }
]

export function Example() {
  return <AlbumCards albums={albums} />
}

Props

PropTypeDescription
albumsAlbum[]Array of album objects to display
classNamestring?Additional CSS classes for the container

Album Type

PropertyTypeDescription
idstringUnique identifier for the album
titlestringTrack/song title
artiststringArtist name
imagestringPath to album cover image
linesstring[]Array of lyric lines to display

Dependencies

This component requires the motion/react library for animations. Install it with:

bash
pnpm add motion

Source Code

Copy this into your components/ui/ folder.

album-cards.tsxtsx
/* eslint-disable @next/next/no-img-element */
"use client";

import { motion, useAnimate, AnimationScope } from "motion/react";
import Image from "next/image";
import { useState, useEffect, useCallback } from "react";

import { cn } from "@/lib/utils";

export interface Album {
    id: string;
    title: string;
    artist: string;
    image: string;
    lines: string[];
}

interface AlbumCardsProps {
    albums: Album[];
    className?: string;
}

// Hook to detect mobile screen
function useIsMobile(breakpoint = 768) {
    const [isMobile, setIsMobile] = useState(false);

    useEffect(() => {
        const checkMobile = () => setIsMobile(window.innerWidth < breakpoint);
        checkMobile();
        window.addEventListener("resize", checkMobile);
        return () => window.removeEventListener("resize", checkMobile);
    }, [breakpoint]);

    return isMobile;
}

export function AlbumCards({ albums, className }: AlbumCardsProps) {
    const [expandedId, setExpandedId] = useState<string | null>(null);
    const [currentIndex, setCurrentIndex] = useState(0);
    const isMobile = useIsMobile();

    const animationRefs = albums.reduce((acc, album) => {
        const [scope, animate] = useAnimate<HTMLDivElement>();
        acc[album.id] = { scope, animate };
        return acc;
    }, {} as { [key: string]: { scope: AnimationScope<HTMLDivElement>, animate: ReturnType<typeof useAnimate>[1] } });

    const [highlightedIndices, setHighlightedIndices] = useState<{ [key: string]: number }>({});

    // Get responsive dimensions
    const getCollapsedSize = useCallback(() => ({
        width: isMobile ? "160px" : "180px",
        height: isMobile ? "160px" : "180px",
    }), [isMobile]);

    const getExpandedSize = useCallback(() => ({
        width: isMobile ? "320px" : "400px",
        height: isMobile ? "420px" : "500px",
    }), [isMobile]);

    useEffect(() => {
        const intervals: { [key: string]: NodeJS.Timeout } = {};
        albums.forEach(album => {
            if (expandedId === album.id) {
                intervals[album.id] = setInterval(() => {
                    setHighlightedIndices(prev => ({
                        ...prev,
                        [album.id]: ((prev[album.id] || 0) + 1) % album.lines.length
                    }));
                }, 1500);
            } else {
                setHighlightedIndices(prev => ({ ...prev, [album.id]: 0 }));
            }
        });
        return () => Object.values(intervals).forEach(clearInterval);
    }, [expandedId, albums]);

    const toggleExpand = async (clickedCardId: string) => {
        const currentlyExpandedCardId = expandedId;
        const newTargetCardId = (currentlyExpandedCardId === clickedCardId) ? null : clickedCardId;
        const collapsedSize = getCollapsedSize();
        const expandedSize = getExpandedSize();

        if (currentlyExpandedCardId && currentlyExpandedCardId !== newTargetCardId) {
            const { scope, animate } = animationRefs[currentlyExpandedCardId];
            if (scope.current) {
                await animate(scope.current, collapsedSize, { duration: 0.3, ease: "easeInOut" });
            }
        }
        setExpandedId(newTargetCardId);
        if (newTargetCardId) {
            const { scope, animate } = animationRefs[newTargetCardId];
            if (scope.current) {
                animate(scope.current, expandedSize, {
                    duration: 0.4, ease: "easeOut", type: "spring", stiffness: 100, damping: 16,
                });
            }
        }
    };

    // Mobile navigation
    const goToNext = () => { setCurrentIndex((prev) => (prev + 1) % albums.length); setExpandedId(null); };
    const goToPrev = () => { setCurrentIndex((prev) => (prev - 1 + albums.length) % albums.length); setExpandedId(null); };

    const displayedAlbums = isMobile ? [albums[currentIndex]] : albums;
    const collapsedSize = getCollapsedSize();

    return (
        <div className={cn("w-full flex flex-col items-center gap-4 font-courier", className)}>
            <div className="flex flex-row gap-6 justify-center items-start">
                {displayedAlbums.map((album) => (
                    <motion.div
                        key={album.id}
                        className="flex flex-col items-center justify-between border bg-card border-foreground/10 rounded-2xl shadow-sm cursor-pointer overflow-auto z-10 hide-scrollbar"
                        style={{ willChange: "width, height", ...collapsedSize }}
                        onClick={() => toggleExpand(album.id)}
                        ref={animationRefs[album.id].scope}
                        layout
                    >
                        {/* Card content... */}
                    </motion.div>
                ))}
            </div>

            {/* Mobile navigation */}
            {isMobile && albums.length > 1 && (
                <div className="flex items-center gap-4 mt-2">
                    <button onClick={goToPrev} className="p-2 rounded-full border border-foreground/10 bg-card">
                        <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
                        </svg>
                    </button>
                    <div className="flex gap-2">
                        {albums.map((_, index) => (
                            <button
                                key={index}
                                onClick={() => { setCurrentIndex(index); setExpandedId(null); }}
                                className={cn("w-2 h-2 rounded-full transition-all",
                                    index === currentIndex ? "bg-foreground w-4" : "bg-foreground/30"
                                )}
                            />
                        ))}
                    </div>
                    <button onClick={goToNext} className="p-2 rounded-full border border-foreground/10 bg-card">
                        <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                            <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
                        </svg>
                    </button>
                </div>
            )}
        </div>
    );
}