Animated “now playing” cards for your GitHub profile - rendered as SVG, served from the edge.
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
fetchchain 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.
Detailed - / - 420 × 142
Blurred album backdrop, artwork, status line, title, artist and album.
Compact - /small - 340 × 76
Widget-style strip: artwork, status, title, artist and logo.
Portrait - /portrait - 300 × 420
Album art on top, frosted glass below, coloured by the record itself.
Set with ?theme=. Every one works on both layouts.
dark default |
light |
spotify |
dracula |
nord |
catppuccin |
tokyonight |
gruvbox |
rosepine |
synthwave |
transparent adapts to the page |
custom hex overrides |
The portrait card accepts the same presets, and mode controls the glass shell
independently of them.
/portrait default |
?mode=light |
?tint=95 more album colour |
?tint=10 muted |
?glass=false solid panels |
?theme=synthwave |
/?width=600&theme=tokyonight
/?theme=dracula&blur=false&radius=4
/small?theme=catppuccin&width=460&radius=24
/portrait?width=380&radius=0&tint=85
You need: a Spotify account, a Vercel account, and Node.js 20+. The whole thing takes about five minutes and costs nothing.
-
Open the Spotify Developer Dashboard → Create app.
-
Give it any name, for example
readme-card. -
Under Redirect URIs add exactly this, then press Add:
http://127.0.0.1:5175/callbackSpotify rejects
localhost. It has to be the loopback IP. -
Tick Web API, save, then open Settings and copy the Client ID and Client secret.
git clone https://github.com/GautamVhavle/spotify-readme-card.git
cd spotify-readme-card
npm install
cp .env.example .envPut 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=npm run authorizeThe 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:3000Option 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)
-
Push your clone to your own GitHub repository.
-
Go to vercel.com/new and import it.
-
Leave the framework preset as Other - there is no build step.
-
Add three environment variables:
Name Value SPOTIFY_CLIENT_IDfrom step 1 SPOTIFY_CLIENT_SECRETfrom step 1 SPOTIFY_REFRESH_TOKENfrom step 3 -
Deploy. Every later push to
mainredeploys 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<img src="https://YOUR-APP.vercel.app/" alt="What I'm listening to" width="420" />Make it clickable by wrapping it in a link:
[](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.
| 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.
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.
<!-- 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" />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
camoproxy, 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.
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: 0Opening 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://camo.githubusercontent.com/<hash>/<hash>" | grep -Ei 'age|x-cache'
age: 849
x-cache: HITThe 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-nameThe 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.
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.
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| 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.
| 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.
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.
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 testMIT - do whatever you like, no attribution required.
Built by Gautam Vhavle · Live demo · Issues · Discussions
If this made your profile a little cooler, a star goes a long way.