HeatmapChart

Contribution grid with a themed colour ramp and a readout.

A calendar of bins shaded by how much happened in each — one column per period, one row per bin inside it. It answers “when was this busy” at a glance, which no line can: a year of daily numbers plotted as a series is a hairball, and as a grid it is a pattern.

Installation

HeatmapChart ships with the library — no separate install.

import { HeatmapChart, buildHeatmapCalendar, Frame, Text } from 'panelui-native';

Or copy the source into your project, to own and edit it:

npx panelui-cli@latest add heatmap-chart

Usage

<HeatmapChart data={weeks} weekStartDay={1}>
  <HeatmapChart.Header title="Contributions" value="1,204" legend />
  <HeatmapChart.XAxis />
  <HeatmapChart.YAxis />
  <HeatmapChart.Cells />
  <HeatmapChart.Tooltip />
</HeatmapChart>

Composition

<HeatmapChart>
  <HeatmapChart.Header />     {/* the strip above the grid */}
  <HeatmapChart.XAxis />      {/* month labels, above the grid */}
  <HeatmapChart.YAxis />      {/* weekday labels, beside it */}
  <HeatmapChart.Separator />  {/* rules grouping the columns */}
  <HeatmapChart.Cells />      {/* the grid itself */}
  <HeatmapChart.Tooltip />    {/* the readout under the finger */}
  <HeatmapChart.Legend />     {/* the Less → More key, below */}
</HeatmapChart>
  • HeatmapChart.Header — The strip above the grid — what the chart is of, what it reads, and the ramp as a key. The place to put the key on a grid that scrolls sideways, where Legend would scroll away with the cells.
  • HeatmapChart.Cells — The grid. Every row of every column is drawn, including the empty ones.
  • HeatmapChart.Separator — Vertical rules grouping the columns — quarters, months, sprints.
  • HeatmapChart.XAxis — Month labels above the grid, emitted where the month changes. Pass labels for columns that are not weeks.
  • HeatmapChart.YAxis — Row labels beside the grid. Weekdays by default; pass labels for anything else.
  • HeatmapChart.Tooltip — The readout that follows the finger across the grid.
  • HeatmapChart.Legend — The Less ▢▢▢▢▢ More key, under the grid.

Examples

Building the calendar

Data arrives as dates and numbers; buildHeatmapCalendar does the bucketing — including the two parts that are easy to get wrong. It backs up to the first day of the week the range starts in, so every row lines up with a weekday for the rest of the chart, and it emits a cell for every day in the range whether or not there was an entry, because a calendar with holes in it stops being a calendar.

const weeks = buildHeatmapCalendar(commits, {
  start: new Date(2025, 6, 23),
  end: new Date(2026, 6, 23),
  weekStartDay: 1,
});

<HeatmapChart data={weeks} weekStartDay={1}>
  <HeatmapChart.Cells />
</HeatmapChart>

Scrolling a full year

Fifty-three weeks do not fit across a phone. fluid draws the cells at binSize and lets the grid be as wide as it needs to be, so a horizontal ScrollView is all it takes. The legend belongs at the card's width rather than the grid's, so give it a chart of its own outside the scroller. The readout inside waits for a held press before it takes the touch, so the swipe still scrolls.

<ScrollView horizontal showsHorizontalScrollIndicator={false}>
  <HeatmapChart data={weeks} binSize={13} weekStartDay={1}>
    <HeatmapChart.XAxis />
    <HeatmapChart.YAxis />
    <HeatmapChart.Cells />
    <HeatmapChart.Tooltip />
  </HeatmapChart>
</ScrollView>

<HeatmapChart data={[]}>
  <HeatmapChart.Legend swatchSize={12} />
</HeatmapChart>

Filling the width instead

For a short range — a quarter, a sprint — fill divides the available width between the columns rather than drawing them at a fixed size, so the chart always meets both edges.

HeatmapChart — Filling the width instead.
<HeatmapChart data={weeks.slice(-13)} layout="fill" gap={4}>
  <HeatmapChart.XAxis />
  <HeatmapChart.YAxis />
  <HeatmapChart.Cells cornerRadius={3} />
  <HeatmapChart.Tooltip />
  <HeatmapChart.Legend />
</HeatmapChart>

Changing the colour

Name a theme token and the ramp follows it through light and dark. emptyColor is the cell with nothing in it, and levelOpacity retunes the contrast between the four busy levels without having to name five colours. levelColors is still there for a ramp that is not one colour at all.

{/* A token, so the ramp still follows the theme. */}
<HeatmapChart data={weeks} color="--color-chart-3" />

{/* A literal works too, for a colour that is not in the theme. */}
<HeatmapChart data={weeks} color="#2dd4bf" emptyColor="--color-surface-secondary" />

{/* Flatter contrast between the busy levels. */}
<HeatmapChart data={weeks} levelOpacity={[1, 0.45, 0.62, 0.8, 1]} />

{/* Five colours outright, when the ramp is not one colour fading. */}
<HeatmapChart
  data={weeks}
  levelColors={['#1c1c1c', '#0e4429', '#006d32', '#26a641', '#39d353']}
/>

A grid whose rows are not days

Seven rows is a calendar week, but the grid does not care. Twenty-four gives a day of hours, with the columns as dates — the cells, the ramp and the readout all work the same way.

<HeatmapChart data={hours} rows={24} binSize={9}>
  <HeatmapChart.Cells />
  <HeatmapChart.Tooltip formatLabel={(cell) => `${cell.count} requests`} />
</HeatmapChart>

Reading the active cell above the chart

useHeatmapChart only reaches children of the chart. A readout in the card's header is outside that subtree, so it takes onActiveCellChange instead — which fires when the cell changes, not once per frame.

const [active, setActive] = useState(null);

<Card.Header>
  <Card.Description>
    {active ? `${active.count} on ${active.date?.toDateString()}` : 'Drag to read a day.'}
  </Card.Description>
</Card.Header>

<HeatmapChart data={weeks} onActiveCellChange={setActive}>
  <HeatmapChart.Cells />
  <HeatmapChart.Tooltip />
</HeatmapChart>

Labelling columns that are not weeks

The x-axis emits a label where the month changes, which a grid of weekdays never does — there is no month in it to change. labels overrides them with one per column, the way YAxis already takes one per row, so a grid can be rows of hours by columns of days.

<HeatmapChart data={week} layout="fill" rows={4} gap={4}>
  <HeatmapChart.Header title="Tickets opened" legend />
  <HeatmapChart.XAxis labels={['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']} />
  <HeatmapChart.YAxis labels={['00', '06', '12', '18']} tickFilter="all" width={24} />
  <HeatmapChart.Cells cornerRadius={3} />
  <HeatmapChart.Tooltip />
</HeatmapChart>

Versions

Contribution grid

A full year with a readout that follows the finger. Fifty-three weeks do not fit across a phone, so the chart is fluid inside a horizontal ScrollView — and the header sits outside that scroller, at the frame's width, or it would scroll away with the cells. Press and hold to read a day; a swipe scrolls the chart instead.

<HeatmapChart data={[]}>
  <HeatmapChart.Header value={active ? `${active.count}` : total} caption={caption} legend />
</HeatmapChart>

<ScrollView horizontal showsHorizontalScrollIndicator={false}>
  <HeatmapChart data={weeks} weekStartDay={1} binSize={13} onActiveCellChange={setActive}>
    <HeatmapChart.XAxis />
    <HeatmapChart.YAxis />
    <HeatmapChart.Cells />
    <HeatmapChart.Tooltip />
  </HeatmapChart>
</ScrollView>

Filling the width

For a short range, fill divides the available width between the columns rather than drawing them at a fixed size, so the chart always meets both edges of its card. No chart header here — the card's own already says what this is, and a second title under the first says it twice.

HeatmapChart — Filling the width.
<HeatmapChart data={weeks.slice(-13)} layout="fill" weekStartDay={1} gap={4}>
  <HeatmapChart.XAxis />
  <HeatmapChart.YAxis />
  <HeatmapChart.Cells cornerRadius={3} />
  <HeatmapChart.Tooltip />
  <HeatmapChart.Legend />
</HeatmapChart>

Quarters

A rule every thirteen columns, and the ramp derived from a colour of your own instead of the chart token. tickFilter="all" labels every row, which a narrower axis has room for once the labels are initials.

<HeatmapChart data={weeks} weekStartDay={1} binSize={11} color={success}>
  <HeatmapChart.XAxis />
  <HeatmapChart.YAxis tickFilter="all" labelFormat="initial" width={16} />
  <HeatmapChart.Separator every="quarter" dashArray="2,3" />
  <HeatmapChart.Cells />
  <HeatmapChart.Tooltip />
</HeatmapChart>

Punchcard

Rows that are hours and columns that are weekdays. “When is this busy” is a question the year grid cannot answer, having already spent its rows on the days of the week — rows and a pair of labels turn the same grid on its side. Four six-hour bins: fill sizes a cell off the width, so with seven columns every extra row costs a lot of height.

<HeatmapChart data={week} layout="fill" rows={4} gap={4} onActiveCellChange={setActive}>
  <HeatmapChart.Header title="Tickets opened" value="06:00 – 18:00" legend />
  <HeatmapChart.XAxis labels={weekdays} />
  <HeatmapChart.YAxis labels={hours} tickFilter="all" width={24} />
  <HeatmapChart.Cells cornerRadius={3} />
  <HeatmapChart.Tooltip
    formatLabel={(cell) => `${cell.count} · ${weekdays[cell.column]} ${hours[cell.row]}:00`}
  />
</HeatmapChart>

API Reference

HeatmapChart

PropTypeDefaultDescription
classNamestring
dataHeatmapColumn[]One column per period, with its row bins inside.
layoutHeatmapLayout'fluid'fluid draws cells at binSize and lets the grid be as wide as it needs to be — put it in a horizontal ScrollView for a full year. fill divides the available width between the columns instead.
binSizenumber12Side of one cell in fluid layout, in pixels.
gapnumber3Space between cells, in pixels.
cornerRadiusnumber2Corner radius of a cell.
weekStartDaynumber0Which weekday is the top row. 0 is Sunday. Labels follow it.
rowsnumberDAYS_IN_WEEKRows per column. Seven for a calendar; use another number when the bins are not weekdays — twenty-four for a grid of hours.
levelsnumber[]The four counts at which the ramp steps up. Derived from the data's own quartiles when omitted, so a chart of single digits and a chart of thousands both use the whole ramp.
levelColorsstring[]Five colours — empty, then the four activity levels. Replaces the derived ramp outright. Omit it and the ramp is --color-chart-1 at five opacities, which follows the theme.
colorstringBase colour for the derived ramp — the colour the busiest cells are drawn in, with the quieter levels the same colour at lower opacity. Takes a theme token by name as well as a literal, so "--color-chart-3" recolours the chart and keeps following the theme through light and dark. Defaults to --color-chart-1.
emptyColorstringColour of a cell with nothing in it. Takes a token name too. Defaults to --color-muted, which is the right weight for "measured, and empty" — override it for a chart that should read as denser or fainter than that.
levelOpacitynumber[]Opacity of the base colour at each of the five levels, quietest first. The way to retune the ramp's contrast without having to name five colours. Ignored when levelColors is given, which sets the colours outright.
animationDurationnumber900Milliseconds for the reveal on mount.
inactiveOpacitynumber1Opacity of every cell that is not the one under the finger.
onActiveCellChange(cell: HeatmapCell | null) => voidThe cell under the finger as it moves, and null when it lifts. This is how a readout above the chart gets its value — that readout is outside the chart, so it cannot use useHeatmapChart.

HeatmapChart.HeatmapCells

PropTypeDefaultDescription
cornerRadiusnumber2Corner radius of a cell. Falls back to the chart's.

HeatmapChart.HeatmapSeparator

PropTypeDefaultDescription
every'quarter' | numberquarter draws a rule every thirteen columns; a number draws one every that many columns.
colorstring
dashArraystringDash pattern, e.g. "2,4". Omit for a solid rule.

HeatmapChart.HeatmapXAxis

PropTypeDefaultDescription
classNamestring
formatLabel(date: Date, column: number) => stringLabel a column. Given the first dated bin in it, so a month name can be derived. Return an empty string to leave the column unlabelled.
labelsstring[]Column labels, left to right. Overrides the month names — for a grid whose columns are not weeks, where there is no month to change and so nothing to emit a label on.

HeatmapChart.HeatmapYAxis

PropTypeDefaultDescription
classNamestring
widthnumberDEFAULT_AXIS_WIDTHWidth reserved for the labels. The grid is sized around it.
tickFilter'all' | 'odd' | 'even''odd'Which rows get a label. Every other row is the usual choice.
labelFormat'initial' | 'full''full'initial is the single letter; full is the abbreviated name.
labelsstring[]Row labels, top to bottom. Overrides the weekday names — for a grid whose rows are not days.

HeatmapChart.HeatmapTooltip

PropTypeDefaultDescription
classNamestring
formatLabel(cell: HeatmapCell) => stringThe line shown for a cell. Defaults to the count and the date.
activateAfterLongPressnumberDEFAULT_HOLDHow long a press has to be held before the readout takes over, in milliseconds. It is not zero, and cannot be: a full year of columns lives inside a horizontal scroller, and a readout that claims the touch on the first pixel of movement means the chart can never be scrolled. Holding first is what separates "I am moving the chart" from "I am reading it". Set 0 only for a chart that is not inside a scroll view at all.

HeatmapChart.HeatmapLegend

PropTypeDefaultDescription
classNamestring
lessLabelstring'Less'Text at the low end of the ramp.
moreLabelstring'More'Text at the high end.
swatchSizenumberSide of a swatch, in pixels. Defaults to the chart's cell size.

HeatmapChart.HeatmapHeader

PropTypeDefaultDescription
classNamestring
titlestringSmall line above the value — what the grid is of.
valuestringThe readout. The largest thing on the card, and the first thing read.
captionstringOne muted line under the value — a period, a total, the held cell.
legendbooleanfalseDraw the ramp along the trailing edge, Less ▢▢▢▢▢ More. The key for a grid that scrolls sideways, where HeatmapChart.Legend under the cells would scroll away with them.
lessLabelstring'Less'Text at the low end of the ramp, when legend is set.
moreLabelstring'More'Text at the high end.

Every part also accepts the underlying React Native props (ViewProps or TextProps) and a className for Tailwind utilities.

Notes

The colour ramp

One colour at five opacities, not five colours. A heatmap reads as more and less of one thing, and five distinct hues read as five different things — which is what the --color-chart-* tokens are for, and why only the first of them is used here. The base is --color-chart-1, so the ramp follows the active theme.

There are four ways to change it, in rough order of how often you want them:

  • color swaps the base. It takes a token by name — color="--color-chart-3" — as readily as a literal, and naming the token is almost always what you meant: a literal is a colour frozen at the moment it was written, and it cannot follow the theme into dark mode.
  • emptyColor is the cell with nothing in it, --color-muted by default. Take it lighter or darker when "measured, and empty" should read differently from the surface around it.
  • levelOpacity retunes the contrast between the five steps without naming five colours.
  • levelColors replaces all five outright, for a ramp that is not one colour fading. The opacities are dropped then, since dimming a colour someone chose on purpose is not a ramp.

The four thresholds are the data's own quartiles by default, so a chart of single-digit counts and a chart of thousands both use the whole ramp. Zero is its own level and is left out of the quartiles — counting it drags every threshold to nothing on a sparse chart. Pass levels to fix them yourself.

Reading a grid that is not a calendar

The tooltip's default label names contributions on a date, because that is what the cells usually are. A grid built from bins with no date gets the count on its own — it is not a calendar, so there is nothing to say after the number, and it is emphatically not "no data". Pass formatLabel to name what the cells actually count.

Where the key goes

A grid that scrolls sideways has two places for its ramp and only one of them works. Legend sits under the cells, inside the scroller, and slides out of view with them; Header legend sits above the grid at the chart's own width and stays put. On a grid that fits, either is fine.

Layout

The parts sit in a real layout rather than stacking over the plot: the labels are beside and above the grid and the legend is below it, so they take up room and the grid is sized with them accounted for. Only the cells and the rules are SVG — every label is a React Native view, because SVG text ignores the platform's text scaling and the theme's font.

Reading a cell without hijacking the scroll

HeatmapChart.Tooltip waits for the press to be held before it claims the touch. It has to: a full year of columns lives inside a horizontal scroller, and a readout that takes over on the first pixel of movement means the chart can never be scrolled at all. Holding first is what separates “I am moving the chart” from “I am reading it”, and dragging from there moves the readout as usual.

activateAfterLongPress={0} gives the touch back immediately, for a chart that is not inside a scroll view and so has no scroll to protect.

The cell is resolved on the UI thread and only crosses into JS when it changes, so a drag across a year costs a handful of re-renders rather than one per frame.

The reveal

Columns arrive left to right on mount, drawn by one clip rectangle wiping across the grid rather than by an animation per column. The effect is the same and it costs one animated value instead of fifty-two. It plays once; a data change redraws without replaying it, because repeating it turns a refresh into an animation. useReducedMotion skips it.

On this page