Use Activity Graph to show daily activity in a keyboard-accessible calendar heatmap. It accepts ordinary date and value records, handles duplicate days, and keeps the intensity scale useful when values vary widely.
Install
Basic usage
Examples
Use controlled selection when the selected day affects another view. Pass custom metadata and tooltip content when the calendar needs more context than a numeric value.
GitHub data
Fetch GitHub contribution data on the server and pass normalized days into the graph. Keep tokens and API calls outside the component so the installed UI remains safe for client applications.
src/lib/github-contributions.ts import "server-only";
const GITHUB_GRAPHQL_URL = "https://api.github.com/graphql";
const MAX_RANGE_IN_DAYS = 366;
export interface GitHubContributionDay {
date: string;
value: number;
label: string;
}
export interface GitHubContributionCalendar {
login: string;
days: GitHubContributionDay[];
totalContributions: number;
restrictedContributionsCount: number;
viewerIsProfileOwner: boolean;
from: string;
to: string;
}
export interface GetGitHubContributionsOptions {
login: string;
token?: string;
from?: Date;
to?: Date;
signal?: AbortSignal;
}
interface GitHubGraphQLResponse {
data?: {
user: {
contributionsCollection: {
contributionCalendar: {
totalContributions: number;
weeks: Array<{
contributionDays: Array<{
contributionCount: number;
date: string;
}>;
}>;
};
restrictedContributionsCount: number;
};
login: string;
} | null;
viewer: {
login: string;
};
};
errors?: Array<{
message: string;
}>;
}
const contributionCalendarQuery = `
query ProfileContributionCalendar(
$login: String!
$from: DateTime!
$to: DateTime!
) {
viewer {
login
}
user(login: $login) {
login
contributionsCollection(from: $from, to: $to) {
restrictedContributionsCount
contributionCalendar {
totalContributions
weeks {
contributionDays {
contributionCount
date
}
}
}
}
}
}
`;
function startOfUtcDay(date: Date) {
return new Date(
Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()),
);
}
function defaultDateRange() {
const to = startOfUtcDay(new Date());
const from = new Date(to);
from.setUTCFullYear(from.getUTCFullYear() - 1);
from.setUTCDate(from.getUTCDate() + 1);
return { from, to };
}
function validateDateRange(from: Date, to: Date) {
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime()) || from > to) {
throw new Error("GitHub contribution dates must form a valid range.");
}
const rangeInDays = Math.floor(
(to.getTime() - from.getTime()) / (24 * 60 * 60 * 1000),
);
if (rangeInDays > MAX_RANGE_IN_DAYS) {
throw new Error("GitHub contribution ranges cannot exceed one year.");
}
}
function formatContributionLabel(value: number, date: string) {
const contributionLabel = value === 1 ? "contribution" : "contributions";
return `${value} ${contributionLabel} on ${date}`;
}
export async function getGitHubProfileContributions({
login,
token = process.env.GITHUB_TOKEN,
from: requestedFrom,
to: requestedTo,
signal,
}: GetGitHubContributionsOptions): Promise<GitHubContributionCalendar> {
const normalizedLogin = login.trim();
const normalizedToken = token?.trim();
if (!normalizedLogin) {
throw new Error("A GitHub login is required.");
}
if (!normalizedToken) {
throw new Error(
"GITHUB_TOKEN is required to fetch GitHub contribution data.",
);
}
const defaults = defaultDateRange();
const from = startOfUtcDay(requestedFrom ?? defaults.from);
const to = startOfUtcDay(requestedTo ?? defaults.to);
validateDateRange(from, to);
const response = await fetch(GITHUB_GRAPHQL_URL, {
method: "POST",
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${normalizedToken}`,
"Content-Type": "application/json",
"User-Agent": "sona-ui-activity-graph",
},
body: JSON.stringify({
query: contributionCalendarQuery,
variables: {
login: normalizedLogin,
from: from.toISOString(),
to: to.toISOString(),
},
}),
cache: "no-store",
signal,
});
const payload = (await response.json()) as GitHubGraphQLResponse;
if (!response.ok || payload.errors?.length) {
const message =
payload.errors?.map((error) => error.message).join("; ") ||
`GitHub returned ${response.status}.`;
throw new Error(`Unable to fetch GitHub contributions: ${message}`);
}
const user = payload.data?.user;
const viewer = payload.data?.viewer;
if (!user || !viewer) {
throw new Error(`GitHub user "${normalizedLogin}" was not found.`);
}
const collection = user.contributionsCollection;
const days = collection.contributionCalendar.weeks.flatMap((week) =>
week.contributionDays.map((day) => ({
date: day.date,
value: day.contributionCount,
label: formatContributionLabel(day.contributionCount, day.date),
})),
);
return {
login: user.login,
days,
totalContributions: collection.contributionCalendar.totalContributions,
restrictedContributionsCount: collection.restrictedContributionsCount,
viewerIsProfileOwner:
viewer.login.toLowerCase() === user.login.toLowerCase(),
from: from.toISOString(),
to: to.toISOString(),
};
}
API
| Property | Type | Default | Description |
|---|
data | ActivityGraphDatum[] | required | Dated activity records displayed in the graph. |
startDate | Date | string | 364 days before endDate | Inclusive first date displayed by the graph. |
endDate | Date | string | latest data date or today | Inclusive last date displayed by the graph. |
levels | number | 4 | Number of non-empty color intensity levels. |
maxDays | number | 366 | Maximum number of calendar days rendered. |
weekStartsOn | 0 | 1 | 6 | 0 | First day of each visual week: Sunday, Monday, or Saturday. |
value | Date | string | null | undefined | Controlled selected date. |
defaultValue | Date | string | null | undefined | Initially selected date for uncontrolled usage. |
onValueChange | (date: Date, item: ActivityGraphDatum | undefined) => void | undefined | Called when a date is selected. |
onCellSelect | (item: ActivityGraphDatum | undefined, date: Date) => void | undefined | Called when a selected date is activated with pointer or keyboard. |
renderValue | (context: ActivityGraphValueContext) => ReactNode | undefined | Custom content shown for the currently explored date. |
showValue | boolean | true | Shows the active date and value above the graph. |
showTooltip | boolean | false | Shows an anchored tooltip when a date cell is hovered or focused. |
tooltipDelay | number | 400 | Delay before the first tooltip opens, in milliseconds. |
renderTooltip | (context: ActivityGraphValueContext) => ReactNode | undefined | Custom content rendered inside the optional cell tooltip. |
colors | string[] | undefined | Custom colors for non-empty intensity levels, ordered from low to high. |
emptyColor | string | undefined | Custom color for dates without activity. |
showMonthLabels | boolean | true | Shows month labels above the graph. |
showWeekdayLabels | boolean | true | Shows abbreviated weekday labels beside the graph. |
showLegend | boolean | true | Shows the intensity legend below the graph. |
emptyLabel | string | "No activity" | Accessible description for a date without activity. |
ariaLabel | string | "Activity graph" | Accessible name for the interactive graph. |
gridClassName | string | undefined | Additional classes for the scrollable graph region. |
cellClassName | string | undefined | Additional classes applied to every date cell. |
tooltipClassName | string | undefined | Additional classes for the optional tooltip surface. |
legendClassName | string | undefined | Additional classes for the intensity legend. |
Accessibility
Activity Graph supports roving keyboard focus, visual-direction arrow keys, Home and End, and Enter or Space to select a day. It also provides a range summary and larger coarse-pointer targets.
Styling
The activity-graph token set controls cell size, gaps, radius, colors, and focus treatment. The default range is limited to one year to keep the calendar usable.
Found a bug or need help using this component? Open an issue on GitHub.
License & Usage
Sona UI is available under the MIT license. You may use, modify, and distribute this component in personal and commercial projects.