Created
August 8, 2025 09:53
-
-
Save thepratikguptaa/462d1f7c85fa97dddaadb122fdeaf324 to your computer and use it in GitHub Desktop.
GitHub Heatmap Component
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| "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