Contribution Graph
Contribution Graph is a React and Tailwind CSS data component from SmoothUI, licensed MIT. Copy it and paste it into your project.
SmoothUI
MIT
data
What it is
Contribution Graph is a React and Tailwind CSS data component from SmoothUI, licensed MIT. Copy it and paste it into your project.
How to use it
Open it in the catalogue, press copy, and paste it into your project. The prompt you copy carries the code and the rules that tell your agent to reproduce it without changing anything.
Five free copies a month. No card.
Needs
npm i motion
Licence
This component comes from SmoothUI and is redistributed under the MIT licence, which permits commercial use. The copyright notice travels with the code.
Contribution Graph
"use client";
import { motion, useReducedMotion } from "motion/react";
import type React from "react";
import { useMemo, useState } from "react";
export interface ContributionData {
count: number;
date: string;
level: number;
}
export interface ContributionGraphProps {
className?: string;
data?: ContributionData[];
showLegend?: boolean;
showTooltips?: boolean;
year?: number;
}
const WEEKS_IN_YEAR = 53;
const DAYS_IN_WEEK = 7;
const JANUARY_MONTH = 0;
const DECEMBER_MONTH = 11;
const SUNDAY_DAY = 0;
const MIN_WEEKS_FOR_DECEMBER_HEADER = 2;
const TOOLTIP_OFFSET_X = 10;
const TOOLTIP_OFFSET_Y = 40;
const MONTHS = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
// Contribution level colors (similar to GitHub's)
const CONTRIBUTION_COLORS = [
"bg-primary", // Level 0 - No contributions
"bg-brand/25", // Level 1
"bg-brand/50", // Level 2
"bg-brand/75", // Level 3
"bg-brand", // Level 4 - Max
];
const LEVEL_0 = 0;
const LEVEL_1 = 1;
const LEVEL_2 = 2;
const LEVEL_3 = 3;
const LEVEL_4 = 4;
const CONTRIBUTION_LEVELS = [LEVEL_0, LEVEL_1, LEVEL_2, LEVEL_3, LEVEL_4];
const DAY_1 = 1;
const DAY_31 = 31;
// Helper function to check if date is in valid range
const isDateInValidRange = (
currentDate: Date,
startDate: Date,
endDate: Date,
targetYear: number
) => {
const isInRange = currentDate >= startDate && currentDate <= endDate;
const isPreviousYearDecember =
currentDate.getFullYear() === targetYear - 1 &&
currentDate.getMonth() === DECEMBER_MONTH;
const isNextYearJanuary =
currentDate.getFullYear() === targetYear + 1 &&
currentDate.getMonth() === JANUARY_MONTH;
return isInRange || isPreviousYearDecember || isNextYearJanuary;
};
// Helper function to create day data
const createDayData = (
currentDate: Date,
contributionData: ContributionData[]
): ContributionData => {
const [dateString] = currentDate.toISOString().split("T");
const existingData = contributionData.find((d) => d.date === dateString);
return {
count: existingData?.count ?? LEVEL_0,
date: dateString,
level: existingData?.level ?? LEVEL_0,
};
};
// Helper function to check if month should be shown
interface MonthHeaderCheck {
currentMonth: number;
currentYear: number;
startDateDay: number;
targetYear: number;
weekCount: number;
}
const shouldShowMonthHeader = ({
currentYear,
targetYear,
currentMonth,
startDateDay,
weekCount,
}: MonthHeaderCheck) =>
currentYear === targetYear ||
(currentYear === targetYear - 1 &&
currentMonth === DECEMBER_MONTH &&
startDateDay !== SUNDAY_DAY &&
weekCount >= MIN_WEEKS_FOR_DECEMBER_HEADER);
// Helper function to calculate month headers
const calculateMonthHeaders = (targetYear: number) => {
const headers: { month: string; colspan: number; startWeek: number }[] = [];
const startDate = new Date(targetYear, JANUARY_MONTH, DAY_1);
const firstSunday = new Date(startDate);
firstSunday.setDate(startDate.getDate() - startDate.getDay());
let currentMonth = -1;
let currentYear = -1;
let monthStartWeek = 0;
let weekCount = 0;
for (let weekNumber = 0; weekNumber < WEEKS_IN_YEAR; weekNumber++) {
const weekDate = new Date(firstSunday);
weekDate.setDate(firstSunday.getDate() + weekNumber * DAYS_IN_WEEK);
const monthKey = weekDate.getMonth();
const yearKey = weekDate.getFullYear();
if (monthKey !== currentMonth || yearKey !== currentYear) {
if (
currentMonth !== -1 &&
shouldShowMonthHeader({
currentMonth,
currentYear,
startDateDay: startDate.getDay(),
targetYear,
weekCount,
})
) {
headers.push({
colspan: weekCount,
month: MONTHS[currentMonth],
startWeek: monthStartWeek,
});
}
currentMonth = monthKey;
currentYear = yearKey;
monthStartWeek = weekNumber;
weekCount = 1;
} else {
weekCount++;
}
}
// Add the last month
if (
currentMonth !== -1 &&
shouldShowMonthHeader({
currentMonth,
currentYear,
startDateDay: startDate.getDay(),
targetYear,
weekCount,
})
) {
headers.push({
colspan: weekCount,
month: MONTHS[currentMonth],
startWeek: monthStartWeek,
});
}
return headers;
};
export function ContributionGraph({
data = [],
year = new Date().getFullYear(),
className = "",
showLegend = true,
showTooltips = true,
}: ContributionGraphProps) {
const [hoveredDay, setHoveredDay] = useState<ContributionData | null>(null);
const [tooltipPosition, setTooltipPosition] = useState({ x: 0, y: 0 });
const shouldReduceMotion = useReducedMotion();
// Generate all days for the year
const yearData = useMemo(() => {
const startDate = new Date(year, JANUARY_MONTH, DAY_1);
const endDate = new Date(year, DECEMBER_MONTH, DAY_31);
const days: ContributionData[] = [];
// Start from the Sunday of the first week that contains January 1st
// This ensures December gets proper weeks before January
const firstSunday = new Date(startDate);
firstSunday.setDate(startDate.getDate() - startDate.getDay());
// Generate 53 weeks (GitHub shows 53 weeks)
for (let weekNum = 0; weekNum < WEEKS_IN_YEAR; weekNum++) {
for (let day = 0; day < DAYS_IN_WEEK; day++) {
const currentDate = new Date(firstSunday);
currentDate.setDate(
firstSunday.getDate() + weekNum * DAYS_IN_WEEK + day
);
if (isDateInValidRange(currentDate, startDate, endDate, year)) {
days.push(createDayData(currentDate, data));
} else {
// Add empty day for alignment
days.push({
count: LEVEL_0,
date: "",
level: LEVEL_0,
});
}
}
}
return days;
}, [data, year]);
// Calculate month headers with colspan
const monthHeaders = useMemo(() => calculateMonthHeaders(year), [year]);
const handleDayHover = (day: ContributionData, event: React.MouseEvent) => {
if (showTooltips && day.date) {
setHoveredDay(day);
setTooltipPosition({ x: event.clientX, y: event.clientY });
}
};
const handleDayLeave = () => {
setHoveredDay(null);
};
const formatDate = (dateString: string) => {
if (!dateString) {
return "";
}
const date = new Date(dateString);
return date.toLocaleDateString("en-US", {
day: "numeric",
month: "long",
weekday: "long",
year: "numeric",
});
};
const getContributionText = (count: number) => {
if (count === LEVEL_0) {
return "No contributions";
}
if (count === LEVEL_1) {
return "1 contribution";
}
return `${count} contributions`;
};
return (
<div className={`contribution-graph ${className}`}>
<div className="overflow-x-auto">
<table className="border-separate border-spacing-1 text-xs">
<caption className="sr-only">Contribution Graph for {year}</caption>
{/* Month Headers */}
<thead>
<tr className="h-3">
<td className="w-7 min-w-7" />
{monthHeaders.map((header) => (
<td
className="relative text-left text-foreground"
colSpan={header.colspan}
key={`${header.month}-${header.startWeek}`}
>
<span className="absolute top-0 left-1">{header.month}</span>
</td>
))}
</tr>
</thead>
{/* Day Grid */}
<tbody>
{Array.from({ length: DAYS_IN_WEEK }, (_, dayIndex) => (
<tr className="h-2.5" key={DAYS[dayIndex]}>
{/* Day Labels */}
<td className="relative w-7 min-w-7 text-foreground">
{dayIndex % 2 === 0 && (
<span className="absolute -bottom-0.5 left-0 text-xs">
{DAYS[dayIndex]}
</span>
)}
</td>
{/* Day Cells */}
{Array.from({ length: WEEKS_IN_YEAR }, (_week, weekIndex) => {
const dayData = yearData[weekIndex * DAYS_IN_WEEK + dayIndex];
const cellKey = `${dayData?.date ?? "empty"}-${weekIndex}-${dayIndex}`;
if (!dayData?.date) {
return (
<td className="h-2.5 w-2.5 p-0" key={cellKey}>
<div className="h-2.5 w-2.5" />
</td>
);
}
return (
// biome-ignore lint/a11y/noNoninteractiveElementInteractions: Table cell is interactive for hover tooltips
<td
className="h-2.5 w-2.5 cursor-pointer p-0"
key={cellKey}
onMouseEnter={(e) => handleDayHover(dayData, e)}
onMouseLeave={handleDayLeave}
title={
showTooltips
? `${formatDate(dayData.date)}: ${getContributionText(dayData.count)}`
: undefined
}
>
<div
className={`h-2.5 w-2.5 rounded-sm ${
CONTRIBUTION_COLORS[dayData.level]
} hover:ring-2 hover:ring-background`}
/>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
{/* Tooltip */}
{showTooltips && hoveredDay ? (
<motion.div
animate={
shouldReduceMotion ? { opacity: 1 } : { opacity: 1, scale: 1 }
}
className="pointer-events-none fixed z-50 rounded-lg border bg-primary px-3 py-2 text-foreground text-sm shadow-lg"
exit={
shouldReduceMotion
? { opacity: 0, transition: { duration: 0 } }
: { opacity: 0, scale: 0.8 }
}
initial={
shouldReduceMotion ? { opacity: 1 } : { opacity: 0, scale: 0.8 }
}
style={{
left: tooltipPosition.x + TOOLTIP_OFFSET_X,
top: tooltipPosition.y - TOOLTIP_OFFSET_Y,
}}
transition={shouldReduceMotion ? { duration: 0 } : { duration: 0.2 }}
>
<div className="font-semibold">
{getContributionText(hoveredDay.count)}
</div>
<div className="text-foreground/70">
{formatDate(hoveredDay.date)}
</div>
</motion.div>
) : null}
{/* Legend */}
{showLegend ? (
<div className="mt-4 flex items-center justify-between text-foreground/70 text-xs">
<span>Less</span>
<div className="flex items-center gap-1">
{CONTRIBUTION_LEVELS.map((level) => (
<div
className={`h-3 w-3 rounded-sm ${CONTRIBUTION_COLORS[level]}`}
key={level}
/>
))}
</div>
<span>More</span>
</div>
) : null}
</div>
);
}
export default ContributionGraph;