GitHub Activity Graph
v2.1A privacy-friendly, theme-aware contribution graph.
Install Dependencies
npx shadcn@latest add https://raw.githubusercontent.com/rimu-7/shadcn-components/main/public/registry/github-heatmap.jsonManually setup
Create Component
Copy the code below into components/ui/github-contributions.tsx
"use client";
import { useEffect, useState, useCallback } from "react";
import { ActivityCalendar } from "react-activity-calendar";
import { ExternalLink, AlertCircle } from "lucide-react";
import Link from "next/link";
import { useTheme } from "next-themes";
import { format } from "date-fns";
import * as React from "react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button } from "@/components/ui/button";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
export default function GithubContributions({ username }) {
const { theme, systemTheme } = useTheme();
const [data, setData] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [total, setTotal] = useState(0);
const targetUsername = username || process.env.NEXT_PUBLIC_GITHUB_USERNAME || "rimu-7";
const fetchData = useCallback(async () => {
if (!targetUsername) return;
const controller = new AbortController();
try {
setIsLoading(true);
setError(null);
const response = await fetch(
`https://github-contributions-api.jogruber.de/v4/${targetUsername}?y=last`,
{ signal: controller.signal }
);
if (!response.ok) throw new Error("Failed to fetch contribution data");
const json = await response.json();
if (!json?.contributions) {
setData([]);
setTotal(0);
return;
}
setData(json.contributions);
setTotal(json.contributions.reduce((sum, day) => sum + day.count, 0));
} catch (err) {
if (err.name !== "AbortError") {
console.error("Github API Error:", err);
setError(err.message);
}
} finally {
setIsLoading(false);
}
return () => controller.abort();
}, [targetUsername]);
useEffect(() => { fetchData(); }, [fetchData]);
const currentTheme = theme === "system" ? systemTheme : theme;
const colorTheme = {
light: ["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"],
dark: ["#161b22", "#0e4429", "#006d32", "#26a641", "#39d353"],
};
const renderBlock = (block, activity) => {
const triggerItem = React.cloneElement(block, {
className: "cursor-pointer hover:opacity-80 transition-opacity",
});
return (
<Tooltip key={activity.date}>
<TooltipTrigger asChild>{triggerItem}</TooltipTrigger>
<TooltipContent side="top" className="bg-popover text-popover-foreground shadow-md border">
<div className="text-xs text-center">
<div className="font-bold">{activity.count === 0 ? "No" : activity.count} contributions</div>
<div className="text-muted-foreground">{format(new Date(activity.date), "MMM d, yyyy")}</div>
</div>
</TooltipContent>
</Tooltip>
);
};
if (error) {
return (
<Card className="border-none shadow-none bg-transparent">
<Alert variant="destructive" className="bg-transparent border-none px-0">
<AlertCircle className="h-4 w-4" />
<AlertDescription>Could not load GitHub data.</AlertDescription>
</Alert>
</Card>
);
}
return (
<TooltipProvider delayDuration={0}>
<Card className="w-full mx-auto max-w-3xl border-none shadow-none bg-transparentt">
<CardHeader className="px-0 pt-0 pb-4">
<div className="flex items-center justify-between">
<div className="space-y-1">
<CardTitle className="text-xl font-bold tracking-tight">Github Contributions</CardTitle>
{!isLoading && (
<CardDescription>
<span className="font-medium text-foreground">{total}</span> contributions in the last year
</CardDescription>
)}
</div>
<Button variant="ghost" size="icon" asChild className="h-8 w-8">
<Link href={`https://github.com/${targetUsername}`} target="_blank">
<ExternalLink className="h-4 w-4 text-muted-foreground transition-colors hover:text-foreground" />
</Link>
</Button>
</div>
</CardHeader>
<CardContent className="px-0 pb-0 mx-auto">
{isLoading ? (
<div className="space-y-2 min-w-2xl">
<Skeleton className="h-36 w-full rounded-md opacity-50" />
<div className="flex gap-2"><Skeleton className="h-4 w-24 opacity-50" /><Skeleton className="h-4 w-8 opacity-50" /></div>
</div>
) : (
<div className="w-full overflow-x-auto pb-2 scrollbar-hide">
<div className="min-w-[700px]">
<ActivityCalendar
data={data}
theme={colorTheme}
colorScheme={currentTheme === "dark" ? "dark" : "light"}
blockRadius={3}
blockSize={12}
blockMargin={4}
fontSize={12}
hideTotalCount
hideColorLegend={false}
renderBlock={renderBlock}
labels={{ legend: { less: "Less", more: "More" } }}
/>
</div>
</div>
)}
</CardContent>
</Card>
</TooltipProvider>
);
} How it Works
This component fetches data from github-contributions-api.jogruber.de. This is a proxy service that scrapes GitHub's public contribution graph because GitHub's official GraphQL API is complex to set up for simple public data.
Props
usernamestring
GitHub username. Falls back to env var.