Contribution Graph
Carte thermique de style GitHub de l'activite quotidienne au fil du temps. Installez Contribution Graph depuis le registre VLLNT UI avec la CLI shadcn.
Carte thermique de style GitHub de l'activite quotidienne au fil du temps. Fait partie de la famille data dans VLLNT UI, il est publie comme entree de registre lisible par machine — copiez la source directement dans votre application avec la CLI shadcn et possedez-la, sans dependance d'execution a une bibliotheque de composants.
Apercu
Basculez entre clair et sombre pour inspecter l'apercu Storybook integre.
Installation
Ajoutez Contribution Graph a votre projet avec la CLI shadcn. La source arrive dans votre code, prete a etre adaptee :
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/contribution-graph.jsonSource
import * as React from "react";
import { cn } from "../../lib/utils";
/**
* A single day cell of a {@link ContributionGraph}.
*
* @public
*/
export type ContributionDay = {
/** Contribution count for the day. */
count: number;
/** ISO date string (`YYYY-MM-DD`). */
date: string;
};
/**
* Props for {@link ContributionGraph}.
*
* @public
*/
export type ContributionGraphProps = {
/** Gap between cells in pixels. @defaultValue 3 */
cellGap?: number;
/** Square cell size in pixels. @defaultValue 12 */
cellSize?: number;
/** Color of the cells. Defaults to `currentColor` to follow the text token. */
color?: string;
/** One entry per day. Missing days render as empty cells. */
data: ContributionDay[];
/** Number of intensity buckets above zero. @defaultValue 4 */
levels?: number;
/** Optional cap on week columns; keeps the most recent weeks. */
weeks?: number;
} & React.HTMLAttributes<HTMLDivElement>;
const DEFAULT_CELL_SIZE = 12;
const DEFAULT_CELL_GAP = 3;
const DEFAULT_LEVELS = 4;
const DAYS_PER_WEEK = 7;
const MS_PER_DAY = 24 * 60 * 60 * 1000;
type Cell = { count: number; date: string; level: number };
function parseDay(value: string): null | number {
const parsed = Date.parse(value);
if (Number.isNaN(parsed)) return null;
const date = new Date(parsed);
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
}
function isoFromUtc(time: number): string {
return new Date(time).toISOString().slice(0, 10);
}
function bucket(count: number, max: number, levels: number): number {
if (count <= 0 || max <= 0) return 0;
return Math.min(levels, Math.ceil((count / max) * levels));
}
function countByDay(data: ContributionDay[]): Map<number, number> {
return data.reduce<Map<number, number>>((map, entry) => {
const time = parseDay(entry.date);
if (time !== null) map.set(time, (map.get(time) ?? 0) + entry.count);
return map;
}, new Map());
}
function buildWeeks(
data: ContributionDay[],
levels: number,
weeksLimit?: number,
): Cell[][] {
const counts = countByDay(data);
if (counts.size === 0) return [];
const times = [...counts.keys()];
const minTime = Math.min(...times);
const maxTime = Math.max(...times);
const start = minTime - new Date(minTime).getUTCDay() * MS_PER_DAY;
const end = maxTime + (6 - new Date(maxTime).getUTCDay()) * MS_PER_DAY;
const maxCount = Math.max(...counts.values());
const weekCount =
Math.round((end - start) / (DAYS_PER_WEEK * MS_PER_DAY)) + 1;
const weeks = Array.from({ length: weekCount }, (_week, weekIndex) =>
Array.from({ length: DAYS_PER_WEEK }, (_day, dayIndex) => {
const time = start + (weekIndex * DAYS_PER_WEEK + dayIndex) * MS_PER_DAY;
const count = counts.get(time) ?? 0;
return {
count,
date: isoFromUtc(time),
level: bucket(count, maxCount, levels),
};
}),
);
if (weeksLimit && weeks.length > weeksLimit) {
return weeks.slice(weeks.length - weeksLimit);
}
return weeks;
}
function cellOpacity(level: number, levels: number): number {
if (level <= 0) return 0.1;
return 0.25 + 0.75 * (level / levels);
}
/**
* Token-styled SVG contribution graph (GitHub-style activity heatmap).
*
* Pure SVG, no chart dependency. The chart buckets days into intensity levels
* and draws a week-by-weekday grid from `currentColor` with stepped opacity, so
* the heatmap follows the active theme. Returns `null` without dated entries.
*
* @example
* ```tsx
* <ContributionGraph
* className="text-primary"
* data={[
* { date: "2026-01-01", count: 2 },
* { date: "2026-01-02", count: 7 },
* ]}
* />
* ```
*
* @public
*/
export const ContributionGraph = ({
cellGap = DEFAULT_CELL_GAP,
cellSize = DEFAULT_CELL_SIZE,
className,
color = "currentColor",
data,
levels = DEFAULT_LEVELS,
ref,
weeks,
...props
}: ContributionGraphProps & { ref?: React.Ref<HTMLDivElement> }) => {
const ringLevels = Math.max(1, levels);
const columns = buildWeeks(data, ringLevels, weeks);
if (columns.length === 0) return null;
const step = cellSize + cellGap;
const width = columns.length * step - cellGap;
const height = DAYS_PER_WEEK * step - cellGap;
return (
<div
className={cn(
"rounded-2xl border border-border bg-background/40 p-3",
className,
)}
ref={ref}
{...props}
>
<svg
aria-label="Contribution graph"
className="h-full w-full"
height={height}
role="img"
viewBox={`0 0 ${width} ${height}`}
width={width}
>
{columns.map((column, weekIndex) =>
column.map((cell, dayIndex) => (
<rect
fill={color}
fillOpacity={cellOpacity(cell.level, ringLevels)}
height={cellSize}
key={cell.date}
rx={2}
width={cellSize}
x={weekIndex * step}
y={dayIndex * step}
>
<title>{`${cell.date}: ${cell.count.toLocaleString()}`}</title>
</rect>
)),
)}
</svg>
</div>
);
};
ContributionGraph.displayName = "ContributionGraph";
Stories
Explorez chaque variante et etat dans le Storybook interactif :
Apercu
Basculez entre clair et sombre pour inspecter l'apercu Storybook integre.