For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Contributions Card 01

An activity heatmap with summary statistics and time intervals.

Usage

Pass daily data entries with ISO date strings and counts, plus an array of summary stats. Choose an accent using the palette prop. The interval controls group the supplied observations by week, month, or year. Hover a cell or bar to read its exact total.

Source

"use client";

import { cn } from "cn";
import * as React from "react";

import { useCountUp } from "@/components/hooks/use-count-up";
import { Badge } from "@/components/ui/badge";
import {
  Card,
  CardContent,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import { Reveal } from "@/components/ui/reveal";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
  Tooltip,
  TooltipContent,
  TooltipTrigger,
} from "@/components/ui/tooltip";

export interface ContributionDay {
  date: string;
  count: number;
}
export interface ContributionStat {
  label: string;
  value: string;
}
export type ContributionPalette =
  | "purple"
  | "blue"
  | "cyan"
  | "teal"
  | "green"
  | "lime"
  | "orange"
  | "rose"
  | "gray";

const palettes: Record<ContributionPalette, string> = {
  purple: "var(--contributions-purple, var(--chart-5))",
  blue: "var(--contributions-blue, var(--primary))",
  cyan: "var(--contributions-cyan, var(--chart-4))",
  teal: "var(--contributions-teal, var(--chart-1))",
  green: "var(--contributions-green, var(--chart-1))",
  lime: "var(--contributions-lime, var(--chart-1))",
  orange: "var(--contributions-orange, var(--chart-3))",
  rose: "var(--contributions-rose, var(--chart-3))",
  gray: "var(--foreground-tertiary, var(--muted-foreground))",
};
// A stepped ramp for the purple palette, one token per level.
const purpleRamp = [
  "var(--contributions-purple-1, color-mix(in srgb, var(--contributions-purple, var(--chart-5)) 30%, var(--surface-sunken, var(--muted))))",
  "var(--contributions-purple-2, color-mix(in srgb, var(--contributions-purple, var(--chart-5)) 55%, var(--surface-sunken, var(--muted))))",
  "var(--contributions-purple-3, color-mix(in srgb, var(--contributions-purple, var(--chart-5)) 80%, var(--surface-sunken, var(--muted))))",
  "var(--contributions-purple-4, var(--contributions-purple, var(--chart-5)))",
];
const months = [
  "Jan",
  "Feb",
  "Mar",
  "Apr",
  "May",
  "Jun",
  "Jul",
  "Aug",
  "Sep",
  "Oct",
  "Nov",
  "Dec",
];
type Period = "weekly" | "monthly" | "yearly";
const periodLabels: Record<Period, string> = {
  weekly: "Weekly",
  monthly: "Monthly",
  yearly: "Yearly",
};

const cellColor = (
  palette: ContributionPalette,
  count: number,
  maximum: number
) =>
  count <= 0
    ? "var(--contributions-empty, var(--border))"
    : palette === "purple"
      ? purpleRamp[
          Math.min(3, Math.max(0, Math.ceil((count / maximum) * 4) - 1))
        ]
      : `color-mix(in srgb, ${palettes[palette]} ${Math.max(25, Math.ceil((count / maximum) * 4) * 25)}%, var(--surface-sunken, var(--muted)))`;

/**
 * The daily cells. They are memoised because the hint under the pointer
 * re-renders the card on every cell, and the grid has hundreds of them. The
 * grid reveals as one piece (a left-to-right sweep on the container) instead
 * of one animation per cell.
 */
const DailyGrid = React.memo(function DailyGrid({
  dates,
  dayMap,
  dailyMax,
  palette,
  weeks,
}: {
  dates: string[];
  dayMap: Map<string, number>;
  dailyMax: number;
  palette: ContributionPalette;
  weeks: number;
}) {
  const roomy = weeks < 53;
  return (
    <div
      className={cn(
        "contributions-card-01-sweep grid grid-flow-col grid-cols-(--weeks) grid-rows-7",
        roomy ? "gap-0.75" : "gap-0.5"
      )}
      style={
        {
          "--weeks": `repeat(${weeks}, minmax(0, 1fr))`,
        } as React.CSSProperties
      }
    >
      {dates.map((date) => {
        const count = dayMap.get(date) ?? 0;
        return (
          <span
            key={date}
            data-contribution={date}
            data-count={count}
            className={cn(
              "contributions-card-01-cell aspect-square bg-(--cell) hover:ring-1 hover:ring-foreground/25",
              roomy ? "rounded-[3px]" : "rounded-[1px]"
            )}
            style={
              {
                "--cell": cellColor(palette, count, dailyMax),
              } as React.CSSProperties
            }
          />
        );
      })}
    </div>
  );
});

export function ContributionsCard({
  data,
  stats,
  title = "Activity this year",
  change,
  palette = "purple",
  weeks = 53,
  className,
}: {
  data: ContributionDay[];
  stats: ContributionStat[];
  title?: string;
  change?: string;
  palette?: ContributionPalette;
  /** Weeks of daily cells to draw. Fewer weeks give larger, rounder cells. */
  weeks?: number;
  className?: string;
}) {
  const [period, setPeriod] = React.useState<Period>("weekly");
  const [hint, setHint] = React.useState<string | null>(null);
  const [tooltipOpen, setTooltipOpen] = React.useState(false);
  const total = data.reduce((sum, day) => sum + Math.max(0, day.count), 0);
  const totalRef = useCountUp<HTMLParagraphElement>(total);
  // The same observations are grouped into different time buckets for each view.
  const buckets = React.useMemo(() => {
    const sorted = [...data]
      .filter(
        (day) =>
          /^\d{4}-\d{2}-\d{2}$/.test(day.date) &&
          !Number.isNaN(Date.parse(`${day.date}T00:00:00Z`))
      )
      .toSorted((a, b) => a.date.localeCompare(b.date));
    const grouped = new Map<string, number>();
    for (const day of sorted) {
      const date = new Date(`${day.date}T00:00:00Z`);
      if (period === "weekly") {
        date.setUTCDate(date.getUTCDate() - date.getUTCDay());
      }
      const key =
        period === "weekly"
          ? date.toISOString().slice(0, 10)
          : period === "monthly"
            ? day.date.slice(0, 7)
            : day.date.slice(0, 4);
      grouped.set(key, (grouped.get(key) ?? 0) + Math.max(0, day.count));
    }
    return Array.from(grouped, ([label, count]) => ({ label, count }));
  }, [data, period]);
  const max = Math.max(1, ...buckets.map((bucket) => bucket.count));
  const dayMap = React.useMemo(
    () => new Map(data.map((day) => [day.date, Math.max(0, day.count)])),
    [data]
  );
  const dailyMax = Math.max(1, ...dayMap.values());
  const dates = React.useMemo(() => {
    const latest = [...dayMap.keys()]
      .filter(
        (date) =>
          /^\d{4}-\d{2}-\d{2}$/.test(date) &&
          !Number.isNaN(Date.parse(`${date}T00:00:00Z`))
      )
      .toSorted()
      .at(-1);
    if (!latest) {
      return [];
    }
    const end = new Date(`${latest}T00:00:00Z`);
    end.setUTCDate(end.getUTCDate() + (6 - end.getUTCDay()));
    return Array.from({ length: weeks * 7 }, (_, index) => {
      const date = new Date(end);
      date.setUTCDate(end.getUTCDate() - (weeks * 7 - 1) + index);
      return date.toISOString().slice(0, 10);
    });
  }, [dayMap, weeks]);
  const roomy = weeks < 53;
  const hideHint = () => setTooltipOpen(false);
  const showHint = (event: React.PointerEvent<HTMLDivElement>) => {
    const target = (event.target as HTMLElement).closest<HTMLElement>(
      "[data-contribution]"
    );
    const label = target?.dataset.contribution;
    if (!target || !label) {
      hideHint();
      return;
    }
    setHint(`${label}: ${target.dataset.count ?? 0}`);
    setTooltipOpen(true);
  };

  return (
    <Reveal
      variant="card"
      render={
        <Card
          radius={16}
          variant="muted"
          size="xs"
          className={cn("min-w-0", className)}
        />
      }
    >
      <CardHeader className="mb-1.5 gap-0">
        <CardTitle variant="secondary">{title}</CardTitle>
        <div className="flex items-center gap-2">
          <p
            ref={totalRef}
            className="text-2xl leading-8.5 font-medium tabular-nums"
          >
            {total.toLocaleString("en-US")}
          </p>
          {change && (
            <Badge variant="label" tone="lime">
              {change}
            </Badge>
          )}
        </div>
      </CardHeader>
      <CardContent className="grid grid-cols-2 gap-2 sm:grid-cols-4">
        {stats.map((stat) => (
          <Card key={stat.label} radius={10} variant="raised" size="xs">
            <CardContent className="gap-0">
              <p className="text-sm leading-5 font-medium">{stat.value}</p>
              <p className="text-sm leading-5 text-muted-foreground">
                {stat.label}
              </p>
            </CardContent>
          </Card>
        ))}
      </CardContent>
      <CardHeader className="-mt-1 flex items-center justify-between">
        <CardTitle variant="secondary">Activity</CardTitle>
        <div>
          <Tabs
            value={period}
            onValueChange={(value) => {
              if (
                value === "weekly" ||
                value === "monthly" ||
                value === "yearly"
              ) {
                setPeriod(value);
                setHint(null);
                setTooltipOpen(false);
              }
            }}
          >
            <TabsList variant="ghost" aria-label="Activity interval">
              {(["weekly", "monthly", "yearly"] as const).map((value) => (
                <TabsTrigger key={value} value={value}>
                  {periodLabels[value]}
                </TabsTrigger>
              ))}
            </TabsList>
          </Tabs>
        </div>
      </CardHeader>
      <CardContent className="mx-1.5">
        <Tooltip
          open={tooltipOpen}
          onOpenChange={(open) => {
            if (!open) {
              setTooltipOpen(false);
            }
          }}
          trackCursorAxis="both"
        >
          <TooltipTrigger
            delay={120}
            render={<div className="overflow-x-auto" />}
          >
            <div
              className="@container min-w-[400px]"
              onPointerOver={showHint}
              onPointerLeave={hideHint}
            >
              {period === "weekly" ? (
                <div
                  role="img"
                  aria-label={`Daily activity over ${weeks} weeks: ${total.toLocaleString()} contributions`}
                >
                  <DailyGrid
                    dates={dates}
                    dayMap={dayMap}
                    dailyMax={dailyMax}
                    palette={palette}
                    weeks={weeks}
                  />
                  <div
                    className={cn(
                      // oxlint-disable-next-line shadcn/no-arbitrary-values -- reference: axis labels are 13px
                      "flex justify-between text-[13px] leading-4.5 text-muted-foreground",
                      roomy ? "mt-2" : "mt-1"
                    )}
                  >
                    {months.map((month) => (
                      <span key={month}>{month}</span>
                    ))}
                  </div>
                </div>
              ) : (
                <div role="img" aria-label={`${period} activity totals`}>
                  <div
                    key={period}
                    className="flex h-[calc((100cqw_-_104px)*7/53_+_12px)] items-end gap-1"
                  >
                    {buckets.map((bucket, index) => (
                      <div
                        key={bucket.label}
                        data-contribution={bucket.label}
                        data-count={bucket.count}
                        className="h-(--h) min-w-2 flex-1 contributions-card-01-bar origin-bottom rounded-[3px] bg-(--bar) hover:brightness-90"
                        style={
                          {
                            "--h": `${Math.max(5, (bucket.count / max) * 100)}%`,
                            "--bar": cellColor(palette, bucket.count, max),
                            "--delay": `${Math.min(index * 8, 240)}ms`,
                          } as React.CSSProperties
                        }
                      />
                    ))}
                  </div>
                  {/* oxlint-disable-next-line shadcn/no-arbitrary-values -- reference: axis labels are 13px */}
                  <div className="mt-1 flex justify-between gap-2 text-[13px] leading-4.5 text-muted-foreground">
                    <span>{buckets[0]?.label}</span>
                    <span>{buckets.at(-1)?.label}</span>
                  </div>
                </div>
              )}
            </div>
          </TooltipTrigger>
          <TooltipContent>{hint}</TooltipContent>
        </Tooltip>
      </CardContent>
    </Reveal>
  );
}