Color
Adaptive accent color
Takes an arbitrary brand hex and mixes it toward the ground color until it clears a 4.5:1 contrast floor, so an uncontrolled palette becomes legible in both themes.
- color
- contrast
- accessibility
- wcag
- theming
<div class="aac-panel">
<p class="aac-caption">Ratios below are measured against the current page background.</p>
<div class="aac-table" role="table" aria-label="Brand colors before and after contrast solving">
<div class="aac-row aac-head" role="row">
<span role="columnheader">Brand</span>
<span role="columnheader">As authored</span>
<span role="columnheader">Solved for contrast</span>
</div>
<div id="aac-rows"></div>
</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.
.aac-panel { display: flex; flex-direction: column; gap: 0.9rem; }
.aac-caption {
margin: 0;
font-family: var(--mono);
font-size: 0.8125rem;
color: var(--dim);
}
.aac-table {
display: grid;
gap: 1px;
background: var(--border);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.aac-row {
display: grid;
grid-template-columns: minmax(120px, 1fr) 1fr 1fr;
gap: 1px;
background: var(--border);
}
.aac-row > span {
background: var(--surface);
padding: 0.65rem 0.9rem;
display: flex;
align-items: center;
gap: 0.6rem;
min-width: 0;
}
.aac-head > span {
background: var(--surface-2);
font-family: var(--mono);
font-size: 0.8125rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--dim);
}
.aac-dot {
width: 20px;
height: 20px;
border-radius: 5px;
flex: 0 0 auto;
border: 1px solid var(--border);
}
.aac-brand-name {
font-weight: 650;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.aac-value {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
width: 100%;
}
.aac-sample { font-weight: 600; }
.aac-verdict {
font-family: var(--mono);
font-size: 0.8125rem;
padding: 0.1rem 0.5rem;
border-radius: 4px;
white-space: nowrap;
}
.aac-pass { color: var(--ok); border: 1px solid var(--ok); }
.aac-fail { color: var(--bad); border: 1px solid var(--bad); }(function () {
var rowsContainer = document.getElementById('aac-rows');
// Every hex here stands in for an uncontrolled brand color a user could enter;
// they are the demo's data, not a restatement of the theme palette.
var brandColors = [
{ name: 'Aurora', hex: '#E8A33D' },
{ name: 'Testing', hex: '#1F6F8B' },
{ name: 'Rollback', hex: '#F1C40F' },
{ name: 'Pending', hex: '#C0392B' },
{ name: 'Failed', hex: '#27AE60' },
{ name: 'Active', 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);
}
// Walks a hex color toward black or white in fixed steps, stopping the
// moment the ratio clears the floor so as much of the original hue as
// possible survives.
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;
}));
}
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;
}
function groundReference() {
var groundValue = getComputedStyle(document.documentElement).getPropertyValue('--ground').trim();
return /^#[0-9a-f]{6}$/i.test(groundValue) ? groundValue : '#eef0f3';
}
function paintRows() {
var reference = groundReference();
rowsContainer.innerHTML = brandColors.map(function (brand) {
var solvedHex = readableOn(brand.hex, reference);
var rawRatio = contrastRatio(brand.hex, reference);
var solvedRatio = contrastRatio(solvedHex, reference);
var rawVerdict = rawRatio >= 4.5 ? 'aac-pass' : 'aac-fail';
var solvedVerdict = solvedRatio >= 4.5 ? 'aac-pass' : 'aac-fail';
return '<div class="aac-row" role="row">' +
'<span role="cell"><span class="aac-dot" style="background:' + brand.hex + '"></span>' +
'<span class="aac-brand-name">' + brand.name + '</span></span>' +
'<span role="cell" class="aac-value"><span class="aac-sample" style="color:' + brand.hex + '">' + brand.hex + '</span>' +
'<span class="aac-verdict ' + rawVerdict + '">' + rawRatio.toFixed(2) + ':1</span></span>' +
'<span role="cell" class="aac-value"><span class="aac-sample" style="color:' + solvedHex + '">Status label</span>' +
'<span class="aac-verdict ' + solvedVerdict + '">' + solvedRatio.toFixed(2) + ':1</span></span>' +
'</div>';
}).join('');
}
paintRows();
})();Paste this into an agent to rebuild the pattern from scratch.
Build a small color-solving utility that takes an arbitrary brand hex — one a user or an external feed supplies, not one from the design system — and returns a color that clears a 4.5:1 contrast ratio against the page background, in both light and dark themes, while keeping as much of the original hue as it can.
Reach for this whenever a UI has to render third-party or user-supplied color as text or an accent: a channel's brand color on a program guide, a project's label color on a dashboard, a tag color a user picked from a swatch. Skip it for anything already inside the design system — a token that was chosen by a human and checked once needs no runtime solver, and running one against fixed palette colors just adds indirection.
The core is four pure functions with no framework dependency. Relative luminance follows the WCAG formula: normalize each channel to 0–1, apply the piecewise gamma curve (linear below 0.04045, a power curve above it), then 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 hex — pure black or pure white, whichever the background is farther from — by a fixed fraction per call, most naturally 8%. The solver itself repeats the mix in a bounded loop, recomputing the ratio after each step and stopping the instant it clears the floor, so a color that is already legible is returned untouched and a hard case gets pushed only as far as it needs to go.
Solve against the background the color will actually sit on, not a synthetic white or black — every themed page background is a tint of something, and 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 both 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 above only ever needs to run once per input color, not on every render.
light-dark() has a sharp edge worth documenting 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, the 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 while the OS is set to light. The fix is to never make that later declaration; leave a comment where the temptation would otherwise be, since there is no code to point to.