Showing “Now Playing” from the Spotify API in Framer

Showing “Now Playing” from the Spotify API in Framer

Feb 26, 2026

#Framer, #Portfolio #Spotify #Api

What do you add to a portfolio website besides about you, our work and credentials? For me, I want my portfolio to feel personal. It should reflect me.

One of my inspirations was Lee Robinson. A couple of years ago, he added little live stats to his site. He doesn’t do it anymore though. But, that gave me an idea: a personal site can be more than static pages. By connecting to APIs, it can become dynamic. It can reflect live data from the products and services you use.

Here’s one idea. Show what’s currently playing on my Spotify. It displays the track title, artist, and album art in real time.

I like it a lot. It’s a small detail, but it brings personality to the site, especially showing the album art. It adds texture and color to a website that’s mostly black and white. And because it’s connected to the Spotify API, it updates automatically whenever the song changes.

Thanks to Claude Code, ideas like this move much faster now. I gave it a prompt, adjusted a few details, and ended up with a working React component in Framer that fetches data from an endpoint on my domain. I host the endpoint as a small serverless function on Vercel. My traffic is low, so the free tier is more than enough.

Here's how the framer react component looks like:

Some variations you can get from customizing the properties:

And the empty state…

Then loading state…

If you’re curious, here’s the source code.

If you have no idea how to set it up, copy and paste the article and the code into Claude Code or Codex and ask it to wire it up for you

import { addPropertyControls, ControlType } from "framer"
import { useEffect, useState } from "react"

interface NowPlayingData {
    albumImageUrl: string
    title: string
    artist: string
    songUrl: string
    loaded: boolean
}

type Layout = "compact" | "spacious"
type AlbumStyle = "square" | "cd"

interface Props {
    apiUrl: string
    layout: Layout
    label: {
        text: string
        color: string
        font: object
    }
    track: {
        titleColor: string
        artistColor: string
        separator: string
        font: object
    }
    albumArt: {
        show: boolean
        style: AlbumStyle
    }
    content: {
        silenceText: string
        loadingText: string
    }
}

const layoutConfig = {
    compact: {
        albumSize: 32,
        albumRadius: 4,
        labelSize: 10,
        titleSize: 12,
        artistSize: 12,
        gap: 8,
        innerGap: 5,
        labelGap: 2,
    },
    spacious: {
        albumSize: 64,
        albumRadius: 10,
        labelSize: 12,
        titleSize: 20,
        artistSize: 20,
        gap: 16,
        innerGap: 6,
        labelGap: 4,
    },
}

const ANIMATIONS = `
    @keyframes marquee-scroll {
        0%   { transform: translateX(0); }
        100% { transform: translateX(-50%); }
    }
    @keyframes cd-spin {
        from { transform: rotate(0deg); }
        to   { transform: rotate(360deg); }
    }
    @keyframes skeleton-shine {
        0%   { background-position: -200% 0; }
        100% { background-position: 200% 0; }
    }
`

export default function SpotifyNowPlaying({
    apiUrl,
    layout,
    label,
    track,
    albumArt,
    content,
}: Props) {
    const [imgLoaded, setImgLoaded] = useState(false)
    const [data, setData] = useState<NowPlayingData>({
        albumImageUrl: "",
        title: "",
        artist: "",
        songUrl: "",
        loaded: false,
    })

    useEffect(() => {
        fetch(apiUrl)
            .then((res) => res.json())
            .then((json) => {
                const hasTrackData = json.isPlaying && json.title && json.songUrl
                if (hasTrackData) {
                    setData({
                        albumImageUrl: json.albumImageUrl,
                        title: json.title,
                        artist: json.artist,
                        songUrl: json.songUrl,
                        loaded: true,
                    })
                } else {
                    setData({
                        albumImageUrl: "",
                        title: content.silenceText,
                        artist: "",
                        songUrl: "",
                        loaded: true,
                    })
                }
            })
            .catch(() => {
                setData({
                    albumImageUrl: "",
                    title: "Couldn't load now playing",
                    artist: "",
                    songUrl: "",
                    loaded: true,
                })
            })
    }, [apiUrl, content.silenceText])

    useEffect(() => {
        setImgLoaded(false)
    }, [data.albumImageUrl])

    const {
        albumSize,
        albumRadius,
        labelSize,
        titleSize,
        artistSize,
        gap,
        innerGap,
        labelGap,
    } = layoutConfig[layout] ?? layoutConfig.compact

    const trackTitle = data.loaded ? data.title : content.loadingText
    const hasArtist = data.title.trim() !== "" && data.artist.trim() !== ""

    // Speed: roughly 1s per 4 chars, min 6s
    const marqueeDuration = Math.max(
        6,
        (trackTitle.length + (data.artist?.length ?? 0)) * 0.25
    )

    const handleClick = () => {
        if (data.songUrl) window.open(data.songUrl, "_blank")
    }

    const isCD = albumArt.style === "cd"
    const imgBorderRadius = isCD ? "50%" : albumRadius

    // Show album art area only while loading (skeleton) or when a song is playing
    const showAlbumArea =
        albumArt.show && (!data.loaded || !!data.albumImageUrl)

    return (
        <>
            <style>{ANIMATIONS}</style>
            <div
                onClick={handleClick}
                style={{
                    display: "flex",
                    flexDirection: "row",
                    alignItems: "center",
                    gap,
                    cursor: data.songUrl ? "pointer" : "default",
                    width: "100%",
                    height: "100%",
                    overflow: "hidden",
                }}
            >
                {/* Album art */}
                {showAlbumArea && (
                    <div
                        style={{
                            position: "relative",
                            width: albumSize,
                            height: albumSize,
                            flexShrink: 0,
                            borderRadius: imgBorderRadius,
                            overflow: "hidden",
                        }}
                    >
                        {/* Skeleton — only while fetching or image not yet loaded */}
                        {(!data.loaded || !imgLoaded) && (
                            <div
                                style={{
                                    position: "absolute",
                                    inset: 0,
                                    background:
                                        "linear-gradient(90deg, #d0d0d0 25%, #e8e8e8 50%, #d0d0d0 75%)",
                                    backgroundSize: "200% 100%",
                                    animationName: "skeleton-shine",
                                    animationDuration: "1.5s",
                                    animationTimingFunction: "linear",
                                    animationIterationCount: "infinite",
                                }}
                            />
                        )}
                        {/* Image */}
                        {data.albumImageUrl && (
                            <img
                                src={data.albumImageUrl}
                                alt={`${data.title} album art`}
                                onLoad={() => setImgLoaded(true)}
                                style={{
                                    width: albumSize,
                                    height: albumSize,
                                    objectFit: "cover",
                                    display: "block",
                                    opacity: imgLoaded ? 1 : 0,
                                    animationName: isCD ? "cd-spin" : "none",
                                    animationDuration: "5s",
                                    animationTimingFunction: "linear",
                                    animationIterationCount: "infinite",
                                }}
                            />
                        )}
                        {/* CD center hole */}
                        {isCD && imgLoaded && (
                            <div
                                style={{
                                    position: "absolute",
                                    top: "50%",
                                    left: "50%",
                                    transform: "translate(-50%, -50%)",
                                    width: albumSize * 0.2,
                                    height: albumSize * 0.2,
                                    borderRadius: "50%",
                                    background: "white",
                                    boxShadow: "inset 0 0 3px rgba(0,0,0,0.25)",
                                    pointerEvents: "none",
                                }}
                            />
                        )}
                    </div>
                )}

                {/* Text column */}
                <div
                    style={{
                        display: "flex",
                        flexDirection: "column",
                        gap: labelGap,
                        overflow: "hidden",
                        flex: 1,
                        minWidth: 0,
                    }}
                >
                    {/* Now playing label */}
                    <span
                        style={{
                            fontSize: labelSize,
                            color: label.color,
                            opacity: 1,
                            whiteSpace: "nowrap",
                            textTransform: "uppercase",
                            letterSpacing: "0.06em",
                            ...label.font,
                        }}
                    >
                        {label.text}
                    </span>

                    {/* Compact: inline marquee (disabled for silence) */}
                    {layout === "compact" ? (
                        <div style={{ overflow: "hidden", width: "100%" }}>
                            <div
                                style={{
                                    display: "inline-flex",
                                    alignItems: "center",
                                    whiteSpace: "nowrap",
                                    animationName: data.songUrl
                                        ? "marquee-scroll"
                                        : "none",
                                    animationDuration: `${marqueeDuration}s`,
                                    animationTimingFunction: "linear",
                                    animationIterationCount: "infinite",
                                }}
                            >
                                {/* Duplicate for seamless loop */}
                                {(data.songUrl ? [0, 1] : [0]).map((i) => (
                                    <span
                                        key={i}
                                        style={{
                                            display: "inline-flex",
                                            alignItems: "center",
                                            gap: innerGap,
                                            paddingRight: 15,
                                        }}
                                    >
                                        <span
                                            style={{
                                                fontSize: titleSize,
                                                color: track.titleColor,
                                                ...track.font,
                                            }}
                                        >
                                            {trackTitle}
                                        </span>
                                        {hasArtist && (
                                            <>
                                                <span
                                                    style={{
                                                        fontSize: titleSize,
                                                        color: track.titleColor,
                                                        ...track.font,
                                                    }}
                                                >
                                                    {track.separator}
                                                </span>
                                                <span
                                                    style={{
                                                        fontSize: artistSize,
                                                        color: track.artistColor,
                                                        ...track.font,
                                                    }}
                                                >
                                                    {data.artist}
                                                </span>
                                            </>
                                        )}
                                    </span>
                                ))}
                            </div>
                        </div>
                    ) : (
                        /* Spacious: inline, no marquee */
                        <div
                            style={{
                                display: "flex",
                                flexDirection: "row",
                                alignItems: "center",
                                gap: innerGap,
                                overflow: "hidden",
                            }}
                        >
                            <span
                                style={{
                                    fontSize: titleSize,
                                    color: track.titleColor,
                                    whiteSpace: "nowrap",
                                    overflow: "hidden",
                                    textOverflow: "ellipsis",
                                    flexShrink: 0,
                                    ...track.font,
                                }}
                            >
                                {trackTitle}
                            </span>
                            {hasArtist && (
                                <>
                                    <span
                                        style={{
                                            fontSize: titleSize,
                                            color: track.titleColor,
                                            flexShrink: 0,
                                            ...track.font,
                                        }}
                                    >
                                        {track.separator}
                                    </span>
                                    <span
                                        style={{
                                            fontSize: artistSize,
                                            color: track.artistColor,
                                            whiteSpace: "nowrap",
                                            overflow: "hidden",
                                            textOverflow: "ellipsis",
                                            ...track.font,
                                        }}
                                    >
                                        {data.artist}
                                    </span>
                                </>
                            )}
                        </div>
                    )}
                </div>
            </div>
        </>
    )
}

SpotifyNowPlaying.defaultProps = {
    apiUrl: "https://[yourdomain]/api/now-playing",
    layout: "compact",
    label: {
        text: "Now Playing",
        color: "#000000",
        font: {},
    },
    track: {
        titleColor: "#000000",
        artistColor: "#000000",
        separator: "—",
        font: {},
    },
    albumArt: {
        show: true,
        style: "square",
    },
    content: {
        silenceText: "I'm currently enjoying the silence",
        loadingText: "Loading...",
    },
}

addPropertyControls(SpotifyNowPlaying, {
    apiUrl: {
        type: ControlType.String,
        title: "API URL",
    },
    layout: {
        type: ControlType.Enum,
        title: "Layout",
        options: ["compact", "spacious"],
        optionTitles: ["Compact", "Spacious"],
    },
    albumArt: {
        type: ControlType.Object,
        title: "Album Art",
        controls: {
            show: {
                type: ControlType.Boolean,
                title: "Show",
            },
            style: {
                type: ControlType.Enum,
                title: "Style",
                options: ["square", "cd"],
                optionTitles: ["Square", "Spinning CD"],
                hidden: (props: { show: boolean }) => !props.show,
            },
        },
    },
    label: {
        type: ControlType.Object,
        title: "Label",
        controls: {
            text: {
                type: ControlType.String,
                title: "Text",
            },
            color: {
                type: ControlType.Color,
                title: "Color",
            },
            font: {
                type: ControlType.Font,
                title: "Font",
                controls: "extended",
            },
        },
    },
    track: {
        type: ControlType.Object,
        title: "Track",
        controls: {
            titleColor: {
                type: ControlType.Color,
                title: "Title Color",
            },
            artistColor: {
                type: ControlType.Color,
                title: "Artist Color",
            },
            separator: {
                type: ControlType.String,
                title: "Separator",
            },
            font: {
                type: ControlType.Font,
                title: "Font",
                controls: "extended",
            },
        },
    },
    content: {
        type: ControlType.Object,
        title: "Content",
        controls: {
            silenceText: {
                type: ControlType.String,
                title: "Silence Text",
            },
            loadingText: {
                type: ControlType.String,
                title: "Loading Text",
            },
        },
    },
})
import { addPropertyControls, ControlType } from "framer"
import { useEffect, useState } from "react"

interface NowPlayingData {
    albumImageUrl: string
    title: string
    artist: string
    songUrl: string
    loaded: boolean
}

type Layout = "compact" | "spacious"
type AlbumStyle = "square" | "cd"

interface Props {
    apiUrl: string
    layout: Layout
    label: {
        text: string
        color: string
        font: object
    }
    track: {
        titleColor: string
        artistColor: string
        separator: string
        font: object
    }
    albumArt: {
        show: boolean
        style: AlbumStyle
    }
    content: {
        silenceText: string
        loadingText: string
    }
}

const layoutConfig = {
    compact: {
        albumSize: 32,
        albumRadius: 4,
        labelSize: 10,
        titleSize: 12,
        artistSize: 12,
        gap: 8,
        innerGap: 5,
        labelGap: 2,
    },
    spacious: {
        albumSize: 64,
        albumRadius: 10,
        labelSize: 12,
        titleSize: 20,
        artistSize: 20,
        gap: 16,
        innerGap: 6,
        labelGap: 4,
    },
}

const ANIMATIONS = `
    @keyframes marquee-scroll {
        0%   { transform: translateX(0); }
        100% { transform: translateX(-50%); }
    }
    @keyframes cd-spin {
        from { transform: rotate(0deg); }
        to   { transform: rotate(360deg); }
    }
    @keyframes skeleton-shine {
        0%   { background-position: -200% 0; }
        100% { background-position: 200% 0; }
    }
`

export default function SpotifyNowPlaying({
    apiUrl,
    layout,
    label,
    track,
    albumArt,
    content,
}: Props) {
    const [imgLoaded, setImgLoaded] = useState(false)
    const [data, setData] = useState<NowPlayingData>({
        albumImageUrl: "",
        title: "",
        artist: "",
        songUrl: "",
        loaded: false,
    })

    useEffect(() => {
        fetch(apiUrl)
            .then((res) => res.json())
            .then((json) => {
                const hasTrackData = json.isPlaying && json.title && json.songUrl
                if (hasTrackData) {
                    setData({
                        albumImageUrl: json.albumImageUrl,
                        title: json.title,
                        artist: json.artist,
                        songUrl: json.songUrl,
                        loaded: true,
                    })
                } else {
                    setData({
                        albumImageUrl: "",
                        title: content.silenceText,
                        artist: "",
                        songUrl: "",
                        loaded: true,
                    })
                }
            })
            .catch(() => {
                setData({
                    albumImageUrl: "",
                    title: "Couldn't load now playing",
                    artist: "",
                    songUrl: "",
                    loaded: true,
                })
            })
    }, [apiUrl, content.silenceText])

    useEffect(() => {
        setImgLoaded(false)
    }, [data.albumImageUrl])

    const {
        albumSize,
        albumRadius,
        labelSize,
        titleSize,
        artistSize,
        gap,
        innerGap,
        labelGap,
    } = layoutConfig[layout] ?? layoutConfig.compact

    const trackTitle = data.loaded ? data.title : content.loadingText
    const hasArtist = data.title.trim() !== "" && data.artist.trim() !== ""

    // Speed: roughly 1s per 4 chars, min 6s
    const marqueeDuration = Math.max(
        6,
        (trackTitle.length + (data.artist?.length ?? 0)) * 0.25
    )

    const handleClick = () => {
        if (data.songUrl) window.open(data.songUrl, "_blank")
    }

    const isCD = albumArt.style === "cd"
    const imgBorderRadius = isCD ? "50%" : albumRadius

    // Show album art area only while loading (skeleton) or when a song is playing
    const showAlbumArea =
        albumArt.show && (!data.loaded || !!data.albumImageUrl)

    return (
        <>
            <style>{ANIMATIONS}</style>
            <div
                onClick={handleClick}
                style={{
                    display: "flex",
                    flexDirection: "row",
                    alignItems: "center",
                    gap,
                    cursor: data.songUrl ? "pointer" : "default",
                    width: "100%",
                    height: "100%",
                    overflow: "hidden",
                }}
            >
                {/* Album art */}
                {showAlbumArea && (
                    <div
                        style={{
                            position: "relative",
                            width: albumSize,
                            height: albumSize,
                            flexShrink: 0,
                            borderRadius: imgBorderRadius,
                            overflow: "hidden",
                        }}
                    >
                        {/* Skeleton — only while fetching or image not yet loaded */}
                        {(!data.loaded || !imgLoaded) && (
                            <div
                                style={{
                                    position: "absolute",
                                    inset: 0,
                                    background:
                                        "linear-gradient(90deg, #d0d0d0 25%, #e8e8e8 50%, #d0d0d0 75%)",
                                    backgroundSize: "200% 100%",
                                    animationName: "skeleton-shine",
                                    animationDuration: "1.5s",
                                    animationTimingFunction: "linear",
                                    animationIterationCount: "infinite",
                                }}
                            />
                        )}
                        {/* Image */}
                        {data.albumImageUrl && (
                            <img
                                src={data.albumImageUrl}
                                alt={`${data.title} album art`}
                                onLoad={() => setImgLoaded(true)}
                                style={{
                                    width: albumSize,
                                    height: albumSize,
                                    objectFit: "cover",
                                    display: "block",
                                    opacity: imgLoaded ? 1 : 0,
                                    animationName: isCD ? "cd-spin" : "none",
                                    animationDuration: "5s",
                                    animationTimingFunction: "linear",
                                    animationIterationCount: "infinite",
                                }}
                            />
                        )}
                        {/* CD center hole */}
                        {isCD && imgLoaded && (
                            <div
                                style={{
                                    position: "absolute",
                                    top: "50%",
                                    left: "50%",
                                    transform: "translate(-50%, -50%)",
                                    width: albumSize * 0.2,
                                    height: albumSize * 0.2,
                                    borderRadius: "50%",
                                    background: "white",
                                    boxShadow: "inset 0 0 3px rgba(0,0,0,0.25)",
                                    pointerEvents: "none",
                                }}
                            />
                        )}
                    </div>
                )}

                {/* Text column */}
                <div
                    style={{
                        display: "flex",
                        flexDirection: "column",
                        gap: labelGap,
                        overflow: "hidden",
                        flex: 1,
                        minWidth: 0,
                    }}
                >
                    {/* Now playing label */}
                    <span
                        style={{
                            fontSize: labelSize,
                            color: label.color,
                            opacity: 1,
                            whiteSpace: "nowrap",
                            textTransform: "uppercase",
                            letterSpacing: "0.06em",
                            ...label.font,
                        }}
                    >
                        {label.text}
                    </span>

                    {/* Compact: inline marquee (disabled for silence) */}
                    {layout === "compact" ? (
                        <div style={{ overflow: "hidden", width: "100%" }}>
                            <div
                                style={{
                                    display: "inline-flex",
                                    alignItems: "center",
                                    whiteSpace: "nowrap",
                                    animationName: data.songUrl
                                        ? "marquee-scroll"
                                        : "none",
                                    animationDuration: `${marqueeDuration}s`,
                                    animationTimingFunction: "linear",
                                    animationIterationCount: "infinite",
                                }}
                            >
                                {/* Duplicate for seamless loop */}
                                {(data.songUrl ? [0, 1] : [0]).map((i) => (
                                    <span
                                        key={i}
                                        style={{
                                            display: "inline-flex",
                                            alignItems: "center",
                                            gap: innerGap,
                                            paddingRight: 15,
                                        }}
                                    >
                                        <span
                                            style={{
                                                fontSize: titleSize,
                                                color: track.titleColor,
                                                ...track.font,
                                            }}
                                        >
                                            {trackTitle}
                                        </span>
                                        {hasArtist && (
                                            <>
                                                <span
                                                    style={{
                                                        fontSize: titleSize,
                                                        color: track.titleColor,
                                                        ...track.font,
                                                    }}
                                                >
                                                    {track.separator}
                                                </span>
                                                <span
                                                    style={{
                                                        fontSize: artistSize,
                                                        color: track.artistColor,
                                                        ...track.font,
                                                    }}
                                                >
                                                    {data.artist}
                                                </span>
                                            </>
                                        )}
                                    </span>
                                ))}
                            </div>
                        </div>
                    ) : (
                        /* Spacious: inline, no marquee */
                        <div
                            style={{
                                display: "flex",
                                flexDirection: "row",
                                alignItems: "center",
                                gap: innerGap,
                                overflow: "hidden",
                            }}
                        >
                            <span
                                style={{
                                    fontSize: titleSize,
                                    color: track.titleColor,
                                    whiteSpace: "nowrap",
                                    overflow: "hidden",
                                    textOverflow: "ellipsis",
                                    flexShrink: 0,
                                    ...track.font,
                                }}
                            >
                                {trackTitle}
                            </span>
                            {hasArtist && (
                                <>
                                    <span
                                        style={{
                                            fontSize: titleSize,
                                            color: track.titleColor,
                                            flexShrink: 0,
                                            ...track.font,
                                        }}
                                    >
                                        {track.separator}
                                    </span>
                                    <span
                                        style={{
                                            fontSize: artistSize,
                                            color: track.artistColor,
                                            whiteSpace: "nowrap",
                                            overflow: "hidden",
                                            textOverflow: "ellipsis",
                                            ...track.font,
                                        }}
                                    >
                                        {data.artist}
                                    </span>
                                </>
                            )}
                        </div>
                    )}
                </div>
            </div>
        </>
    )
}

SpotifyNowPlaying.defaultProps = {
    apiUrl: "https://[yourdomain]/api/now-playing",
    layout: "compact",
    label: {
        text: "Now Playing",
        color: "#000000",
        font: {},
    },
    track: {
        titleColor: "#000000",
        artistColor: "#000000",
        separator: "—",
        font: {},
    },
    albumArt: {
        show: true,
        style: "square",
    },
    content: {
        silenceText: "I'm currently enjoying the silence",
        loadingText: "Loading...",
    },
}

addPropertyControls(SpotifyNowPlaying, {
    apiUrl: {
        type: ControlType.String,
        title: "API URL",
    },
    layout: {
        type: ControlType.Enum,
        title: "Layout",
        options: ["compact", "spacious"],
        optionTitles: ["Compact", "Spacious"],
    },
    albumArt: {
        type: ControlType.Object,
        title: "Album Art",
        controls: {
            show: {
                type: ControlType.Boolean,
                title: "Show",
            },
            style: {
                type: ControlType.Enum,
                title: "Style",
                options: ["square", "cd"],
                optionTitles: ["Square", "Spinning CD"],
                hidden: (props: { show: boolean }) => !props.show,
            },
        },
    },
    label: {
        type: ControlType.Object,
        title: "Label",
        controls: {
            text: {
                type: ControlType.String,
                title: "Text",
            },
            color: {
                type: ControlType.Color,
                title: "Color",
            },
            font: {
                type: ControlType.Font,
                title: "Font",
                controls: "extended",
            },
        },
    },
    track: {
        type: ControlType.Object,
        title: "Track",
        controls: {
            titleColor: {
                type: ControlType.Color,
                title: "Title Color",
            },
            artistColor: {
                type: ControlType.Color,
                title: "Artist Color",
            },
            separator: {
                type: ControlType.String,
                title: "Separator",
            },
            font: {
                type: ControlType.Font,
                title: "Font",
                controls: "extended",
            },
        },
    },
    content: {
        type: ControlType.Object,
        title: "Content",
        controls: {
            silenceText: {
                type: ControlType.String,
                title: "Silence Text",
            },
            loadingText: {
                type: ControlType.String,
                title: "Loading Text",
            },
        },
    },
})
import { addPropertyControls, ControlType } from "framer"
import { useEffect, useState } from "react"

interface NowPlayingData {
    albumImageUrl: string
    title: string
    artist: string
    songUrl: string
    loaded: boolean
}

type Layout = "compact" | "spacious"
type AlbumStyle = "square" | "cd"

interface Props {
    apiUrl: string
    layout: Layout
    label: {
        text: string
        color: string
        font: object
    }
    track: {
        titleColor: string
        artistColor: string
        separator: string
        font: object
    }
    albumArt: {
        show: boolean
        style: AlbumStyle
    }
    content: {
        silenceText: string
        loadingText: string
    }
}

const layoutConfig = {
    compact: {
        albumSize: 32,
        albumRadius: 4,
        labelSize: 10,
        titleSize: 12,
        artistSize: 12,
        gap: 8,
        innerGap: 5,
        labelGap: 2,
    },
    spacious: {
        albumSize: 64,
        albumRadius: 10,
        labelSize: 12,
        titleSize: 20,
        artistSize: 20,
        gap: 16,
        innerGap: 6,
        labelGap: 4,
    },
}

const ANIMATIONS = `
    @keyframes marquee-scroll {
        0%   { transform: translateX(0); }
        100% { transform: translateX(-50%); }
    }
    @keyframes cd-spin {
        from { transform: rotate(0deg); }
        to   { transform: rotate(360deg); }
    }
    @keyframes skeleton-shine {
        0%   { background-position: -200% 0; }
        100% { background-position: 200% 0; }
    }
`

export default function SpotifyNowPlaying({
    apiUrl,
    layout,
    label,
    track,
    albumArt,
    content,
}: Props) {
    const [imgLoaded, setImgLoaded] = useState(false)
    const [data, setData] = useState<NowPlayingData>({
        albumImageUrl: "",
        title: "",
        artist: "",
        songUrl: "",
        loaded: false,
    })

    useEffect(() => {
        fetch(apiUrl)
            .then((res) => res.json())
            .then((json) => {
                const hasTrackData = json.isPlaying && json.title && json.songUrl
                if (hasTrackData) {
                    setData({
                        albumImageUrl: json.albumImageUrl,
                        title: json.title,
                        artist: json.artist,
                        songUrl: json.songUrl,
                        loaded: true,
                    })
                } else {
                    setData({
                        albumImageUrl: "",
                        title: content.silenceText,
                        artist: "",
                        songUrl: "",
                        loaded: true,
                    })
                }
            })
            .catch(() => {
                setData({
                    albumImageUrl: "",
                    title: "Couldn't load now playing",
                    artist: "",
                    songUrl: "",
                    loaded: true,
                })
            })
    }, [apiUrl, content.silenceText])

    useEffect(() => {
        setImgLoaded(false)
    }, [data.albumImageUrl])

    const {
        albumSize,
        albumRadius,
        labelSize,
        titleSize,
        artistSize,
        gap,
        innerGap,
        labelGap,
    } = layoutConfig[layout] ?? layoutConfig.compact

    const trackTitle = data.loaded ? data.title : content.loadingText
    const hasArtist = data.title.trim() !== "" && data.artist.trim() !== ""

    // Speed: roughly 1s per 4 chars, min 6s
    const marqueeDuration = Math.max(
        6,
        (trackTitle.length + (data.artist?.length ?? 0)) * 0.25
    )

    const handleClick = () => {
        if (data.songUrl) window.open(data.songUrl, "_blank")
    }

    const isCD = albumArt.style === "cd"
    const imgBorderRadius = isCD ? "50%" : albumRadius

    // Show album art area only while loading (skeleton) or when a song is playing
    const showAlbumArea =
        albumArt.show && (!data.loaded || !!data.albumImageUrl)

    return (
        <>
            <style>{ANIMATIONS}</style>
            <div
                onClick={handleClick}
                style={{
                    display: "flex",
                    flexDirection: "row",
                    alignItems: "center",
                    gap,
                    cursor: data.songUrl ? "pointer" : "default",
                    width: "100%",
                    height: "100%",
                    overflow: "hidden",
                }}
            >
                {/* Album art */}
                {showAlbumArea && (
                    <div
                        style={{
                            position: "relative",
                            width: albumSize,
                            height: albumSize,
                            flexShrink: 0,
                            borderRadius: imgBorderRadius,
                            overflow: "hidden",
                        }}
                    >
                        {/* Skeleton — only while fetching or image not yet loaded */}
                        {(!data.loaded || !imgLoaded) && (
                            <div
                                style={{
                                    position: "absolute",
                                    inset: 0,
                                    background:
                                        "linear-gradient(90deg, #d0d0d0 25%, #e8e8e8 50%, #d0d0d0 75%)",
                                    backgroundSize: "200% 100%",
                                    animationName: "skeleton-shine",
                                    animationDuration: "1.5s",
                                    animationTimingFunction: "linear",
                                    animationIterationCount: "infinite",
                                }}
                            />
                        )}
                        {/* Image */}
                        {data.albumImageUrl && (
                            <img
                                src={data.albumImageUrl}
                                alt={`${data.title} album art`}
                                onLoad={() => setImgLoaded(true)}
                                style={{
                                    width: albumSize,
                                    height: albumSize,
                                    objectFit: "cover",
                                    display: "block",
                                    opacity: imgLoaded ? 1 : 0,
                                    animationName: isCD ? "cd-spin" : "none",
                                    animationDuration: "5s",
                                    animationTimingFunction: "linear",
                                    animationIterationCount: "infinite",
                                }}
                            />
                        )}
                        {/* CD center hole */}
                        {isCD && imgLoaded && (
                            <div
                                style={{
                                    position: "absolute",
                                    top: "50%",
                                    left: "50%",
                                    transform: "translate(-50%, -50%)",
                                    width: albumSize * 0.2,
                                    height: albumSize * 0.2,
                                    borderRadius: "50%",
                                    background: "white",
                                    boxShadow: "inset 0 0 3px rgba(0,0,0,0.25)",
                                    pointerEvents: "none",
                                }}
                            />
                        )}
                    </div>
                )}

                {/* Text column */}
                <div
                    style={{
                        display: "flex",
                        flexDirection: "column",
                        gap: labelGap,
                        overflow: "hidden",
                        flex: 1,
                        minWidth: 0,
                    }}
                >
                    {/* Now playing label */}
                    <span
                        style={{
                            fontSize: labelSize,
                            color: label.color,
                            opacity: 1,
                            whiteSpace: "nowrap",
                            textTransform: "uppercase",
                            letterSpacing: "0.06em",
                            ...label.font,
                        }}
                    >
                        {label.text}
                    </span>

                    {/* Compact: inline marquee (disabled for silence) */}
                    {layout === "compact" ? (
                        <div style={{ overflow: "hidden", width: "100%" }}>
                            <div
                                style={{
                                    display: "inline-flex",
                                    alignItems: "center",
                                    whiteSpace: "nowrap",
                                    animationName: data.songUrl
                                        ? "marquee-scroll"
                                        : "none",
                                    animationDuration: `${marqueeDuration}s`,
                                    animationTimingFunction: "linear",
                                    animationIterationCount: "infinite",
                                }}
                            >
                                {/* Duplicate for seamless loop */}
                                {(data.songUrl ? [0, 1] : [0]).map((i) => (
                                    <span
                                        key={i}
                                        style={{
                                            display: "inline-flex",
                                            alignItems: "center",
                                            gap: innerGap,
                                            paddingRight: 15,
                                        }}
                                    >
                                        <span
                                            style={{
                                                fontSize: titleSize,
                                                color: track.titleColor,
                                                ...track.font,
                                            }}
                                        >
                                            {trackTitle}
                                        </span>
                                        {hasArtist && (
                                            <>
                                                <span
                                                    style={{
                                                        fontSize: titleSize,
                                                        color: track.titleColor,
                                                        ...track.font,
                                                    }}
                                                >
                                                    {track.separator}
                                                </span>
                                                <span
                                                    style={{
                                                        fontSize: artistSize,
                                                        color: track.artistColor,
                                                        ...track.font,
                                                    }}
                                                >
                                                    {data.artist}
                                                </span>
                                            </>
                                        )}
                                    </span>
                                ))}
                            </div>
                        </div>
                    ) : (
                        /* Spacious: inline, no marquee */
                        <div
                            style={{
                                display: "flex",
                                flexDirection: "row",
                                alignItems: "center",
                                gap: innerGap,
                                overflow: "hidden",
                            }}
                        >
                            <span
                                style={{
                                    fontSize: titleSize,
                                    color: track.titleColor,
                                    whiteSpace: "nowrap",
                                    overflow: "hidden",
                                    textOverflow: "ellipsis",
                                    flexShrink: 0,
                                    ...track.font,
                                }}
                            >
                                {trackTitle}
                            </span>
                            {hasArtist && (
                                <>
                                    <span
                                        style={{
                                            fontSize: titleSize,
                                            color: track.titleColor,
                                            flexShrink: 0,
                                            ...track.font,
                                        }}
                                    >
                                        {track.separator}
                                    </span>
                                    <span
                                        style={{
                                            fontSize: artistSize,
                                            color: track.artistColor,
                                            whiteSpace: "nowrap",
                                            overflow: "hidden",
                                            textOverflow: "ellipsis",
                                            ...track.font,
                                        }}
                                    >
                                        {data.artist}
                                    </span>
                                </>
                            )}
                        </div>
                    )}
                </div>
            </div>
        </>
    )
}

SpotifyNowPlaying.defaultProps = {
    apiUrl: "https://[yourdomain]/api/now-playing",
    layout: "compact",
    label: {
        text: "Now Playing",
        color: "#000000",
        font: {},
    },
    track: {
        titleColor: "#000000",
        artistColor: "#000000",
        separator: "—",
        font: {},
    },
    albumArt: {
        show: true,
        style: "square",
    },
    content: {
        silenceText: "I'm currently enjoying the silence",
        loadingText: "Loading...",
    },
}

addPropertyControls(SpotifyNowPlaying, {
    apiUrl: {
        type: ControlType.String,
        title: "API URL",
    },
    layout: {
        type: ControlType.Enum,
        title: "Layout",
        options: ["compact", "spacious"],
        optionTitles: ["Compact", "Spacious"],
    },
    albumArt: {
        type: ControlType.Object,
        title: "Album Art",
        controls: {
            show: {
                type: ControlType.Boolean,
                title: "Show",
            },
            style: {
                type: ControlType.Enum,
                title: "Style",
                options: ["square", "cd"],
                optionTitles: ["Square", "Spinning CD"],
                hidden: (props: { show: boolean }) => !props.show,
            },
        },
    },
    label: {
        type: ControlType.Object,
        title: "Label",
        controls: {
            text: {
                type: ControlType.String,
                title: "Text",
            },
            color: {
                type: ControlType.Color,
                title: "Color",
            },
            font: {
                type: ControlType.Font,
                title: "Font",
                controls: "extended",
            },
        },
    },
    track: {
        type: ControlType.Object,
        title: "Track",
        controls: {
            titleColor: {
                type: ControlType.Color,
                title: "Title Color",
            },
            artistColor: {
                type: ControlType.Color,
                title: "Artist Color",
            },
            separator: {
                type: ControlType.String,
                title: "Separator",
            },
            font: {
                type: ControlType.Font,
                title: "Font",
                controls: "extended",
            },
        },
    },
    content: {
        type: ControlType.Object,
        title: "Content",
        controls: {
            silenceText: {
                type: ControlType.String,
                title: "Silence Text",
            },
            loadingText: {
                type: ControlType.String,
                title: "Loading Text",
            },
        },
    },
})

The serverless endpoint on Next JS.

// nowPlaying.ts
import fetch from "node-fetch";
import { getAccessToken } from "./spotify";

const NOW_PLAYING_URL = `https://api.spotify.com/v1/me/player/currently-playing`;

const getNowPlaying = async (): Promise<any> => {
  const accessTokenData = await getAccessToken();
  const accessToken = accessTokenData.access_token;

  if (!accessToken) {
    throw new Error("Failed to get access token");
  }

  const response = await fetch(NOW_PLAYING_URL, {
    headers: {
      Authorization: `Bearer ${accessToken}`,
    },
  });

  if (!response.ok) {
    throw new Error("Failed to fetch now playing");
  }

  return response.json();
};

export { getNowPlaying };


// spotify.ts
import axios from "axios";

const CLIENT_ID = process.env.SPOTIFY_CLIENT_ID as string;
const CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET as string;
const REDIRECT_URI = process.env.SPOTIFY_REDIRECT_URI as string;
const SPOTIFY_TOKEN_ENDPOINT = "https://accounts.spotify.com/api/token";

const getSpotifyAuthUrl = (): string => {
  return `https://accounts.spotify.com/authorize?client_id=${CLIENT_ID}&response_type=code&redirect_uri=${REDIRECT_URI}&scope=user-read-currently-playing`;
};

const REFRESH_TOKEN = process.env.SPOTIFY_REFRESH_TOKEN as string;

const getAccessToken = async (): Promise<any> => {
  const body = new URLSearchParams();
  body.append("grant_type", "refresh_token");
  body.append("refresh_token", REFRESH_TOKEN);

  try {
    const response = await axios.post(SPOTIFY_TOKEN_ENDPOINT, body, {
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        Authorization: `Basic ${Buffer.from(
          `${CLIENT_ID}:${CLIENT_SECRET}`
        ).toString("base64")}`,
      },
    });

    return response.data;
  } catch (error) {
    console.error("Error getting access token:", error);
    throw error;
  }
};

export { getSpotifyAuthUrl, getAccessToken };
// nowPlaying.ts
import fetch from "node-fetch";
import { getAccessToken } from "./spotify";

const NOW_PLAYING_URL = `https://api.spotify.com/v1/me/player/currently-playing`;

const getNowPlaying = async (): Promise<any> => {
  const accessTokenData = await getAccessToken();
  const accessToken = accessTokenData.access_token;

  if (!accessToken) {
    throw new Error("Failed to get access token");
  }

  const response = await fetch(NOW_PLAYING_URL, {
    headers: {
      Authorization: `Bearer ${accessToken}`,
    },
  });

  if (!response.ok) {
    throw new Error("Failed to fetch now playing");
  }

  return response.json();
};

export { getNowPlaying };


// spotify.ts
import axios from "axios";

const CLIENT_ID = process.env.SPOTIFY_CLIENT_ID as string;
const CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET as string;
const REDIRECT_URI = process.env.SPOTIFY_REDIRECT_URI as string;
const SPOTIFY_TOKEN_ENDPOINT = "https://accounts.spotify.com/api/token";

const getSpotifyAuthUrl = (): string => {
  return `https://accounts.spotify.com/authorize?client_id=${CLIENT_ID}&response_type=code&redirect_uri=${REDIRECT_URI}&scope=user-read-currently-playing`;
};

const REFRESH_TOKEN = process.env.SPOTIFY_REFRESH_TOKEN as string;

const getAccessToken = async (): Promise<any> => {
  const body = new URLSearchParams();
  body.append("grant_type", "refresh_token");
  body.append("refresh_token", REFRESH_TOKEN);

  try {
    const response = await axios.post(SPOTIFY_TOKEN_ENDPOINT, body, {
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        Authorization: `Basic ${Buffer.from(
          `${CLIENT_ID}:${CLIENT_SECRET}`
        ).toString("base64")}`,
      },
    });

    return response.data;
  } catch (error) {
    console.error("Error getting access token:", error);
    throw error;
  }
};

export { getSpotifyAuthUrl, getAccessToken };
// nowPlaying.ts
import fetch from "node-fetch";
import { getAccessToken } from "./spotify";

const NOW_PLAYING_URL = `https://api.spotify.com/v1/me/player/currently-playing`;

const getNowPlaying = async (): Promise<any> => {
  const accessTokenData = await getAccessToken();
  const accessToken = accessTokenData.access_token;

  if (!accessToken) {
    throw new Error("Failed to get access token");
  }

  const response = await fetch(NOW_PLAYING_URL, {
    headers: {
      Authorization: `Bearer ${accessToken}`,
    },
  });

  if (!response.ok) {
    throw new Error("Failed to fetch now playing");
  }

  return response.json();
};

export { getNowPlaying };


// spotify.ts
import axios from "axios";

const CLIENT_ID = process.env.SPOTIFY_CLIENT_ID as string;
const CLIENT_SECRET = process.env.SPOTIFY_CLIENT_SECRET as string;
const REDIRECT_URI = process.env.SPOTIFY_REDIRECT_URI as string;
const SPOTIFY_TOKEN_ENDPOINT = "https://accounts.spotify.com/api/token";

const getSpotifyAuthUrl = (): string => {
  return `https://accounts.spotify.com/authorize?client_id=${CLIENT_ID}&response_type=code&redirect_uri=${REDIRECT_URI}&scope=user-read-currently-playing`;
};

const REFRESH_TOKEN = process.env.SPOTIFY_REFRESH_TOKEN as string;

const getAccessToken = async (): Promise<any> => {
  const body = new URLSearchParams();
  body.append("grant_type", "refresh_token");
  body.append("refresh_token", REFRESH_TOKEN);

  try {
    const response = await axios.post(SPOTIFY_TOKEN_ENDPOINT, body, {
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
        Authorization: `Basic ${Buffer.from(
          `${CLIENT_ID}:${CLIENT_SECRET}`
        ).toString("base64")}`,
      },
    });

    return response.data;
  } catch (error) {
    console.error("Error getting access token:", error);
    throw error;
  }
};

export { getSpotifyAuthUrl, getAccessToken };

I’m excited to explore more ways to make my personal website even more personal by connecting it to APIs so it feels dynamic. Next, I want to do the same for other products I use daily, like Claude Code stats.

What would you bring into your personal website?

Now Playing
Loading...

© 2026.

Now Playing
Loading...

© 2026.

Get in touch