Color
Ground-referenced color
Two jobs solved against the page’s own ground token rather than a synthetic white or black — walking an uncontrolled brand hex until it clears a 4.5:1 floor, and mixing an ordered intensity ramp — so both invert correctly between themes from one expression.
Reach for it when building
- user-picked tag colors
- third-party brand colors
- channel or label colors on a dashboard
- calendar event colors
- contribution calendar
- usage heatmap
- streak and habit trackers
- density grids
- color
- contrast
- color-mix
- accessibility
- wcag
- theming
<div class="grc-demo">
<section class="grc-block">
<p class="grc-label">Contrast floor — an uncontrolled hex made legible</p>
<p class="grc-caption">Ratios below are measured against the current page background.</p>
<div class="grc-table" role="table" aria-label="Brand colors before and after contrast solving">
<div class="grc-row grc-head" role="row">
<span role="columnheader">Label</span>
<span role="columnheader">As authored</span>
<span role="columnheader">Solved for contrast</span>
</div>
<div id="grc-rows"></div>
</div>
</section>
<section class="grc-block">
<p class="grc-label">Intensity ramp — an ordered scale mixed into the ground</p>
<div class="grc-grid" id="grc-grid" aria-label="Sample weekly activity grid"></div>
<div class="grc-legend">
<span>Less</span>
<span class="grc-cell grc-level-0"></span>
<span class="grc-cell grc-level-1"></span>
<span class="grc-cell grc-level-2"></span>
<span class="grc-cell grc-level-3"></span>
<span class="grc-cell grc-level-4"></span>
<span>More</span>
</div>
</section>
</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.
.grc-demo { display: flex; flex-direction: column; gap: 1.75rem; }
.grc-block { display: flex; flex-direction: column; gap: 0.5rem; }
.grc-label {
margin: 0;
font-family: var(--mono);
font-size: 0.8125rem;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--dim);
}
.grc-caption {
margin: 0 0 0.25rem;
font-family: var(--mono);
font-size: 0.8125rem;
color: var(--dim);
}
/* ---------------------------------------------------------------
Variation one — solve an uncontrolled color against the ground.
--------------------------------------------------------------- */
.grc-table {
display: grid;
gap: 1px;
background: var(--border);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.grc-row {
display: grid;
grid-template-columns: minmax(120px, 1fr) 1fr 1fr;
gap: 1px;
background: var(--border);
}
.grc-row > span {
background: var(--surface);
padding: 0.65rem 0.9rem;
display: flex;
align-items: center;
gap: 0.6rem;
min-width: 0;
}
.grc-head > span {
background: var(--surface-2);
font-family: var(--mono);
font-size: 0.8125rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--dim);
}
.grc-dot {
width: 20px;
height: 20px;
border-radius: 5px;
flex: 0 0 auto;
border: 1px solid var(--border);
}
.grc-name {
font-weight: 650;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.grc-value {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
width: 100%;
}
.grc-sample { font-weight: 600; }
.grc-verdict {
font-family: var(--mono);
font-size: 0.8125rem;
padding: 0.1rem 0.5rem;
border-radius: 4px;
white-space: nowrap;
}
.grc-pass { color: var(--ok); border: 1px solid var(--ok); }
.grc-fail { color: var(--bad); border: 1px solid var(--bad); }
/* ---------------------------------------------------------------
Variation two — mix an ordered scale into the ground.
--------------------------------------------------------------- */
.grc-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;
}
.grc-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. */
.grc-level-0 { background: color-mix(in srgb, var(--dim) 10%, var(--ground)); }
.grc-level-1 { background: color-mix(in srgb, var(--accent) 34%, var(--ground)); }
.grc-level-2 { background: color-mix(in srgb, var(--accent) 56%, var(--ground)); }
.grc-level-3 { background: color-mix(in srgb, var(--accent) 78%, var(--ground)); }
.grc-level-4 { background: var(--accent); }
.grc-legend {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
font-size: 0.9375rem;
color: var(--dim);
}// The page ground is the reference both halves are solved against, so both
// read it the same way and fall back to the light token if it is missing.
function groundReference() {
var groundValue = getComputedStyle(document.documentElement).getPropertyValue('--ground').trim();
return /^#[0-9a-f]{6}$/i.test(groundValue) ? groundValue : '#eef0f3';
}
// --- Variation one: walk a hex until it clears the floor -----------------
(function () {
var rowsContainer = document.getElementById('grc-rows');
// Every hex here stands in for an uncontrolled color a user could enter;
// they are the demo's data, not a restatement of the theme palette.
var labelColors = [
{ name: 'Shoegaze', hex: '#E8A33D' },
{ name: 'City pop', hex: '#1F6F8B' },
{ name: 'Krautrock', hex: '#F1C40F' },
{ name: 'Trip hop', hex: '#C0392B' },
{ name: 'Dub techno', hex: '#27AE60' },
{ name: 'Post-punk', hex: '#566573' }
];
function hexToRgb(hex) {
var clean = hex.trim();
return [
parseInt(clean.slice(1, 3), 16),
parseInt(clean.slice(3, 5), 16),
parseInt(clean.slice(5, 7), 16)
];
}
function rgbToHex(rgb) {
return '#' + rgb.map(function (channel) {
var clamped = Math.round(Math.min(255, Math.max(0, channel)));
var hexPart = clamped.toString(16);
return hexPart.length === 1 ? '0' + hexPart : hexPart;
}).join('');
}
function relativeLuminance(rgb) {
var linear = rgb.map(function (channel) {
var value = channel / 255;
return value <= 0.04045 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4);
});
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
}
function contrastRatio(foregroundHex, backgroundHex) {
var foregroundLuminance = relativeLuminance(hexToRgb(foregroundHex));
var backgroundLuminance = relativeLuminance(hexToRgb(backgroundHex));
var lighter = Math.max(foregroundLuminance, backgroundLuminance);
var darker = Math.min(foregroundLuminance, backgroundLuminance);
return (lighter + 0.05) / (darker + 0.05);
}
function mixToward(hex, targetHex, amount) {
var fromRgb = hexToRgb(hex);
var towardRgb = hexToRgb(targetHex);
return rgbToHex(fromRgb.map(function (channel, index) {
return channel + (towardRgb[index] - channel) * amount;
}));
}
// Walks a hex toward whichever pole the ground is farther from, in fixed
// steps, stopping the moment the ratio clears the floor so as much of the
// original hue as possible survives.
function readableOn(hex, backgroundHex, minimumRatio) {
var floor = minimumRatio || 4.5;
var mixTarget = relativeLuminance(hexToRgb(backgroundHex)) > 0.5 ? '#000000' : '#ffffff';
var current = hex;
for (var step = 0; step < 40; step += 1) {
if (contrastRatio(current, backgroundHex) >= floor) break;
current = mixToward(current, mixTarget, 0.08);
}
return current;
}
var reference = groundReference();
rowsContainer.innerHTML = labelColors.map(function (label) {
var solvedHex = readableOn(label.hex, reference);
var rawRatio = contrastRatio(label.hex, reference);
var solvedRatio = contrastRatio(solvedHex, reference);
var rawVerdict = rawRatio >= 4.5 ? 'grc-pass' : 'grc-fail';
var solvedVerdict = solvedRatio >= 4.5 ? 'grc-pass' : 'grc-fail';
return '<div class="grc-row" role="row">' +
'<span role="cell"><span class="grc-dot" style="background:' + label.hex + '"></span>' +
'<span class="grc-name">' + label.name + '</span></span>' +
'<span role="cell" class="grc-value"><span class="grc-sample" style="color:' + label.hex + '">' + label.hex + '</span>' +
'<span class="grc-verdict ' + rawVerdict + '">' + rawRatio.toFixed(2) + ':1</span></span>' +
'<span role="cell" class="grc-value"><span class="grc-sample" style="color:' + solvedHex + '">Genre label</span>' +
'<span class="grc-verdict ' + solvedVerdict + '">' + solvedRatio.toFixed(2) + ':1</span></span>' +
'</div>';
}).join('');
})();
// --- Variation two: an ordered scale mixed into the ground ---------------
(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('grc-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 = 'grc-cell grc-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.
Two colour jobs share one rule: solve against the page's own ground token, never against a synthetic white or black. Every themed page background is a tint of something, and treating it as pure white or pure black systematically produces the wrong answer. Build whichever half you need — they are independent — but take the reference from the same place.
**Job one: make an uncontrolled colour legible.** Take an arbitrary hex a user or an external feed supplied — a tag colour someone picked, a channel's brand colour, a label from a syndicated source — and return a colour that clears a 4.5:1 contrast ratio against the page background in both themes, keeping as much of the original hue as it can. Skip this entirely for anything already inside the design system: a token chosen by a human and checked once needs no runtime solver, and running one against fixed palette colours just adds indirection.
The core is four pure functions with no framework dependency. Relative luminance follows the WCAG formula: normalise each channel to 0–1, apply the piecewise gamma curve (linear below 0.04045, a power curve above), weight the channels 0.2126 / 0.7152 / 0.0722 and sum. Contrast ratio takes the lighter and darker of two luminances and returns (lighter + 0.05) / (darker + 0.05). A mixing step walks one hex toward a target — pure black or pure white, whichever pole the *ground* is farther from — by a fixed fraction per call, most naturally 8%. The solver repeats that mix in a bounded loop, recomputing the ratio after each step and stopping the instant it clears the floor, so an already-legible colour is returned untouched and a hard case is pushed only as far as it needs to go.
Note where the ground appears in that: it is the thing contrast is *measured against*, and the thing that decides which pole to walk toward. Solving light mode against pure white will systematically undershoot, because the real ground is darker than white.
At runtime this collapses to one CSS value. Precompute the light-solved and dark-solved hex once, offline, and ship color: light-dark(lightSolved, darkSolved) — one property, no media query, no JavaScript in the paint path. The loop only ever runs once per input colour, not per render.
light-dark() has a sharp edge worth a comment next to the code: it resolves against the page's used color-scheme, not the OS setting directly. If a stylesheet declares color-scheme: light dark after a framework has already set the page's scheme, that declaration wins, the page is pinned to "supports both", and the browser hands the choice back to the OS — so a page forced to dark can silently render the light branch of every light-dark() value. The fix is to never make that later declaration, which means leaving a comment where the temptation would otherwise be, since there is no code to point at.
**Job two: build an ordered intensity scale.** A grid or heatmap that encodes a scalar as colour intensity — activity calendars, usage heatmaps, density grids. It is the wrong tool for categorical values: 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%, 100%. Because every step is a mix *into* the page's ground, "stronger" always means "further from the page", which is exactly what a ramp mixed against a fixed white or black gets backwards: on a fixed-hue ramp the palest step in dark mode sits closer to the page's own bright text colour than the darkest step does, so it reads as the most active cell instead of the least.
The floor is the detail worth getting right. Level 0 mixes a *neutral* into the ground, not the palest step of the accent, because a ramp whose zero is a pale accent cannot be told apart from "a little" at a glance — both are pale versions of one hue. A low percentage of the muted token gives zero a different hue path, so the eye separates "nothing happened" from "barely anything" without reading a tooltip.
Generate sample data with a small seeded pseudo-random generator rather than Math.random, so the same grid renders on every load and a screenshot taken today matches one taken next year. Keep an internal integer state, advance it by a fixed additive constant per call, run it through a couple of xor/multiply mixing steps, normalise to 0–1. Lay the grid out as roughly 19 columns of 7 rows with grid-auto-flow: column so it reads left-to-right as weeks and top-to-bottom as days with no manual row and column maths; pin grid-auto-columns to the cell width and justify-content: start, or the implicit tracks stretch and spread the weeks apart.
Each cell is a plain non-interactive span carrying both a native title and an aria-label with the same human-readable count. At grid sizes in the hundreds, a full tooltip component per cell is the wrong trade; the native title covers pointer users and the aria-label covers screen readers. Pair the grid with a 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 and later; below that the declarations are ignored and cells render as whatever the browser falls back to for an unparsed value, so add a plain fallback background on the cell class if the audience includes older browsers.