Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save thepratikguptaa/462d1f7c85fa97dddaadb122fdeaf324 to your computer and use it in GitHub Desktop.

Select an option

Save thepratikguptaa/462d1f7c85fa97dddaadb122fdeaf324 to your computer and use it in GitHub Desktop.
GitHub Heatmap Component
"use client"
import * as React from "react"
import { useTheme } from "next-themes"
type Day = { date: string; contributionCount: number; color?: string }
type Week = { firstDay?: string; contributionDays: Day[] }
export function GitHubHeatmap({
weeks = [],
title = "GitHub Contribution",
}: {
weeks?: Week[]
title?: string
}) {
const { theme } = useTheme()
const [mounted, setMounted] = React.useState(false)
React.useEffect(() => {
setMounted(true)
}, [])
const days: Day[] = weeks.flatMap((w) => w.contributionDays || [])
const max = Math.max(1, ...days.map((d) => d.contributionCount || 0))
const totalContributions = days.reduce((sum, d) => sum + d.contributionCount, 0)
// GitHub-like color scale - theme aware
function getColor(count: number) {
if (!mounted) return "#ebedf0" // Default while mounting
const isDark = theme === "dark"
if (count === 0) {
return isDark ? "rgb(22, 27, 34)" : "#ebedf0" // GitHub's exact colors
}
// GitHub's contribution colors
if (isDark) {
if (count <= Math.ceil(max * 0.25)) return "#0e4429" // Dark green 1
if (count <= Math.ceil(max * 0.5)) return "#006d32" // Dark green 2
if (count <= Math.ceil(max * 0.75)) return "#26a641" // Dark green 3
return "#39d353" // Dark green 4
} else {
if (count <= Math.ceil(max * 0.25)) return "#9be9a8" // Light green 1
if (count <= Math.ceil(max * 0.5)) return "#40c463" // Light green 2
if (count <= Math.ceil(max * 0.75)) return "#30a14e" // Light green 3
return "#216e39" // Light green 4
}
}
// GitHub-style month labels - positioned at the start of each month
const monthLabels = React.useMemo(() => {
if (!weeks.length) return []
const labels: { month: string; x: number; weekIndex: number }[] = []
const seenMonths = new Set<string>()
weeks.forEach((week, weekIndex) => {
if (week.contributionDays?.[0]) {
const firstDayOfWeek = new Date(week.contributionDays[0].date)
const month = firstDayOfWeek.toLocaleDateString('en-US', { month: 'short' })
const monthYear = `${month}-${firstDayOfWeek.getFullYear()}`
// Check if this is the first week of a new month
if (!seenMonths.has(monthYear)) {
seenMonths.add(monthYear)
// Only add label if it's not the very first week (to avoid cramped spacing)
// and if there are at least 2 weeks remaining to show the month name
if (weekIndex > 0 && weekIndex < weeks.length - 1) {
const cellSize = typeof window !== 'undefined' && window.innerWidth < 768 ? 9 : 11
const dayLabelWidth = typeof window !== 'undefined' && window.innerWidth < 768 ? 18 : 27
labels.push({
month,
x: weekIndex * (cellSize + 1) + dayLabelWidth,
weekIndex
})
}
}
}
})
// Filter out labels that are too close to each other
const filteredLabels: typeof labels = []
labels.forEach((label, index) => {
const nextLabel = labels[index + 1]
const minDistance = typeof window !== 'undefined' && window.innerWidth < 768 ? 35 : 45
if (!nextLabel || (nextLabel.x - label.x) >= minDistance) {
filteredLabels.push(label)
}
})
return filteredLabels
}, [weeks])
// Responsive cell size
const cellSize = React.useMemo(() => {
if (typeof window === 'undefined') return 11
return window.innerWidth < 768 ? 9 : 11
}, [])
// Day labels for y-axis
const dayLabels = React.useMemo(() => {
if (typeof window === 'undefined') return ['', 'Mon', '', 'Wed', '', 'Fri', '']
return window.innerWidth < 768
? ['', 'M', '', 'W', '', 'F', '']
: ['', 'Mon', '', 'Wed', '', 'Fri', '']
}, [])
const dayLabelWidth = typeof window !== 'undefined' && window.innerWidth < 768 ? 18 : 27
if (!mounted) {
return (
<div className="w-full space-y-3 md:space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<h3 className="text-lg md:text-xl font-bold">{title}</h3>
<div className="flex items-center gap-2 text-xs md:text-sm text-muted-foreground">
<span>Less</span>
<div className="flex gap-1">
{[0, 1, 2, 3, 4].map(level => (
<div
key={level}
className="w-2.5 h-2.5 md:w-3 md:h-3 rounded-sm bg-muted"
/>
))}
</div>
<span>More</span>
</div>
</div>
<div className="h-32 bg-muted animate-pulse rounded-lg" />
</div>
)
}
return (
<div className="w-full space-y-3 md:space-y-4">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<h3 className="text-lg md:text-xl font-bold">{title}</h3>
<div className="flex items-center gap-2 text-xs md:text-sm text-muted-foreground">
<span>Less</span>
<div className="flex gap-1">
{[0, 1, 2, 3, 4].map(level => (
<div
key={level}
className="w-2.5 h-2.5 md:w-3 md:h-3 rounded-sm"
style={{
backgroundColor: level === 0
? getColor(0)
: getColor(Math.ceil(max * (level / 4)))
}}
/>
))}
</div>
<span>More</span>
</div>
</div>
{/* Centered contribution graph */}
<div className="flex justify-center">
<div className="overflow-x-auto">
<div
className="relative"
style={{
minWidth: `${weeks.length * (cellSize + 1) + dayLabelWidth + 10}px`
}}
>
{/* Month labels positioned like GitHub */}
<div className="relative h-5 md:h-6 mb-2">
{monthLabels.map(({ month, x, weekIndex }) => (
<div
key={`${month}-${weekIndex}`}
className="absolute text-xs text-muted-foreground font-medium"
style={{
left: `${x}px`
}}
>
{month}
</div>
))}
</div>
<div className="flex">
{/* Day labels */}
<div className="flex flex-col gap-0.5 pt-1" style={{ width: `${dayLabelWidth}px` }}>
{dayLabels.map((day, i) => (
<div
key={i}
className="text-xs text-muted-foreground flex items-center justify-end pr-1"
style={{
height: `${cellSize}px`
}}
>
{day}
</div>
))}
</div>
{/* Contribution grid - GitHub style squares */}
<div
className="grid grid-rows-7"
style={{
gridAutoFlow: 'column',
gridAutoColumns: `${cellSize + 1}px`,
gap: '1px'
}}
>
{weeks.map((week, weekIndex) =>
(week.contributionDays || []).map((day, dayIndex) => (
<div
key={`${weekIndex}-${dayIndex}`}
className="cursor-pointer hover:ring-1 hover:ring-primary/50 transition-all duration-200"
style={{
backgroundColor: getColor(day.contributionCount),
width: `${cellSize}px`,
height: `${cellSize}px`,
borderRadius: '2px'
}}
title={`${day.contributionCount} contribution${day.contributionCount === 1 ? '' : 's'} on ${new Date(day.date).toLocaleDateString('en-US', {
weekday: 'long',
year: 'numeric',
month: 'long',
day: 'numeric'
})}`}
/>
))
)}
</div>
</div>
</div>
</div>
</div>
<p className="text-xs md:text-sm text-muted-foreground text-center">
<span className="font-medium">{totalContributions}</span> contributions in the last year
</p>
</div>
)
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment