A beautiful, interactive component to display GitHub contribution activity with real-time fetching, smart formatting, zero layout shift, and zero API keys required.
Enter a GitHub username to preview their contribution chart.
Fetches public contribution SVGs directly without personal access tokens or OAuth setups.
Renders a full year of daily contribution intensity blocks with interactive tooltips.
Matches light & dark themes automatically using next-themes.
Includes Skeleton loading states and fallback error states for seamless rendering.
Install react-activity-calendar, date-fns, and axios.
pnpm add react-activity-calendar date-fns axios lucide-reactCreate components/ui/github-heatmap.tsx.
"use client"
import { useEffect, useState } from "react"
import { ActivityCalendar, type Activity, type ThemeInput } 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 axios from "axios"
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 interface GithubContributionsProps {
username?: string
}
export interface ContributionDay {
date: string
count: number
level: 0 | 1 | 2 | 3 | 4
}
export interface ApiResponse {
total: {
[year: number]: number
[year: string]: number
}
contributions: ContributionDay[]
}
export function GithubHeatMap({ username }: GithubContributionsProps) {
const { theme, systemTheme } = useTheme()
const targetUsername = username || process.env.NEXT_PUBLIC_GITHUB_USERNAME || "rimu-7"
const [data, setData] = useState<ContributionDay[]>([])
const [total, setTotal] = useState(0)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [prevUsername, setPrevUsername] = useState<string>(targetUsername)
if (targetUsername !== prevUsername) {
setPrevUsername(targetUsername)
setIsLoading(true)
setError(null)
}
useEffect(() => {
let isMounted = true
const controller = new AbortController()
const loadData = async () => {
if (!targetUsername) return
try {
const response = await axios.get<ApiResponse>(
`https://github-contributions-api.jogruber.de/v4/${targetUsername}?y=last`,
{ signal: controller.signal }
)
if (!isMounted) return
const responseData = response.data
if (!responseData?.contributions) {
setData([])
setTotal(0)
return
}
setData(responseData.contributions)
setTotal(responseData.contributions.reduce((sum, day) => sum + day.count, 0))
} catch (err: any) {
if (!isMounted || axios.isCancel(err)) return
console.error("Github API Error:", err)
if (err.response?.status === 404) {
setError("GitHub user not found")
} else {
setError(err.message || "An API error occurred")
}
} finally {
if (isMounted) setIsLoading(false)
}
}
loadData()
return () => {
isMounted = false
controller.abort()
}
}, [targetUsername])
const currentTheme = theme === "system" ? systemTheme : theme
const colorTheme: ThemeInput = {
light: ["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"],
dark: ["#161b22", "#0e4429", "#006d32", "#26a641", "#39d353"],
}
const renderBlock = (block: React.ReactElement, activity: Activity) => {
const triggerItem = React.cloneElement(block as React.ReactElement<any>, {
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>{error}</AlertDescription>
</Alert>
</Card>
)
}
return (
<TooltipProvider delayDuration={0}>
<Card className="w-full mx-auto max-w-3xl border-none shadow-none bg-transparent">
<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}
hideColorLegend={false}
renderBlock={renderBlock}
labels={{ legend: { less: "Less", more: "More" } }}
/>
</div>
</div>
)}
</CardContent>
</Card>
</TooltipProvider>
)
}
export default GithubHeatMap
Import and add the component to your Next.js pages or components.
import { GithubHeatMap } from "@/components/ui/github-heatmap"
export default function Page() {
return <GithubHeatMap username="rimu-7" />
}"use client"
import { useEffect, useState } from "react"
import { ActivityCalendar, type Activity, type ThemeInput } 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 axios from "axios"
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 interface GithubContributionsProps {
username?: string
}
export interface ContributionDay {
date: string
count: number
level: 0 | 1 | 2 | 3 | 4
}
export interface ApiResponse {
total: {
[year: number]: number
[year: string]: number
}
contributions: ContributionDay[]
}
export function GithubHeatMap({ username }: GithubContributionsProps) {
const { theme, systemTheme } = useTheme()
const targetUsername = username || process.env.NEXT_PUBLIC_GITHUB_USERNAME || "rimu-7"
const [data, setData] = useState<ContributionDay[]>([])
const [total, setTotal] = useState(0)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [prevUsername, setPrevUsername] = useState<string>(targetUsername)
if (targetUsername !== prevUsername) {
setPrevUsername(targetUsername)
setIsLoading(true)
setError(null)
}
useEffect(() => {
let isMounted = true
const controller = new AbortController()
const loadData = async () => {
if (!targetUsername) return
try {
const response = await axios.get<ApiResponse>(
`https://github-contributions-api.jogruber.de/v4/${targetUsername}?y=last`,
{ signal: controller.signal }
)
if (!isMounted) return
const responseData = response.data
if (!responseData?.contributions) {
setData([])
setTotal(0)
return
}
setData(responseData.contributions)
setTotal(responseData.contributions.reduce((sum, day) => sum + day.count, 0))
} catch (err: any) {
if (!isMounted || axios.isCancel(err)) return
console.error("Github API Error:", err)
if (err.response?.status === 404) {
setError("GitHub user not found")
} else {
setError(err.message || "An API error occurred")
}
} finally {
if (isMounted) setIsLoading(false)
}
}
loadData()
return () => {
isMounted = false
controller.abort()
}
}, [targetUsername])
const currentTheme = theme === "system" ? systemTheme : theme
const colorTheme: ThemeInput = {
light: ["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"],
dark: ["#161b22", "#0e4429", "#006d32", "#26a641", "#39d353"],
}
const renderBlock = (block: React.ReactElement, activity: Activity) => {
const triggerItem = React.cloneElement(block as React.ReactElement<any>, {
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>{error}</AlertDescription>
</Alert>
</Card>
)
}
return (
<TooltipProvider delayDuration={0}>
<Card className="w-full mx-auto max-w-3xl border-none shadow-none bg-transparent">
<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}
hideColorLegend={false}
renderBlock={renderBlock}
labels={{ legend: { less: "Less", more: "More" } }}
/>
</div>
</div>
)}
</CardContent>
</Card>
</TooltipProvider>
)
}
export default GithubHeatMap
| Prop | Type | Default | Description |
|---|---|---|---|
| username | string | NEXT_PUBLIC_GITHUB_USERNAME || "rimu-7" | Target GitHub username to fetch and display contribution activity chart. |
<GithubHeatMap username="rimu-7" />
import { GithubHeatMap } from "@/components/ui/github-heatmap"
export default function Profile() {
const username = process.env.NEXT_PUBLIC_GITHUB_USERNAME || "rimu-7"
return <GithubHeatMap username={username} />
}<div className="rounded-xl border bg-card p-6 shadow-sm"> <GithubHeatMap username="vercel" /> </div>