github-contribution-svg
A GitHub contribution calendar as a crisp inline SVG for React. Zero dependencies, themeable with plain CSS custom properties, SSR-friendly, and happy to render data you fetched yourself.
Live on adnanreza.com in both light and dark themes.
| Light | Dark |
|---|---|
![]() |
![]() |
Why another one?
react-github-calendar
is excellent and more featureful — use it if you want labels, legends, and
options. This package is the minimalist take: one component, no dependencies,
no shipped stylesheet, cells styled by CSS variables so the chart inherits
your design tokens (including dark mode) with no theme objects or filters.
Install
npm install github-contribution-svg
React ≥ 18 is a peer dependency.
Quick start
import { ContributionGraph } from "github-contribution-svg";
// Convenience mode: fetches the last year client-side (no token needed)
<ContributionGraph username="octocat" />
The SVG scales to its container (it only sets a viewBox), so size it with
CSS. Until data arrives it renders null — reserve space with aspect-ratio
if you want zero layout shift: 52 weeks at default geometry is 673 / 88.
Bring your own data (SSR / one fetch for chart + total)
import { ContributionGraph, fetchContributions } from "github-contribution-svg";
const { contributions, totalLastYear } = await fetchContributions("octocat");
<figure>
<ContributionGraph data={contributions} />
<figcaption>{totalLastYear} contributions in the last year</figcaption>
</figure>
data mode makes no network request, so it works in server components,
static builds, or with data from your own proxy.
Prefer the convenience of username mode but still want the total for a
caption? Use onData (and onError to degrade gracefully):
const [total, setTotal] = useState<number | null>(null);
<ContributionGraph
username="octocat"
skeleton
onData={({ totalLastYear }) => setTotal(totalLastYear)}
onError={() => setTotal(null)}
/>
{total != null && <p>{total} contributions in the last year</p>}
Theming
Cells fill from var(--gcs-cell-{level}, <github-green default>) and carry
the classes gcs-cell gcs-l0 … gcs-l4. Define five custom properties and the
chart is yours:
.my-chart {
--gcs-cell-0: oklch(90% 0.008 240); /* no contributions */
--gcs-cell-1: oklch(80% 0.055 245);
--gcs-cell-2: oklch(68% 0.095 245);
--gcs-cell-3: oklch(55% 0.125 245);
--gcs-cell-4: oklch(41% 0.13 245); /* most contributions */
}
[data-theme="dark"] .my-chart {
--gcs-cell-0: oklch(26% 0 0);
--gcs-cell-4: oklch(79% 0.1 245); /* brightest = most active */
/* ... */
}
No JavaScript theme objects, no filter: invert() hacks — dark mode is just
different variables.
Responsive recipe
A full year squeezed into a phone screen means ~4px cells. Render two instances and let a media query pick one:
<ContributionGraph data={days} weeks={52} className="chart chart--full" />
<ContributionGraph data={days} weeks={26} className="chart chart--half" />
.chart--half { display: none; }
@media (max-width: 640px) {
.chart--full { display: none; }
.chart--half { display: block; }
}
Use data mode for both instances (as above) so the community API is hit
once, not twice. If you started in username mode, capture the payload from
one instance with onData and feed the second via data.
Loading skeleton
Pass skeleton and the component renders a ghost grid (same geometry, all
cells at level 0, aria-hidden) while there's nothing to draw — during the
username-mode fetch, or in data mode while your own request is in flight.
It appears in server-rendered HTML, so SSR users get it at first paint. If
the username-mode fetch fails, the skeleton is removed rather than pulsing
forever.
<ContributionGraph username="octocat" skeleton />
It's static by default (no CSS ships with this package). One optional rule adds a pulse:
@media (prefers-reduced-motion: no-preference) {
.gcs-skeleton { animation: gcs-pulse 1.8s ease-in-out infinite; }
}
@keyframes gcs-pulse { 50% { opacity: 0.55; } }
CSS-only variant (shows before your JS bundle loads)
For a skeleton visible even before hydration, paint a CSS-only ghost grid on the container and drop it when loaded:
.chart-frame.is-loading {
background-color: var(--gcs-cell-0, #ebedf0);
/* one tile = a cell (10/13 of the pitch) + a gap stripe; 52 columns */
background-image:
repeating-linear-gradient(to right,
transparent 0 calc(100% / 52 * 10 / 13),
var(--page-bg, #fff) calc(100% / 52 * 10 / 13) calc(100% / 52)),
repeating-linear-gradient(to bottom,
transparent 0 calc(100% / 7 * 10 / 13),
var(--page-bg, #fff) calc(100% / 7 * 10 / 13) calc(100% / 7));
}
@media (prefers-reduced-motion: no-preference) {
.chart-frame.is-loading { animation: pulse 1.8s ease-in-out infinite; }
}
@keyframes pulse { 50% { opacity: 0.55; } }
Toggle is-loading off when your data lands (and on fetch failure, so it
doesn't shimmer forever). Server-render the class for the first-paint
benefit.
Props
| Prop | Default | Description |
|---|---|---|
data |
— | ContributionDay[] ({date, count, level}), date-ascending. Disables fetching. |
username |
— | GitHub username; fetches the last year client-side. |
url |
jogruber.de API | Endpoint template for username mode; {username} is substituted. |
skeleton |
false |
Ghost grid while no data is available; removed on fetch failure. |
onData |
— | Username mode: (payload) => void after a successful fetch ({contributions, totalLastYear}). |
onError |
— | Username mode: (error) => void when the fetch fails. |
weeks |
52 |
Trailing weeks to render. |
cellSize / gap / radius |
10 / 3 / 2 |
Geometry in SVG units. |
tooltips |
true |
Native per-cell <title> tooltips. |
ariaLabel |
derived | Accessible label for the role="img" SVG. |
className |
— | Class on the <svg>. |
Also exported: buildWeeks(days) (Sunday-start week bucketing) and
fetchContributions(username, {url, signal}).
Data source & credits
Username mode uses the free community
github-contributions-api
by @grubersjoe, which reads the public
profile calendar — please be considerate with request volume, and point url
at a self-hosted instance for anything high-traffic.
License
MIT Adnan Reza

