A GitHub-style calendar: a hundred and eighty days as small coloured squares, seven rows deep, one column per week, darker where sales were higher. You have D3 installed and a component that already draws charts with it. This one uses none of it — no SVG, no scales, no join. It is a CSS grid, and it is here directly after the D3 lesson to make the comparison unavoidable. Checkpoint: ui-08.

What a calendar layout actually is

Seven rows, one per weekday. One column per week. Each day lands in the row for its weekday and the column for its week — which is a two-dimensional grid with fixed row count and a column count that follows the data. CSS has an operator for exactly that:

.calendar {
    display: grid;
    grid-template-rows: repeat(7, 1fr);
    grid-auto-flow: column;
    gap: 2px;
}

grid-auto-flow: column fills top to bottom, then starts a new column. Emit cells in date order with a few leading blanks so the first day lands on its correct weekday, and the calendar assembles itself. No positioning maths, no week-number arithmetic, no per-cell coordinates.

Compare with the D3 version of the same picture: an ordinal scale for weekdays, another for weeks, a join producing <rect> elements, x/y/width/height on each, and a colour scale. All of it correct, and all of it re-implementing what the layout engine does natively.

Intensity as a custom property

Each cell carries one number — how strong its colour should be — and passes it to CSS as a variable:

html`<div class="cell" style="--v: ${v.toFixed(2)}" title=${label}></div>`
.cell {
    background: color-mix(in oklab, var(--accent) calc(var(--v) * 100%), var(--muted));
}

color-mix() blends the accent with the muted background in a proportion driven by --v, so the colour ramp is one declaration and automatically follows the theme. Change the accent — which lesson 11 does with a slider — and every cell recolours, in light mode and dark, with nothing recalculated in JavaScript.

in oklab matters for a ramp. Mixing in sRGB passes through muddy, desaturated middles; oklab is perceptually uniform, so equal steps in --v look like equal steps in intensity. That is the same reason the tokens in lesson 11 are oklch.

The scaling is deliberate too. Values are normalised against the maximum in the current range and floored around 0.5 so a low day is still visible against the background rather than disappearing into it — a heatmap where the bottom half of the range is invisible communicates less than a table.

What you get for free

Because these are DOM elements rather than SVG shapes:

  • title gives a native tooltip with no library and no positioning code
  • each cell is a real element for hit-testing, so hover and focus styles are ordinary CSS
  • the grid reflows if the container narrows, without a redraw
  • there is no measure-then-draw step, so no resize observer and no flash of a wrongly-sized chart

The component still uses SignalWatcher and reads timeSeries from the store like every other panel, and it still carries a sql-peek and an info-tip. What it does not carry is a rendering library.

The line worth drawing

Both approaches were available; the useful part is the rule for choosing.

D3 earns its place when you need scales, axes, paths, or interpolated transitions — continuous data mapped into pixel space, where the mapping is the hard part. The time series is exactly that: a domain of dates, a range of pixels, an area path, and a smooth transition when the data changes.

CSS wins when the layout is a layout and the colour is a colour. A grid of squares tinted by one value has no scales worth the name. Reaching for a charting library here means shipping weight, learning an API, and giving up native tooltips and reflow to reimplement them.

Most dashboards are heavier than they need to be because the first chart justified the dependency and everything after it inherited the decision. It is worth asking, per visualisation, whether the library is doing anything the platform will not.

Build it

File Action What goes in it
src/components/calendar-heatmap.ts write The grid, leading blanks for the first weekday, --v per cell, color-mix ramp, legend, tooltips, skeleton state
index.html modify <calendar-heatmap> between the time series and the bar chart
src/index.ts modify Import the component
src/style.css modify Add the heatmap section to the panel rule

Done when: the heatmap renders 180 cells whose intensities respond to filtering, and document.querySelector('calendar-heatmap').shadowRoot .querySelectorAll('svg').length is 0.

Answer key: ui-07...ui-08.

Challenge: build the D3 version and compare

Implement the same heatmap with D3 — scaleBand for both axes, a join of <rect> elements, scaleSequential for colour. Get it working, then diff the two components. Count lines, count dependencies, and check what happens to each when you narrow the browser window or hover a cell. Whichever you prefer, you will have an argument for it rather than a habit.

Next lesson: the largest component in the app, and the accessibility work a data table actually needs.