Skip to content

GurbinderEvilcharts

Recharts Heatmap Chart

Recharts heatmap chart component

Loading preview…

Installation

terminal
pnpm dlx shadcn@latest add https://layro.dev/r/legions-developer/recharts-heatmap-chart.json

Or, with @layro in your components.json:

terminal
pnpm dlx shadcn@latest add @layro/legions-developer/recharts-heatmap-chart

Code

Dependencies
Demo
"use client";

import {
  Children,
  createContext,
  isValidElement,
  memo,
  use,
  useCallback,
  useEffect,
  useId,
  useMemo,
  useState,
  useSyncExternalStore,
  type ComponentProps,
  type FC,
  type KeyboardEvent,
  type ReactNode,
} from "react";
import {
  Scatter,
  ScatterChart,
  XAxis,
  YAxis,
  useChartHeight,
  useChartWidth,
  type ScatterShapeProps,
  type XAxisTickContentProps,
  type YAxisTickContentProps,
} from "recharts";
import {
  ChartContainer,
  LoadingIndicator,
  getColorsCount,
  type ChartConfig,
} from "@/registry/ui/recharts-chart";
import {
  ChartTooltip,
  type TooltipRoundness,
  type TooltipVariant,
} from "@/registry/ui/recharts-tooltip";
import { motion, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export type { ChartConfig, TooltipRoundness, TooltipVariant };

const DAY_MS = 86_400_000;
const DAYS_PER_WEEK = 7;
const TRAILING_YEAR_DAYS = 365; // default range — the trailing year, GitHub style

const INTRO_COLUMN_STAGGER = 0.014; // seconds between one week column and the next
const INTRO_CELL_GROW = 0.32; // seconds for a single cell to scale up + fade in
const INTRO_CELL_SCALE_FROM = 0.4; // a cell opens from this fraction of its size
const LOADING_ANIMATION_DURATION = 2000; // shimmer loop, in milliseconds

const DEFAULT_CELL_RADIUS = 2;
const DEFAULT_CELL_GAP = 3;
const MIN_CELL_SIZE = 4; // never fit cells smaller than this
const FALLBACK_CELL_SIZE = 12; // used before the container has a measured size

const DAY_LABEL_WIDTH = 32;
const MONTH_LABEL_HEIGHT = 22;
const EDGE_PADDING = 2; // keeps the outermost cells' hover ring inside the plot
const LABEL_MARGIN = 8; // gap between the month labels' baseline and the grid
const MIN_MONTH_LABEL_COLUMNS = 3; // a month label is dropped when the next one is closer than this
// Module-level so the margin keeps its identity across renders: a new object
// makes Recharts recompute the layout and remount every cell.
const CHART_MARGIN = {
  top: EDGE_PADDING,
  right: EDGE_PADDING,
  bottom: EDGE_PADDING,
  left: EDGE_PADDING,
};

const CELL_DIM_OPACITY = 0.55; // cells outside the current selection — soft, so the ramp still reads
const HOVER_RING_WIDTH = 1; // outline drawn around the hovered cell, in pixels
const SELECTED_RING_WIDTH = 1.5; // outline drawn around the selected cell, in pixels

const LOADING_CELL_FLOOR = 0.07; // cell fill outside the sweep, × foreground alpha
const LOADING_CELL_PEAK = 0.22; // cell fill inside the sweep, × foreground alpha
const LOADING_SHIMMER_BAND = 0.22; // sweep half-width, fraction of chart width

const DEFAULT_DAY_LABELS = ["", "Mon", "", "Wed", "", "Fri", ""]; // Sunday first, like GitHub
const DEFAULT_MONTH_LABELS = [
  "Jan",
  "Feb",
  "Mar",
  "Apr",
  "May",
  "Jun",
  "Jul",
  "Aug",
  "Sep",
  "Oct",
  "Nov",
  "Dec",
];

const roundnessMap: Record<TooltipRoundness, string> = {
  sm: "rounded-sm",
  md: "rounded-md",
  lg: "rounded-lg",
  xl: "rounded-xl",
};

const variantMap: Record<TooltipVariant, string> = {
  default: "bg-background",
  "frosted-glass": "bg-background/70 backdrop-blur-sm",
};

export type HeatmapDatum = {
  date: string;
  value: number;
};

export type HeatmapRange = number | string | [string, string];

export type WeekStart = "sunday" | "monday";
export type HeatmapScale = "quantile" | "linear";
export type HeatmapAnimationType = "none" | "default";
export type TooltipPosition = "fixed" | "variable";

export type HeatmapSelection = { date: string; value: number };

type ScatterChartProps = ComponentProps<typeof ScatterChart>;

export interface EvilHeatmapChartProps {
  data: HeatmapDatum[]; // one entry per day that has a value
  config: ChartConfig; // the series key's label + color ramp (one color per intensity level)
  children: ReactNode; // composed parts — <Cell>, <MonthLabel>, <DayLabel>, <Tooltip>, <Legend>
  className?: string; // extra classes for the chart container
  chartProps?: Omit<ScatterChartProps, "data" | "children" | "margin">; // escape hatch for the raw Recharts ScatterChart
  range?: HeatmapRange; // calendar window; defaults to the trailing year
  weekStart?: WeekStart; // which day starts each column
  cellSize?: number; // fixed cell size in pixels; omit to fit the container width
  scale?: HeatmapScale; // how values map onto the color ramp when no thresholds are given
  thresholds?: number[]; // explicit ascending level boundaries; values below the first are level 1
  defaultSelectedDate?: string | null; // cell selected on first render
  onSelectionChange?: (selection: HeatmapSelection | null) => void; // fires when the selected cell changes
  isLoading?: boolean; // shows the animated loading skeleton
  animation?: boolean; // master switch for the intro sweep — false renders instantly
  animationType?: HeatmapAnimationType; // "none" disables the intro sweep
}

export interface CellProps {
  dataKey: string; // config key whose colors form the intensity ramp
  radius?: number; // corner radius of each cell in pixels
  gap?: number; // space between cells in pixels
  isClickable?: boolean; // lets cells be selected by clicking them
}

/**
 * Configures the heatmap cells. A configuration slot — the root reads its props
 * and wires them into the Recharts scatter that paints the grid, so it renders
 * nothing itself. `dataKey` picks the config entry whose color array becomes the
 * intensity ramp: the first color is the lightest level, the last the strongest.
 */
const Cell: FC<CellProps> = () => null;

export interface MonthLabelProps {
  labels?: string[]; // twelve month names, January first
}

/** Presence shows month names above the grid. A configuration slot — renders nothing itself. */
const MonthLabel: FC<MonthLabelProps> = () => null;

export interface DayLabelProps {
  labels?: string[]; // seven day names, Sunday first; an empty string hides that row's label
}

/** Presence shows weekday names beside the grid. A configuration slot — renders nothing itself. */
const DayLabel: FC<DayLabelProps> = () => null;

export interface LegendProps {
  align?: "left" | "right"; // which side of the chart the ramp sits on
  lessLabel?: string; // text before the ramp
  moreLabel?: string; // text after the ramp
}

/** Presence renders the "Less → More" ramp under the grid. A configuration slot — renders nothing itself. */
const Legend: FC<LegendProps> = () => null;

type CellSlot = {
  dataKey: string;
  radius: number;
  gap: number;
  isClickable: boolean;
};
type MonthLabelSlot = { labels: string[] };
type DayLabelSlot = { labels: string[] };
type LegendSlot = {
  align: "left" | "right";
  lessLabel: string;
  moreLabel: string;
};

type CollectedSlots = {
  cell: CellSlot;
  monthLabel: MonthLabelSlot | null;
  dayLabel: DayLabelSlot | null;
  legend: LegendSlot | null;
};

function collectSlots(children: ReactNode): CollectedSlots {
  let cell: CellSlot = {
    dataKey: "",
    radius: DEFAULT_CELL_RADIUS,
    gap: DEFAULT_CELL_GAP,
    isClickable: false,
  };
  let monthLabel: MonthLabelSlot | null = null;
  let dayLabel: DayLabelSlot | null = null;
  let legend: LegendSlot | null = null;

  Children.forEach(children, (child) => {
    if (!isValidElement(child)) return;
    const type = child.type;

    if (type === Cell) {
      const props = child.props as CellProps;
      cell = {
        dataKey: props.dataKey,
        radius: props.radius ?? DEFAULT_CELL_RADIUS,
        gap: props.gap ?? DEFAULT_CELL_GAP,
        isClickable: props.isClickable ?? false,
      };
    } else if (type === MonthLabel) {
      const props = child.props as MonthLabelProps;
      monthLabel = { labels: props.labels ?? DEFAULT_MONTH_LABELS };
    } else if (type === DayLabel) {
      const props = child.props as DayLabelProps;
      dayLabel = { labels: props.labels ?? DEFAULT_DAY_LABELS };
    } else if (type === Legend) {
      const props = child.props as LegendProps;
      legend = {
        align: props.align ?? "right",
        lessLabel: props.lessLabel ?? "Less",
        moreLabel: props.moreLabel ?? "More",
      };
    }
  });

  return { cell, monthLabel, dayLabel, legend };
}

function defaultDateFormatter(date: string): string {
  const ms = parseIsoDate(date);
  if (Number.isNaN(ms)) return date;
  return new Date(ms).toLocaleDateString("en-US", {
    month: "short",
    day: "numeric",
    year: "numeric",
    timeZone: "UTC",
  });
}

function defaultValueFormatter(value: number): string {
  return value.toLocaleString();
}

function parseIsoDate(iso: string): number {
  const [y, m, d] = iso.split("-").map(Number);
  if (!y || !m || !d) return Number.NaN;
  return Date.UTC(y, m - 1, d);
}

function toIsoDate(ms: number): string {
  return new Date(ms).toISOString().slice(0, 10);
}

function todayUtc(): number {
  const now = new Date();
  return Date.UTC(now.getFullYear(), now.getMonth(), now.getDate());
}

type ResolvedRange = { start: number; end: number }; // inclusive UTC midnights

function resolveRange(range: HeatmapRange | undefined, data: HeatmapDatum[]): ResolvedRange {
  if (typeof range === "number") {
    return { start: Date.UTC(range, 0, 1), end: Date.UTC(range, 11, 31) };
  }
  if (typeof range === "string") {
    const [y, m] = range.split("-").map(Number);
    if (y && m) return { start: Date.UTC(y, m - 1, 1), end: Date.UTC(y, m, 0) };
    if (y) return { start: Date.UTC(y, 0, 1), end: Date.UTC(y, 11, 31) };
  }
  if (Array.isArray(range)) {
    const start = parseIsoDate(range[0]);
    const end = parseIsoDate(range[1]);
    if (!Number.isNaN(start) && !Number.isNaN(end) && start <= end) return { start, end };
  }

  let latest = Number.NEGATIVE_INFINITY;
  for (const datum of data) {
    const ms = parseIsoDate(datum.date);
    if (!Number.isNaN(ms) && ms > latest) latest = ms;
  }
  const end = Number.isFinite(latest) ? latest : todayUtc();
  return { start: end - (TRAILING_YEAR_DAYS - 1) * DAY_MS, end };
}

type GridCell = {
  date: string; // ISO calendar date
  value: number; // 0 for days absent from the data
  level: number; // 0 = empty, 1..levels = ramp intensity
  column: number; // week column, 0-based from the range start
  row: number; // weekday row, 0-based from `weekStart`
};

type MonthTick = { column: number; month: number }; // month label anchored at this week column

type GridModel = {
  cells: GridCell[];
  weeks: number; // number of week columns
  levels: number; // number of non-empty intensity levels
  monthTicks: MonthTick[]; // month starts inside the range, thinned so labels never overlap
  range: ResolvedRange;
};

function computeThresholds(values: number[], levels: number, scale: HeatmapScale): number[] {
  const cuts = levels - 1;
  if (cuts <= 0) return [];
  const nonZero = values.filter((value) => value > 0).sort((a, b) => a - b);
  if (nonZero.length === 0) return Array.from({ length: cuts }, (_, i) => i + 1);

  if (scale === "linear") {
    const min = nonZero[0];
    const max = nonZero[nonZero.length - 1];
    if (max === min) return Array.from({ length: cuts }, () => max + 1);
    return Array.from({ length: cuts }, (_, i) => min + ((max - min) * (i + 1)) / levels);
  }

  return Array.from({ length: cuts }, (_, i) => {
    const position = ((i + 1) / levels) * (nonZero.length - 1);
    const lower = Math.floor(position);
    const upper = Math.min(nonZero.length - 1, lower + 1);
    const fraction = position - lower;
    return nonZero[lower] + (nonZero[upper] - nonZero[lower]) * fraction;
  });
}

function levelOf(value: number, thresholds: number[]): number {
  if (value <= 0) return 0;
  let level = 1;
  for (const threshold of thresholds) {
    if (value >= threshold) level += 1;
  }
  return level;
}

function buildGrid(
  data: HeatmapDatum[],
  range: ResolvedRange,
  weekStart: WeekStart,
  levels: number,
  scale: HeatmapScale,
  thresholds: number[] | undefined,
): GridModel {
  const firstDay = weekStart === "monday" ? 1 : 0;
  const valueByDate = new Map<string, number>();
  for (const datum of data) {
    const ms = parseIsoDate(datum.date);
    if (Number.isNaN(ms) || ms < range.start || ms > range.end) continue;
    valueByDate.set(toIsoDate(ms), (valueByDate.get(toIsoDate(ms)) ?? 0) + datum.value);
  }

  const cuts =
    thresholds && thresholds.length > 0
      ? [...thresholds].sort((a, b) => a - b)
      : computeThresholds([...valueByDate.values()], levels, scale);
  const effectiveLevels = cuts.length + 1;

  const startOffset =
    (new Date(range.start).getUTCDay() - firstDay + DAYS_PER_WEEK) % DAYS_PER_WEEK;
  const days = Math.round((range.end - range.start) / DAY_MS) + 1;
  const weeks = Math.floor((days + startOffset + DAYS_PER_WEEK - 1) / DAYS_PER_WEEK);

  const cells: GridCell[] = [];
  const monthStarts: MonthTick[] = [];
  for (let i = 0; i < days; i++) {
    const ms = range.start + i * DAY_MS;
    const day = new Date(ms);
    const date = toIsoDate(ms);
    const value = valueByDate.get(date) ?? 0;
    const column = Math.floor((i + startOffset) / DAYS_PER_WEEK);
    cells.push({
      date,
      value,
      level: Math.min(effectiveLevels, levelOf(value, cuts)),
      column,
      row: (i + startOffset) % DAYS_PER_WEEK,
    });
    // The range's first day names its month too, so a window opening mid-month
    // still starts with a label — GitHub does the same.
    if (i === 0 || day.getUTCDate() === 1) {
      monthStarts.push({ column, month: day.getUTCMonth() });
    }
  }

  // Two labels closer than a few columns would overlap — keep the later one.
  const monthTicks = monthStarts.filter(
    (tick, index) =>
      index === monthStarts.length - 1 ||
      monthStarts[index + 1].column - tick.column >= MIN_MONTH_LABEL_COLUMNS,
  );

  return { cells, weeks, levels: effectiveLevels, monthTicks, range };
}

/** Index into the config's color slots for a non-empty level. */
function rampIndex(level: number, levels: number, colorsCount: number): number {
  if (levels <= 1 || colorsCount <= 1) return colorsCount - 1;
  const index = Math.round(((level - 1) / (levels - 1)) * (colorsCount - 1));
  return Math.max(0, Math.min(colorsCount - 1, index));
}

type Layout = {
  cellSize: number; // pixels per week column and per weekday row
  gridTop: number; // pixel y of the first row's top edge, from the top of the chart
  xDomain: [number, number]; // week columns visible across the plot, in cell units
  yDomain: [number, number]; // weekday rows visible down the plot, in cell units
};

/**
 * Sizes square cells to the plot and expresses the result as axis domains: one
 * domain unit is exactly one cell, so Recharts' linear scales place every cell
 * without any further math. Columns fill from the left; the seven rows sit
 * centered vertically in whatever height is left.
 */
function computeLayout(
  chartWidth: number,
  chartHeight: number,
  weeks: number,
  fixedCellSize: number | undefined,
  hasDayLabels: boolean,
  hasMonthLabels: boolean,
): Layout {
  const plotWidth = Math.max(
    0,
    chartWidth - 2 * EDGE_PADDING - (hasDayLabels ? DAY_LABEL_WIDTH : 0),
  );
  const plotHeight = Math.max(
    0,
    chartHeight - 2 * EDGE_PADDING - (hasMonthLabels ? MONTH_LABEL_HEIGHT : 0),
  );

  let cellSize = fixedCellSize ?? FALLBACK_CELL_SIZE;
  if (fixedCellSize === undefined && plotWidth > 0 && weeks > 0) {
    const fitWidth = Math.floor(plotWidth / weeks);
    const fitHeight =
      plotHeight > 0 ? Math.floor(plotHeight / DAYS_PER_WEEK) : Number.POSITIVE_INFINITY;
    cellSize = Math.max(MIN_CELL_SIZE, Math.min(fitWidth, fitHeight));
  }

  const columnsAcross = plotWidth > 0 ? plotWidth / cellSize : weeks;
  const rowsDown = plotHeight > 0 ? plotHeight / cellSize : DAYS_PER_WEEK;
  const spare = Math.max(0, plotHeight - cellSize * DAYS_PER_WEEK);
  const rowOffset = Math.floor(spare / 2) / cellSize; // whole pixels keep the cells crisp

  return {
    cellSize,
    gridTop: EDGE_PADDING + (hasMonthLabels ? MONTH_LABEL_HEIGHT : 0) + rowOffset * cellSize,
    xDomain: [-0.5, columnsAcross - 0.5],
    yDomain: [-0.5 - rowOffset, rowsDown - 0.5 - rowOffset],
  };
}

type CellStatus = "idle" | "selected" | "dimmed"; // a cell's look relative to the selection

/**
 * Selection and intro state, kept outside React state on purpose. Recharts
 * remounts every scatter symbol whenever the <Scatter> element receives new
 * props, so a selection held in React state would rebuild all ~365 cells (and
 * replay their intro) on each click. Instead each cell subscribes to its own
 * status and only the cells whose status flips re-render.
 */
type HeatmapStore = {
  getSelected: () => string | null; // currently selected ISO date, or null when none
  subscribe: (listener: () => void) => () => void; // useSyncExternalStore contract
  toggle: (date: string, value: number) => void; // selects a day, or clears it when already selected
  setOnSelectionChange: (callback: EvilHeatmapChartProps["onSelectionChange"]) => void; // synced by the root
  isRevealPending: () => boolean; // true until the first real cells mount — later remounts skip the intro
  armReveal: () => void; // the next cells to mount play the intro again (after a loading state)
  settleReveal: () => void; // called once cells have mounted
  getTabStop: () => string | null; // day that last took keyboard focus, or null for the default stop
  setTabStop: (date: string | null) => void; // moves the grid's single Tab stop (roving tabindex)
};

function createHeatmapStore(initialSelected: string | null): HeatmapStore {
  let selected = initialSelected;
  let tabStop: string | null = null;
  let onSelectionChange: EvilHeatmapChartProps["onSelectionChange"];
  let revealPending = true;
  const listeners = new Set<() => void>();
  const notify = () => {
    for (const listener of listeners) listener();
  };

  return {
    getSelected: () => selected,
    subscribe: (listener) => {
      listeners.add(listener);
      return () => {
        listeners.delete(listener);
      };
    },
    toggle: (date, value) => {
      selected = selected === date ? null : date;
      notify();
      onSelectionChange?.(selected === null ? null : { date: selected, value });
    },
    getTabStop: () => tabStop,
    setTabStop: (date) => {
      if (tabStop === date) return;
      tabStop = date;
      notify();
    },
    setOnSelectionChange: (callback) => {
      onSelectionChange = callback;
    },
    isRevealPending: () => revealPending,
    armReveal: () => {
      revealPending = true;
    },
    settleReveal: () => {
      revealPending = false;
    },
  };
}

/** The cell's look relative to the selection; re-renders only when that look changes. */
function useCellStatus(store: HeatmapStore, date: string): CellStatus {
  const getStatus = (): CellStatus => {
    const selected = store.getSelected();
    if (selected === null) return "idle";
    return selected === date ? "selected" : "dimmed";
  };
  return useSyncExternalStore(store.subscribe, getStatus, getStatus);
}

/**
 * Whether the cell is the grid's single Tab stop: the day that last had focus,
 * else the selected day, else the most recent day in the range. A stored day
 * with no cell (outside the range, or gone after a data change) is skipped, so
 * some cell always keeps the stop and the grid stays reachable by keyboard.
 */
function useIsTabStop(
  store: HeatmapStore,
  date: string,
  isLast: boolean,
  hasDate: (date: string) => boolean,
): boolean {
  const getIsTabStop = () => {
    const tabStop = store.getTabStop();
    const selected = store.getSelected();
    const stop =
      tabStop !== null && hasDate(tabStop)
        ? tabStop
        : selected !== null && hasDate(selected)
          ? selected
          : null;
    return stop === null ? isLast : stop === date;
  };
  return useSyncExternalStore(store.subscribe, getIsTabStop, getIsTabStop);
}

/** Day index a key press moves to: rows are weekdays, columns are weeks. */
function keyTarget(key: string, index: number, count: number): number | null {
  switch (key) {
    case "ArrowUp":
      return index - 1;
    case "ArrowDown":
      return index + 1;
    case "ArrowLeft":
      return index - DAYS_PER_WEEK;
    case "ArrowRight":
      return index + DAYS_PER_WEEK;
    case "Home":
      return 0;
    case "End":
      return count - 1;
    default:
      return null;
  }
}

/**
 * Shared state for every part of the chart. Lifted into <EvilHeatmapChart /> so
 * the cells, axes, tooltip, and legend can read it without prop drilling.
 */
type HeatmapChartContextValue = {
  config: ChartConfig; // colors + label for the series named by <Cell dataKey>
  cell: CellSlot; // resolved <Cell /> props
  grid: GridModel; // the date window laid out as week columns
  colorsCount: number; // number of color slots the config provides for the ramp
  fixedCellSize: number | undefined; // the root's `cellSize` prop
  hasDayLabels: boolean; // whether <DayLabel /> is composed
  hasMonthLabels: boolean; // whether <MonthLabel /> is composed
  weekStart: WeekStart; // which weekday sits on the top row
  isLoading: boolean; // whether the chart shows its loading skeleton
  revealEnabled: boolean; // whether cells play the intro sweep when they mount
  store: HeatmapStore; // selection + intro state, stable for the chart's lifetime
};

const HeatmapChartContext = createContext<HeatmapChartContextValue | null>(null);

function useHeatmapChart() {
  const context = use(HeatmapChartContext);

  if (!context) {
    throw new Error(
      "Heatmap chart parts (<Cell />, <Tooltip />, <Legend />, …) must be used within <EvilHeatmapChart />",
    );
  }

  return context;
}

/** The current cell layout, derived from the live chart size. Only valid inside the Recharts tree. */
function useHeatmapLayout(): Layout {
  const width = useChartWidth() ?? 0;
  const height = useChartHeight() ?? 0;
  const { grid, fixedCellSize, hasDayLabels, hasMonthLabels } = useHeatmapChart();

  return useMemo(
    () => computeLayout(width, height, grid.weeks, fixedCellSize, hasDayLabels, hasMonthLabels),
    [width, height, grid.weeks, fixedCellSize, hasDayLabels, hasMonthLabels],
  );
}

/**
 * Calendar heatmap rendered with Recharts — the GitHub contribution graph shape:
 * one square per day, weeks as columns, color intensity from the config's color
 * ramp. The root owns the date window, the value → level mapping, selection
 * state, and the loading skeleton. The visual parts — `<Cell>`, `<MonthLabel>`,
 * `<DayLabel>`, `<Tooltip>`, `<Legend>` — are composed as children. Under the
 * hood the grid is a Recharts scatter on two numeric axes measured in cells, so
 * the cells stay square at any container size.
 */
export function EvilHeatmapChart({
  data,
  config,
  children,
  className,
  chartProps,
  range,
  weekStart = "sunday",
  cellSize,
  scale = "quantile",
  thresholds,
  defaultSelectedDate = null,
  onSelectionChange,
  isLoading = false,
  animation = true,
  animationType = "default",
}: EvilHeatmapChartProps) {
  const chartId = useId().replace(/:/g, ""); // colon-free id keeps CSS/SVG selectors valid
  const shouldReduceMotion = useReducedMotion();
  const [store] = useState(() => createHeatmapStore(defaultSelectedDate));

  const slots = useMemo(() => collectSlots(children), [children]);
  const { legend } = slots;
  const monthLabels = slots.monthLabel?.labels;
  const dayLabels = slots.dayLabel?.labels;
  // `children` is a new element tree on every parent render, so the <Cell />
  // slot is re-keyed on its values — a fresh object would ripple down into the
  // Recharts tree and remount every cell.
  const { dataKey, radius, gap, isClickable } = slots.cell;
  const cell = useMemo(
    () => ({ dataKey, radius, gap, isClickable }),
    [dataKey, radius, gap, isClickable],
  );

  const colorsCount = config[cell.dataKey] ? getColorsCount(config[cell.dataKey]) : 1;

  const grid = useMemo(
    () => buildGrid(data, resolveRange(range, data), weekStart, colorsCount, scale, thresholds),
    [data, range, weekStart, colorsCount, scale, thresholds],
  );

  useEffect(() => {
    store.setOnSelectionChange(onSelectionChange);
  }, [store, onSelectionChange]);

  // The intro is a mount-time animation on each cell. It plays when the first
  // real cells mount and again after each loading state; resizes and data
  // updates remount the Recharts symbols but skip it (see HeatmapStore).
  useEffect(() => {
    if (isLoading) store.armReveal();
  }, [store, isLoading]);

  const revealEnabled = animation && animationType !== "none" && !shouldReduceMotion;

  const contextValue = useMemo<HeatmapChartContextValue>(
    () => ({
      config,
      cell,
      grid,
      colorsCount,
      fixedCellSize: cellSize,
      hasDayLabels: dayLabels !== undefined,
      hasMonthLabels: monthLabels !== undefined,
      weekStart,
      isLoading,
      revealEnabled,
      store,
    }),
    [
      config,
      cell,
      grid,
      colorsCount,
      cellSize,
      dayLabels,
      monthLabels,
      weekStart,
      isLoading,
      revealEnabled,
      store,
    ],
  );

  const shimmerId = `${chartId}-heatmap-shimmer`;

  return (
    <HeatmapChartContext value={contextValue}>
      <ChartContainer
        className={className}
        config={config}
        footer={legend && !isLoading && <HeatmapLegend legend={legend} />}
      >
        <LoadingIndicator isLoading={isLoading} />
        <ScatterChart id={chartId} margin={CHART_MARGIN} {...chartProps}>
          <HeatmapPlot
            monthLabels={monthLabels}
            dayLabels={dayLabels}
            shimmerId={shimmerId}
            animateShimmer={!shouldReduceMotion}
          />
          {children}
        </ScatterChart>
      </ChartContainer>
    </HeatmapChartContext>
  );
}

type HeatmapPlotProps = {
  monthLabels: string[] | undefined; // twelve month names, or undefined to hide the axis
  dayLabels: string[] | undefined; // seven day names, Sunday first, or undefined to hide the axis
  shimmerId: string; // gradient id shared by the skeleton cells
  animateShimmer: boolean; // false parks the loading sweep (reduced motion)
};

/**
 * Everything drawn inside the Recharts tree: the axes, the cells, and the
 * loading gradient. The layout is measured once here and handed down. The
 * <Scatter> element is memoized on what actually moves the cells — Recharts
 * remounts every symbol when it gets new props — and the cells read their
 * selection status from the store, so a click never rebuilds the grid.
 */
const HeatmapPlot = memo(function HeatmapPlot({
  monthLabels,
  dayLabels,
  shimmerId,
  animateShimmer,
}: HeatmapPlotProps) {
  const { config, cell, grid, colorsCount, isLoading, revealEnabled, store } = useHeatmapChart();
  const layout = useHeatmapLayout();

  const gap = Math.min(cell.gap, layout.cellSize - 1);
  const size = Math.max(1, layout.cellSize - gap);
  const radius = Math.min(cell.radius, size / 2);
  const { dataKey, isClickable } = cell;
  const { cells, levels, range } = grid;
  const configLabel = config[dataKey]?.label;
  const seriesLabel = typeof configLabel === "string" ? configLabel : dataKey;

  // Cells are consecutive days, so a day's index is its offset from the range start.
  const navigate = useCallback(
    (date: string, key: string) => {
      const index = Math.round((parseIsoDate(date) - range.start) / DAY_MS);
      const target = keyTarget(key, index, cells.length);
      if (target === null) return undefined;
      return cells[Math.max(0, Math.min(cells.length - 1, target))].date;
    },
    [cells, range.start],
  );
  const lastDate = cells.at(-1)?.date;
  const dates = useMemo(() => new Set(cells.map((day) => day.date)), [cells]);
  const hasDate = useCallback((date: string) => dates.has(date), [dates]);

  const renderCell = useCallback(
    ({ cx, cy, payload }: ScatterShapeProps) => {
      const day = payload as GridCell | undefined;
      if (isLoading) {
        return <LoadingCell cx={cx} cy={cy} size={size} radius={radius} gradientId={shimmerId} />;
      }
      if (!day) return <g />;
      return (
        <HeatmapCell
          cx={cx}
          cy={cy}
          size={size}
          radius={radius}
          date={day.date}
          value={day.value}
          column={day.column}
          fill={
            day.level === 0
              ? undefined
              : `var(--color-${dataKey}-${rampIndex(day.level, levels, colorsCount)})`
          }
          isClickable={isClickable}
          isLast={day.date === lastDate}
          hasDate={hasDate}
          seriesLabel={seriesLabel}
          navigate={navigate}
          reveal={revealEnabled}
          store={store}
        />
      );
    },
    [
      isLoading,
      size,
      radius,
      shimmerId,
      dataKey,
      levels,
      colorsCount,
      isClickable,
      lastDate,
      hasDate,
      seriesLabel,
      navigate,
      revealEnabled,
      store,
    ],
  );

  const scatter = useMemo(
    () => <Scatter data={grid.cells} isAnimationActive={false} shape={renderCell} />,
    [grid.cells, renderCell],
  );

  return (
    <>
      <HeatmapAxes layout={layout} monthLabels={monthLabels} dayLabels={dayLabels} />
      {isLoading && <ShimmerGradient id={shimmerId} animated={animateShimmer} />}
      {scatter}
    </>
  );
});

type HeatmapAxesProps = {
  layout: Layout; // the measured cell layout
  monthLabels: string[] | undefined; // twelve month names, or undefined to hide the axis
  dayLabels: string[] | undefined; // seven day names, Sunday first, or undefined to hide the axis
};

/**
 * The two numeric axes that position the grid. Their domains are measured in
 * cells, so one unit along either axis is exactly one cell; the axes double as
 * the month labels (top) and weekday labels (left).
 */
function HeatmapAxes({ layout, monthLabels, dayLabels }: HeatmapAxesProps) {
  const { grid, weekStart } = useHeatmapChart();
  const firstDay = weekStart === "monday" ? 1 : 0;

  const monthByColumn = useMemo(
    () => new Map(grid.monthTicks.map((tick) => [tick.column, tick.month])),
    [grid.monthTicks],
  );
  const monthTicks = useMemo(() => grid.monthTicks.map((tick) => tick.column), [grid.monthTicks]);
  const dayTicks = useMemo(() => Array.from({ length: DAYS_PER_WEEK }, (_, row) => row), []);

  return (
    <>
      <XAxis
        type="number"
        dataKey="column"
        orientation="top"
        domain={layout.xDomain}
        allowDataOverflow
        allowDecimals
        ticks={monthTicks}
        interval={0}
        hide={monthLabels === undefined}
        height={MONTH_LABEL_HEIGHT}
        axisLine={false}
        tickLine={false}
        tickMargin={0}
        tick={(props: XAxisTickContentProps) => {
          const month = monthByColumn.get(Number(props.payload.value));
          if (month === undefined) return null;
          // The rows sit centered in the plot, so the labels follow the grid's
          // top edge instead of the axis line.
          return (
            <text
              x={Number(props.x) - layout.cellSize / 2}
              y={layout.gridTop - LABEL_MARGIN}
              textAnchor="start"
              className="fill-muted-foreground text-[11px]"
            >
              {monthLabels?.[month] ?? DEFAULT_MONTH_LABELS[month]}
            </text>
          );
        }}
      />
      <YAxis
        type="number"
        dataKey="row"
        reversed
        domain={layout.yDomain}
        allowDataOverflow
        allowDecimals
        ticks={dayTicks}
        interval={0}
        hide={dayLabels === undefined}
        width={DAY_LABEL_WIDTH}
        axisLine={false}
        tickLine={false}
        tickMargin={0}
        tick={(props: YAxisTickContentProps) => {
          const row = Number(props.payload.value);
          const label = dayLabels?.[(row + firstDay) % DAYS_PER_WEEK];
          if (!label) return null;
          return (
            <text
              x={Number(props.x)}
              y={Number(props.y)}
              textAnchor="end"
              dominantBaseline="central"
              className="fill-muted-foreground text-[10px]"
            >
              {label}
            </text>
          );
        }}
      />
    </>
  );
}

type CellGeometry = {
  cx: number | undefined; // pixel center of the cell along the columns axis
  cy: number | undefined; // pixel center of the cell along the rows axis
  size: number; // side of the painted square, gap already removed
  radius: number; // corner radius, clamped to the square
};

type HeatmapCellProps = CellGeometry & {
  date: string; // the day this cell stands for
  value: number; // that day's value
  column: number; // week column, drives the intro stagger
  fill: string | undefined; // ramp color, or undefined for an empty day
  isClickable: boolean; // lets the cell be selected by clicking it or from the keyboard
  isLast: boolean; // the most recent day — the default Tab stop when nothing is selected
  hasDate: (date: string) => boolean; // whether a day has a cell in the current grid
  seriesLabel: string; // series name read out with the value, e.g. "Contributions"
  navigate: (date: string, key: string) => string | undefined; // day a key press moves focus to
  reveal: boolean; // whether intro animations are enabled at all
  store: HeatmapStore; // selection + intro state
};

/**
 * Renders a single day. Memoized on primitive props, because Recharts re-runs
 * the shape function on every hover; the selection comes from the store, so
 * only the cells whose status flips re-render on a click.
 *
 * Clickable cells are toggle buttons behind a single Tab stop: arrow keys move
 * by day (up/down) and by week (left/right), Home/End jump to the range ends,
 * and Enter or Space toggles the focused day.
 */
const HeatmapCell = memo(function HeatmapCell({
  cx,
  cy,
  size,
  radius,
  date,
  value,
  column,
  fill,
  isClickable,
  isLast,
  hasDate,
  seriesLabel,
  navigate,
  reveal,
  store,
}: HeatmapCellProps) {
  const status = useCellStatus(store, date);
  const isTabStop = useIsTabStop(store, date, isLast, hasDate);
  // Read once at mount: the first real cells play the intro, later remounts don't.
  const [playIntro] = useState(() => reveal && store.isRevealPending());
  useEffect(() => {
    store.settleReveal();
  }, [store]);

  if (cx === undefined || cy === undefined) return null;

  const selected = status === "selected";

  const handleKeyDown = (event: KeyboardEvent<SVGRectElement>) => {
    if (event.key === "Enter" || event.key === " ") {
      event.preventDefault();
      store.toggle(date, value);
      return;
    }
    const target = navigate(date, event.key);
    if (target === undefined) return;
    // Keep the key from scrolling the page or reaching Recharts' own keyboard layer.
    event.preventDefault();
    event.stopPropagation();
    event.currentTarget.ownerSVGElement
      ?.querySelector<SVGRectElement>(`[data-date="${target}"]`)
      ?.focus();
  };

  return (
    <motion.rect
      x={cx - size / 2}
      y={cy - size / 2}
      width={size}
      height={size}
      rx={radius}
      ry={radius}
      className={cn(
        "fill-foreground/[0.08] transition-[fill-opacity,stroke] duration-200",
        selected ? "stroke-foreground/90" : "hover:stroke-foreground/60 stroke-transparent",
        isClickable && "focus-visible:stroke-foreground cursor-pointer outline-none",
      )}
      style={{ fill, originX: 0.5, originY: 0.5 }}
      fillOpacity={status === "dimmed" ? CELL_DIM_OPACITY : 1}
      strokeWidth={selected ? SELECTED_RING_WIDTH : HOVER_RING_WIDTH}
      initial={playIntro ? { opacity: 0, scale: INTRO_CELL_SCALE_FROM } : false}
      animate={{ opacity: 1, scale: 1 }}
      transition={{
        delay: column * INTRO_COLUMN_STAGGER,
        duration: INTRO_CELL_GROW,
        ease: "easeOut",
      }}
      onClick={isClickable ? () => store.toggle(date, value) : undefined}
      {...(isClickable && {
        "data-date": date,
        role: "button",
        "aria-pressed": selected,
        "aria-label": `${defaultDateFormatter(date)}: ${defaultValueFormatter(value)} ${seriesLabel}`,
        tabIndex: isTabStop ? 0 : -1,
        onKeyDown: handleKeyDown,
        onFocus: () => store.setTabStop(date),
      })}
    />
  );
});

type LoadingCellProps = CellGeometry & {
  gradientId: string; // the shimmer gradient shared by every skeleton cell
};

/** A skeleton cell — the same footprint as a real one, painted by the shared shimmer. */
const LoadingCell = memo(function LoadingCell({
  cx,
  cy,
  size,
  radius,
  gradientId,
}: LoadingCellProps) {
  if (cx === undefined || cy === undefined) return null;

  return (
    <rect
      x={cx - size / 2}
      y={cy - size / 2}
      width={size}
      height={size}
      rx={radius}
      ry={radius}
      fill={`url(#${gradientId})`}
      pointerEvents="none"
    />
  );
});

type ShimmerGradientProps = {
  id: string; // gradient id referenced by the skeleton cells
  animated: boolean; // false leaves the band parked in the middle (reduced motion)
};

/**
 * One gradient shared by every skeleton cell, in chart pixel space so a whole
 * column lights up together as the band passes. The sweep is a native SVG
 * animation, so it never touches React while it loops.
 */
function ShimmerGradient({ id, animated }: ShimmerGradientProps) {
  const width = useChartWidth() ?? 0;
  const bandStart = 0.5 - LOADING_SHIMMER_BAND;
  const bandEnd = 0.5 + LOADING_SHIMMER_BAND;

  return (
    <defs>
      <linearGradient
        id={id}
        gradientUnits="userSpaceOnUse"
        x1={0}
        y1={0}
        x2={width}
        y2={0}
        gradientTransform="skewX(-20)"
      >
        <stop offset={0} stopColor="currentColor" stopOpacity={LOADING_CELL_FLOOR} />
        <stop offset={bandStart} stopColor="currentColor" stopOpacity={LOADING_CELL_FLOOR} />
        <stop offset={0.5} stopColor="currentColor" stopOpacity={LOADING_CELL_PEAK} />
        <stop offset={bandEnd} stopColor="currentColor" stopOpacity={LOADING_CELL_FLOOR} />
        <stop offset={1} stopColor="currentColor" stopOpacity={LOADING_CELL_FLOOR} />
        {animated && width > 0 && (
          <animateTransform
            attributeName="gradientTransform"
            type="translate"
            additive="sum"
            from={`${-width} 0`}
            to={`${width} 0`}
            dur={`${LOADING_ANIMATION_DURATION}ms`}
            repeatCount="indefinite"
          />
        )}
      </linearGradient>
    </defs>
  );
}

export interface TooltipProps {
  variant?: TooltipVariant; // visual style of the tooltip surface
  roundness?: TooltipRoundness; // border-radius of the tooltip
  position?: TooltipPosition; // "variable" follows the hovered cell (default); "fixed" pins the tooltip to the top and tracks the cell's X
  dateFormatter?: (date: string) => string; // formats the hovered day for the tooltip title
  valueFormatter?: (value: number) => string; // formats the hovered day's value
}

/**
 * The hover tooltip. Reads the chart's loading state from context and is hidden
 * automatically while the chart shows its skeleton.
 */
function Tooltip({
  variant = "default",
  roundness = "lg",
  position = "variable",
  dateFormatter = defaultDateFormatter,
  valueFormatter = defaultValueFormatter,
}: TooltipProps) {
  const { isLoading } = useHeatmapChart();

  if (isLoading) return null;

  return (
    <ChartTooltip
      cursor={false}
      position={position === "fixed" ? { y: 0 } : undefined}
      content={
        <HeatmapTooltipContent
          variant={variant}
          roundness={roundness}
          dateFormatter={dateFormatter}
          valueFormatter={valueFormatter}
        />
      }
    />
  );
}

type HeatmapTooltipContentProps = {
  active?: boolean;
  payload?: ReadonlyArray<{ payload?: unknown }>;
  variant: TooltipVariant;
  roundness: TooltipRoundness;
  dateFormatter: (date: string) => string;
  valueFormatter: (value: number) => string;
};

/**
 * Tooltip body for a hovered day: the formatted date as the title, then the
 * series label and value beside a swatch of that day's ramp color. A scatter's
 * default payload is one entry per axis, so this reads the day off the first
 * entry instead of listing the axes.
 */
function HeatmapTooltipContent({
  active,
  payload,
  variant,
  roundness,
  dateFormatter,
  valueFormatter,
}: HeatmapTooltipContentProps) {
  const { config, cell, grid, colorsCount } = useHeatmapChart();
  const item = payload?.[0]?.payload as GridCell | undefined;

  if (!active || !item) {
    // Empty tooltip — keeps the position from snapping back to 0,0 between hovers
    return <span className="p-4" />;
  }

  const label = config[cell.dataKey]?.label ?? cell.dataKey;
  const swatch =
    item.level === 0
      ? undefined
      : `var(--color-${cell.dataKey}-${rampIndex(item.level, grid.levels, colorsCount)})`;

  return (
    <div
      className={cn(
        "border-border/50 grid min-w-32 items-start gap-1.5 border px-2.5 py-1.5 text-xs shadow-xl",
        roundnessMap[roundness],
        variantMap[variant],
      )}
    >
      <div className="font-medium">{dateFormatter(item.date)}</div>
      <div className="flex w-full items-center gap-2">
        <div
          className="bg-foreground/10 h-2.5 w-2.5 shrink-0 rounded-[2px]"
          style={{ background: swatch }}
        />
        <div className="flex flex-1 items-center justify-between gap-4 leading-none">
          <span className="text-muted-foreground">{label}</span>
          <span className="text-foreground font-mono font-medium tabular-nums">
            {valueFormatter(item.value)}
          </span>
        </div>
      </div>
    </div>
  );
}

/** The "Less → More" ramp under the grid: one swatch per level, plus the empty cell. */
function HeatmapLegend({ legend }: { legend: LegendSlot }) {
  const { cell, colorsCount } = useHeatmapChart();
  const swatchStyle = { borderRadius: Math.min(cell.radius, 5) };

  return (
    <div
      className={cn(
        "text-muted-foreground flex shrink-0 items-center gap-1 pt-2 text-[11px] leading-none",
        legend.align === "left" ? "justify-start" : "justify-end",
      )}
    >
      <span className="mr-0.5">{legend.lessLabel}</span>
      <span className="bg-foreground/[0.08] h-2.5 w-2.5 shrink-0" style={swatchStyle} />
      {Array.from({ length: colorsCount }, (_, index) => (
        <span
          key={index}
          className="h-2.5 w-2.5 shrink-0"
          style={{ ...swatchStyle, background: `var(--color-${cell.dataKey}-${index})` }}
        />
      ))}
      <span className="ml-0.5">{legend.moreLabel}</span>
    </div>
  );
}

EvilHeatmapChart.Cell = Cell;
EvilHeatmapChart.MonthLabel = MonthLabel;
EvilHeatmapChart.DayLabel = DayLabel;
EvilHeatmapChart.Tooltip = Tooltip;
EvilHeatmapChart.Legend = Legend;

Versions

  1. 1.0.0 · current

    Oct 30, 2026

    First release.

Similar components