Color
Theme-aware intensity ramp
A five-step intensity ramp mixed against the page ground rather than white or black, so "stronger" always means "further from the page" and the scale inverts correctly between light and dark from one expression.
- color
- color-mix
- data-display
- theming
- grid
<div class="tair-panel">
<div class="tair-grid" id="tair-grid" aria-label="Sample weekly activity grid"></div>
<div class="tair-legend">
<span>Less</span>
<span class="tair-cell tair-level-0"></span>
<span class="tair-cell tair-level-1"></span>
<span class="tair-cell tair-level-2"></span>
<span class="tair-cell tair-level-3"></span>
<span class="tair-cell tair-level-4"></span>
<span>More</span>
</div>
</div>Colors come from shared theme tokens — --surface, --ink, --border, --accent and friends — so this CSS carries no palette
of its own. Use Runnable file to copy the tokens along with it.
.tair-panel { display: flex; flex-direction: column; gap: 0.25rem; }
.tair-grid {
display: grid;
grid-auto-flow: column;
grid-template-rows: repeat(7, 15px);
/* Implicit column tracks size to auto and would stretch to fill the
container, spreading the weeks apart. Pin them to the cell width and
pack the whole grid to the start instead. */
grid-auto-columns: 15px;
justify-content: start;
gap: 3px;
}
.tair-cell {
width: 15px;
height: 15px;
border-radius: 3px;
}
/* Level 0 mixes a neutral into the ground, never the palest step of the
accent hue, so "nothing happened" cannot be mistaken for "a little
happened". Levels 1-4 mix the accent at increasing strength; level 4 is
the accent at full strength with no mix at all. */
.tair-level-0 { background: color-mix(in srgb, var(--dim) 10%, var(--ground)); }
.tair-level-1 { background: color-mix(in srgb, var(--accent) 34%, var(--ground)); }
.tair-level-2 { background: color-mix(in srgb, var(--accent) 56%, var(--ground)); }
.tair-level-3 { background: color-mix(in srgb, var(--accent) 78%, var(--ground)); }
.tair-level-4 { background: var(--accent); }
.tair-legend {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.75rem;
font-size: 0.9375rem;
color: var(--dim);
}(function () {
var WEEK_COUNT = 19;
var DAY_COUNT = 7;
var SEED = 20260810;
// mulberry32 — deterministic and dependency-free, so the sample grid is
// identical on every reload instead of reshuffling like Math.random would.
function createSeededRandom(seed) {
var state = seed >>> 0;
return function () {
state = (state + 0x6d2b79f5) >>> 0;
var mixed = state;
mixed = Math.imul(mixed ^ (mixed >>> 15), mixed | 1);
mixed ^= mixed + Math.imul(mixed ^ (mixed >>> 7), mixed | 61);
return ((mixed ^ (mixed >>> 14)) >>> 0) / 4294967296;
};
}
function levelForCount(count) {
if (count === 0) return 0;
if (count <= 2) return 1;
if (count <= 4) return 2;
if (count <= 6) return 3;
return 4;
}
var randomValue = createSeededRandom(SEED);
var grid = document.getElementById('tair-grid');
var fragment = document.createDocumentFragment();
for (var week = 0; week < WEEK_COUNT; week += 1) {
for (var day = 0; day < DAY_COUNT; day += 1) {
var isWeekend = day === 5 || day === 6;
var weight = isWeekend ? 0.35 : 1;
var count = randomValue() < 0.22 ? 0 : Math.max(0, Math.round(randomValue() * 9 * weight));
var level = levelForCount(count);
var cell = document.createElement('span');
cell.className = 'tair-cell tair-level-' + level;
var entryWord = count === 1 ? 'minute' : 'minutes';
cell.title = count + ' ' + entryWord;
cell.setAttribute('aria-label', count + ' ' + entryWord);
fragment.appendChild(cell);
}
}
grid.appendChild(fragment);
})();Paste this into an agent to rebuild the pattern from scratch.
Build a five-step intensity ramp for a contribution-style grid — a cell per day, colored by how much happened that day — that inverts correctly between light and dark themes from a single CSS expression, with no media query and no theme read in JavaScript.
Reach for this any time a grid or heatmap encodes a scalar as color intensity across a page that supports both themes: activity calendars, usage heatmaps, density grids. It is the wrong tool when the values are categorical rather than ordered — a ramp implies "more," and forcing unordered categories onto it invents an ordering that was not there.
Each step is "color-mix(in srgb, accent N%, var(--ground))" at five fixed strengths — 10%, 34%, 56%, 78%, and 100% — except the lowest step, which mixes a neutral color rather than the accent. Because every step is a mix against the page's own ground token, "stronger" always means "further from the page," which is exactly backwards from a ramp mixed against a fixed white or black: on a fixed-hue ramp the palest step in dark mode is closer to the page's own bright text color than the darkest step is, so it reads as the most active cell instead of the least.
The neutral floor at level 0 is the detail worth getting right. A ramp that uses the palest step of the accent hue for "zero" cannot be told apart from "a little" at a glance — both are pale versions of the same color. Mixing a low percentage of the muted/dim token into the ground instead gives zero a genuinely different hue path, so the eye can tell "nothing happened" from "barely anything happened" without reading the tooltip.
Generate the cell data with a small deterministic pseudo-random generator seeded from a fixed constant, not Math.random, so the same sample grid renders on every load — a screenshot or a visual-regression test taken today matches one taken next year. A minimal seeded generator is a few lines: keep an internal integer state, advance it with a fixed additive constant each call, then run it through a couple of xor/multiply mixing steps and normalize to 0–1. Build the grid as roughly 19 columns of 7 rows using "grid-auto-flow: column", so the layout reads left-to-right as weeks and top-to-bottom as days without any manual row/column math.
Each cell is a plain, non-interactive span carrying both a native title attribute and an aria-label with the same human-readable count — at grid sizes in the hundreds of cells, a full interactive tooltip component per cell is the wrong trade for the value it adds, and the native title already gives sighted pointer users the detail on hover while the aria-label covers screen readers. Pair the grid with a small legend spanning the same five levels so "less" and "more" are anchored without a number.
color-mix() needs Chrome 111, Safari 16.2, or Firefox 113 or later; below that the declarations are simply ignored and cells render as whatever the browser falls back to for an unparsed value, so pair this with a fallback background on the cell class if the audience includes older browsers.