Skip to content

In a README

A card is a plain SVG file. Commit it and reference it like any other image:

![My GitHub stats](./cards/stats.svg)

That is the whole of it for one card, whatever drew the file: the action, which is the recommended way to keep it drawn, or the CLI. The rest of this page is what people usually want next.

A directory of saved cards beside a directory of rendered ones keeps the two straight:

.github/
cards/
stats.json the options, saved by the CLI
stats.svg what it rendered

The JSON is the card you can edit and re-render; the SVG is what the README points at. Both belong in the repository — a browser fetches the SVG, not the CLI. The action keeps its options in the workflow instead, so it writes only the SVG.

GitHub serves READMEs in whichever theme is set, and a card drawn for one looks wrong in the other. Render it twice, then let the browser pick:

Terminal window
npx @stats-forge/github-stats-forge-cli --config .github/cards/stats-light.json \
--generate --out .github/cards/stats-light.svg
npx @stats-forge/github-stats-forge-cli --config .github/cards/stats-dark.json \
--generate --out .github/cards/stats-dark.svg

The two saved cards differ in one option — "theme": "default" against "theme": "dark" — and the README picks between them with <picture>:

<picture>
<source media="(prefers-color-scheme: dark)" srcset="./.github/cards/stats-dark.svg" />
<img alt="My GitHub stats" src="./.github/cards/stats-light.svg" />
</picture>

Only the <img> needs the alt; it is the one a screen reader announces, and the one shown anywhere <picture> is not understood.

This site does the same thing with every card on it, which is why each example changes when you switch the theme in the header.

That is the recommended way, and there are four others — theme_light / theme_dark in a single file, GitHub’s #gh-dark-mode-only tag, the transparent theme, and an alpha channel on any theme. Light and dark mode weighs them up.

Markdown puts consecutive images on one line if nothing separates them:

![Stats](./cards/stats.svg)![Top languages](./cards/top-langs.svg)

That is enough for two cards of the same height. For anything else — differing sizes, links, centring, a light and dark pair per card — see Aligning cards, which is the whole subject.

Wrap it, the way any image is linked — a pinned repository card pointing at that repository is the usual case:

[![rollup-plugin-sass](./cards/pin.svg)](https://github.com/marcalexiei/rollup-plugin-sass)

A committed SVG holds the numbers from the day it was rendered, so redraw it on a schedule if that matters. The GitHub Stats Forge action is the recommended way to do it, one step per card, and its own README has the inputs. This is the same thing with the CLI, which is what the contributed to card still needs:

name: Refresh cards
on:
schedule:
- cron: '0 6 * * 1' # Mondays, 06:00 UTC
workflow_dispatch:
permissions:
contents: write
jobs:
cards:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 24
- name: Render
env:
PAT_1: ${{ secrets.CARDS_TOKEN }}
run: |
npx @stats-forge/github-stats-forge-cli \
--config .github/cards/stats.json --generate --out .github/cards/stats.svg
- name: Commit if anything changed
run: |
git config user.name 'github-actions[bot]'
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
git add .github/cards
git diff --quiet --cached || git commit -m 'chore: refresh cards'
git push

CARDS_TOKEN is a personal access token with no scopes — the cards read public data only. The workflow’s own GITHUB_TOKEN cannot be used for the rendering: it is scoped to this repository, and a stats card asks about an account.

Pick an interval that matches how fast the numbers move. Daily is more commits than most profiles justify, and every run spends rate limit.

  • It renders here but not there. GitHub serves images through a proxy that caches them; a card you have just replaced can be the old one for a while. A hard refresh does not clear it — wait, or rename the file.
  • The animation does not play. Some contexts strip it. Render with disable_animations=true so the card is drawn in its final state rather than its first frame.
  • The text is cut off. The renderer wraps and truncates to a fixed width. Raise card_width, or shorten what goes in it — a custom_title, fewer show entries.