Skip to content

About

Animated Spotify now-playing SVG cards for your GitHub README

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

4 stars

Watchers

0 watching

Forks

Repository files navigation

Spotify now playing card

Spotify README Card

Animated “now playing” cards for your GitHub profile - rendered as SVG, served from the edge.

CI Smoke test License Node Runtime dependencies

Website · Live demo · Gallery · Setup · API · Contributing

Deploy with Vercel


Overview

A small serverless service that asks the Spotify Web API what you are listening to and answers with an SVG image. Because the response is an image, it works anywhere a URL does - GitHub profile READMEs, Gists, blogs, docs sites.

  • Live status - shows the current track while you are playing, and your last played track with a relative timestamp when you are not.
  • Actually animated - CSS keyframes inside the SVG, so equalizer bars pulse and long titles scroll.
  • Survives GitHub's proxy - album art is inlined as base64, so nothing is blocked by camo.
  • Three layouts - a detailed card, a compact widget, and a glass portrait that takes its colour from the album art.
  • Eleven themes - plus six per-token colour overrides for anything custom.
  • Zero runtime dependencies - one fetch chain and a string of SVG, so cold starts stay in the low hundreds of milliseconds.
  • Tested and typed - strict TypeScript, 24 renderer tests, CI on every push.

Gallery

Layouts

Detailed card

Detailed - / - 420 × 142
Blurred album backdrop, artwork, status line, title, artist and album.



Compact card

Compact - /small - 340 × 76
Widget-style strip: artwork, status, title, artist and logo.



Portrait card

Portrait - /portrait - 300 × 420
Album art on top, frosted glass below, coloured by the record itself.

Themes

Set with ?theme=. Every one works on both layouts.

dark theme
dark default
light theme
light
spotify theme
spotify
dracula theme
dracula
nord theme
nord
catppuccin theme
catppuccin
tokyonight theme
tokyonight
gruvbox theme
gruvbox
rosepine theme
rosepine
synthwave theme
synthwave
transparent theme
transparent adapts to the page
custom palette
custom hex overrides

The portrait card accepts the same presets, and mode controls the glass shell independently of them.

portrait dark
/portrait default
portrait light
?mode=light
portrait full album colour
?tint=95 more album colour
portrait muted
?tint=10 muted
portrait without glass
?glass=false solid panels
portrait synthwave
?theme=synthwave

Variations

Wide card

/?width=600&theme=tokyonight



Flat card without backdrop

/?theme=dracula&blur=false&radius=4



Wide compact card

/small?theme=catppuccin&width=460&radius=24



Large square-cornered portrait

/portrait?width=380&radius=0&tint=85


Setup

You need: a Spotify account, a Vercel account, and Node.js 20+. The whole thing takes about five minutes and costs nothing.

Step 1 - Create a Spotify app

  1. Open the Spotify Developer Dashboard → Create app.

  2. Give it any name, for example readme-card.

  3. Under Redirect URIs add exactly this, then press Add:

    http://127.0.0.1:5175/callback
    

    Spotify rejects localhost. It has to be the loopback IP.

  4. Tick Web API, save, then open Settings and copy the Client ID and Client secret.

Step 2 - Get the code

git clone https://github.com/GautamVhavle/spotify-readme-card.git
cd spotify-readme-card
npm install
cp .env.example .env

Put the two values from step 1 into .env:

SPOTIFY_CLIENT_ID=your_client_id_here
SPOTIFY_CLIENT_SECRET=your_client_secret_here
SPOTIFY_REFRESH_TOKEN=

Step 3 - Generate a refresh token

npm run authorize

The script prints an authorization URL. Open it, approve the request, and your refresh token is written back into .env automatically. Refresh tokens do not expire, so this is a one-time step.

Check it locally:

npm i -g vercel     # if you do not have it
npm run dev         # http://localhost:3000

Step 4 - Deploy

Option A - one click (fastest)

Press the Deploy with Vercel button at the top. Vercel clones the repo into your account and prompts for the three environment variables during setup.

Option B - Vercel dashboard (recommended, gives you auto-deploy)
  1. Push your clone to your own GitHub repository.

  2. Go to vercel.com/new and import it.

  3. Leave the framework preset as Other - there is no build step.

  4. Add three environment variables:

    Name Value
    SPOTIFY_CLIENT_ID from step 1
    SPOTIFY_CLIENT_SECRET from step 1
    SPOTIFY_REFRESH_TOKEN from step 3
  5. Deploy. Every later push to main redeploys automatically.

Option C - Vercel CLI
npm i -g vercel
vercel link

for key in SPOTIFY_CLIENT_ID SPOTIFY_CLIENT_SECRET SPOTIFY_REFRESH_TOKEN; do
  grep "^$key=" .env | cut -d= -f2- | tr -d '\n' | vercel env add "$key" production
done

vercel --prod

Step 5 - Put it in your README

<img src="https://YOUR-APP.vercel.app/" alt="What I'm listening to" width="420" />

Make it clickable by wrapping it in a link:

[![Spotify](https://YOUR-APP.vercel.app/)](https://open.spotify.com/user/YOUR_USER_ID)

GitHub strips <a> elements from inside an SVG, so the link has to sit on the outside.


API reference

Endpoints

Method Path Response Description
GET / image/svg+xml Detailed card, 420 × 142
GET /small image/svg+xml Compact card, 340 × 76
GET /portrait image/svg+xml Portrait card, 300 × 420
GET /json application/json Raw track data - useful when debugging a setup

Anything other than GET returns 405. Errors always return a rendered card rather than a broken image, so your README never shows a torn thumbnail.

Query parameters

Every card endpoint accepts the same options. /portrait keeps a fixed 300 × 420 aspect ratio, so width scales the whole card rather than stretching it.

Parameter Values Default Description
theme see the gallery dark Colour preset
mode dark / light dark Glass shell on /portrait
width 320–760 (260–560 small, 240–420 portrait) 420 / 340 / 300 Card width in pixels
radius 0–40 16 / 14 / 22 Corner radius
bars true / false true Animated equalizer while playing
blur true / false true on /, false on /small Blurred album-art backdrop
glass true / false true Frosted glass on /portrait
tint 0–100 55 How much album colour bleeds in
show_border true / false true (false on /portrait) Outer border
bg hex theme Card background
surface hex theme Veil and artwork placeholder
text hex theme Track title
sub hex theme Artist and album
accent hex theme Logo, status line and equalizer
border hex theme Border and artwork ring

Colours take 3, 4, 6 or 8 hex digits without the leading # (it is a URL fragment character), plus the keyword transparent. Anything that does not match is ignored and the theme value is used instead. Booleans accept true/false, 1/0, yes/no, on/off.

Examples

<!-- Compact widget in Dracula -->
<img src="https://YOUR-APP.vercel.app/small?theme=dracula" width="340" />

<!-- Wide, flat, no border -->
<img src="https://YOUR-APP.vercel.app/?width=600&radius=0&show_border=false" width="600" />

<!-- Transparent so it works in both GitHub colour modes -->
<img src="https://YOUR-APP.vercel.app/?theme=transparent&text=888888" width="420" />

<!-- Fully custom palette -->
<img src="https://YOUR-APP.vercel.app/?bg=0f0f0f&accent=ff2d55&text=fafafa&sub=737373" width="420" />

How it works

GitHub README
     │
     ▼
camo image proxy
     │
     ▼
Vercel function  ──▶  refresh token  ──▶  access token   (cached while warm)
     │
     ├──▶  GET /me/player/currently-playing
     ├──▶  GET /me/player/recently-played        (fallback)
     ├──▶  album art  ──▶  base64 data URI       (allow-listed hosts)
     │
     ▼
  SVG string

Four decisions worth knowing about:

  • Album art has to be inlined. GitHub serves README images through its camo proxy, which refuses to load remote resources referenced from inside an SVG. The function downloads the cover and embeds it as a data URI.
  • The animation is CSS, not a GIF. CSS keyframes inside an SVG do run when the SVG is loaded through an <img> tag. That keeps the card vector-sharp at any size and far smaller on the wire than an animated raster image.
  • Text is measured by approximation. There is no DOM in a serverless function, so character widths are estimated from a ratio table to decide when to truncate a line or scroll it.
  • Tailwind cannot run here. A standalone SVG has no build pipeline and no class engine, so the styles are hand-authored CSS inside the document. The palettes still follow the same six-token structure (bg / surface / text / subtext / accent / border), so adding a theme feels like editing a design system.

Caching and freshness

The function renders fresh SVG on every request and asks every layer in front of it not to keep a copy:

Cache-Control:      no-cache, no-store, must-revalidate, max-age=0, s-maxage=0
CDN-Cache-Control:  no-cache, no-store, must-revalidate, max-age=0, s-maxage=0
Surrogate-Control:  max-age=0, no-store
Pragma:             no-cache
Expires:            0

Opening the URL directly is therefore always live. Inside a README it is not, and no combination of headers changes that: GitHub routes README images through its camo proxy, which holds its own cached copy for several minutes and ignores the origin's cache directives. You can watch it happen - age keeps climbing while the origin says no-store:

$ curl -sSI "https://assets-git-camo.f3mw1.com/<hash>/<hash>" | grep -Ei 'age|x-cache'
age: 849
x-cache: HIT

The only reliable way to move it along is to ask the proxy to drop the image, which it will do on request:

node scripts/purge-camo.mjs https://github.com/your-name/your-name

The script reads the rendered page, finds every proxied image on it, and sends each one a PURGE. (The proxy URLs are signed by GitHub, so they can only be discovered this way - they cannot be computed locally.)

refresh-card.yml runs that script on a ten minute schedule. To keep your own profile card fresh, copy the workflow into your profile repository and point it at your profile page:

- run: node scripts/purge-camo.mjs "https://github.com/${{ github.repository_owner }}/${{ github.repository_owner }}"

Scheduled workflows are best-effort - GitHub delays them under load - so treat ten minutes as a target, not a guarantee.


Project structure

api/
├── card.ts               GET /        detailed card
├── small.ts              GET /small   compact card
├── portrait.ts           GET /portrait  portrait card
├── json.ts               GET /json    raw track data
└── _lib/
    ├── spotify.ts        token refresh, now-playing, recently-played
    ├── artwork.ts        album art → base64, host allow-list, bounded cache
    ├── options.ts        query-string parsing and validation
    ├── themes.ts         colour presets
    ├── svg.ts            text measurement, marquee, equalizer, icons
    ├── render.ts         card layouts
    ├── cache.ts          no-cache response headers
    └── handler.ts        shared request handling
scripts/
├── authorize.mjs         one-time OAuth helper
└── purge-camo.mjs        drop GitHub's cached copy of the card
tests/
└── render.test.mjs       renderer, escaping and option tests

Files under api/_lib/ begin with an underscore, so Vercel treats them as shared modules instead of routes.


Development

npm install
npm run dev          # vercel dev on http://localhost:3000
npm run typecheck    # tsc --noEmit
npm test             # compile, then run the renderer tests
npm run format       # prettier

Continuous integration

Workflow Trigger Does
ci.yml every push and pull request Format check, typecheck, renderer tests
smoke.yml after a production deployment Verifies the live endpoints return real cards

Deployment itself is handled by Vercel's native Git integration: pushing to main builds and promotes to production, and pull requests get their own preview URL.


Troubleshooting

Symptom Cause and fix
Card reads Spotify unavailable One of the three environment variables is missing or wrong on the deployment. Check the function logs in Vercel.
Card reads Nothing playing No playback history on the account, or the token was issued without the required scopes. Re-run npm run authorize.
INVALID_CLIENT: Invalid redirect URI The Spotify dashboard must contain exactly http://127.0.0.1:5175/callback. Spotify rejects localhost.
Card never updates GitHub's image proxy is holding a cached copy. Open the URL directly to confirm the service is live, then run node scripts/purge-camo.mjs <your page>. See Caching and freshness.
Card is blank in a Markdown preview Some editors block SVG animation. Test in a browser first.
Everything broke after a password change Changing your Spotify password revokes refresh tokens. Re-run npm run authorize and update the deployment variable.
Artwork missing, everything else fine The album has no cover on Spotify's CDN. The card falls back to a placeholder by design.

Still stuck? Open a discussion.


Security

Every deployment is single-tenant and serves exactly one Spotify account. Credentials stay server-side, colour parameters are validated against a strict hex pattern, all API text is XML-escaped, artwork is only fetched from allow-listed Spotify CDN hosts, and errors never leak upstream messages into the card.

Read the full threat model in SECURITY.md. Report vulnerabilities privately through GitHub Security Advisories

  • please do not open a public issue.

Contributing

Pull requests are welcome, and new themes are the easiest place to start. See CONTRIBUTING.md for setup, the theme checklist and code style, and CODE_OF_CONDUCT.md for how we treat each other.

npm run format && npm run typecheck && npm test

License

MIT - do whatever you like, no attribution required.


Now playing



Built by Gautam Vhavle · Live demo · Issues · Discussions


If this made your profile a little cooler, a star goes a long way.

About

Animated Spotify now-playing SVG cards for your GitHub README

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages