Mischief

136 / Charts

Heat Calendar

A year of daily counts laid out as weeks, with a tooltip on every day and arrow keys that move through them.

1,532 commits in the last year

Hover a day, or tab in and use the arrow keys.

Sunday
Monday
Tuesday
Wednesday
Thursday
Friday
Saturday

Installation

Copy the source into your project, or keep it behind a package.

npx shadcn@latest add Tinkerers-Labs/mischief-ui/heat-calendar
import { HeatCalendar } from "mischief-ui/heat-calendar"

Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.

registry/default/heat-calendar/heat-calendar.tsx
"use client" import * as React from "react" import { cn } from "@/lib/utils" export type HeatCalendarDay = {  /** A calendar day as YYYY-MM-DD, read in UTC. */  date: string  count: number} export type HeatCalendarProps = Omit<  React.HTMLAttributes<HTMLDivElement>,

Usage

export function Commits({ days }: { days: HeatCalendarDay[] }) {
  return (
    <HeatCalendar
      data={days}
      label="Commits per day"
      unit={{ one: "commit", other: "commits" }}
    />
  )
}

Dates are days, read in UTC

Each entry is a calendar day written as YYYY-MM-DD and a count. Days are read and named in UTC, so a day never shifts by one because the reader's clock is in another time zone. Bucket your events into days on the server, in whatever time zone your product counts in, and pass the result. Two entries for the same day are added together.

<HeatCalendar
  data={[
    { date: "2026-03-02", count: 4 },
    { date: "2026-03-03", count: 12 },
  ]}
  start="2026-01-01"
  end="2026-03-31"
/>
Without start and end it shows the 52 weeks up to the latest day in the data.

Week start and names

Month and weekday names come from Intl in the given locale, and so does the first day of the week where the browser reports it. weekStart overrides it, with 0 for Sunday. unit names what is being counted, in its singular and plural forms, for the cell names and the tooltip.

How the shades are chosen

A day with nothing is drawn in the muted colour. Every other day falls into one of four steps by its share of the busiest day in range, each step the chart colour mixed further into the background. Because the mix is with the background, the scale works in a dark theme without a second palette.

On a narrow screen

The weeks scroll sideways while the weekday labels stay put, and the scroll starts at the newest week, since that is usually the one being checked.

API

dataHeatCalendarDay[]Daily counts as { date, count }, with date as YYYY-MM-DD.
date, count (HeatCalendarDay)string, numberOne calendar day in UTC and its count.
startstringFirst day shown. Defaults to 52 weeks before the end.
endstringLast day shown. Defaults to the latest day in the data, or today.
weekStartnumberFirst row, 0 for Sunday. Defaults to the locale's first day of the week.
localestringThe locale for dates, weekdays, and numbers.
unit{ one: string; other: string }What is counted. Defaults to contribution and contributions.
labelstringThe accessible name of the grid. Defaults to Activity.
colorstringAny CSS colour. Defaults to var(--chart-1), then var(--primary).
classNamestringClasses for placement.
...divPropsHTMLAttributesNative div attributes.

Accessibility

The calendar is a grid with one row per weekday, each row headed by the weekday's full name. Only one day is a tab stop. The up and down arrows move by a day, left and right by a week, Home and End to the first and last week on that weekday, and Ctrl+Home and Ctrl+End to the first and last day. Every day is named in full, such as 12 contributions on Tuesday, March 3, 2026, so the shade is never the only way to read it. The tooltip repeats that for pointer and keyboard users and closes on Escape. Month labels and the legend are decoration and hidden from assistive technology.