Skip to content

Instantly share code, notes, and snippets.

@cvan
Created August 1, 2025 23:29
Show Gist options
  • Save cvan/647dbab98ff44a9f2b2d0223972f2520 to your computer and use it in GitHub Desktop.
Save cvan/647dbab98ff44a9f2b2d0223972f2520 to your computer and use it in GitHub Desktop.
convert a light-mode to dark-mode foreground color based on a dark background color
import { parse, formatCss, wcagContrast } from "culori";
const WCAG_MINIMUM_CONTRAST_RATIO = 4.5; // @see https://www.w3.org/WAI/WCAG21/Understanding/contrast-minimum.html
/**
* Format a number as "X.yyyy" without unnecessary trailing zeros.
* - Example: 402 → "402"
* - Example: 402.1200 → "402.12"
* - Example: 402.1234 → "402.1234"
*
* @param {number|string} value
* @returns {string}
*/
function formatNumber(value) {
// Convert to string with up to 4 decimal places
const formatted = Number(value).toFixed(4);
// Remove trailing zeros and optional trailing decimal point
return formatted.replace(/(\.\d*?[1-9])0+$/, "$1").replace(/\.0+$/, "");
}
/**
* Convert a light-mode OKLCH color to dark-mode color, whilst
* preserving hue/chroma and meeting WCAG 4.5:1 threshold.
*/
function lightToDarkColor(
lightColor,
darkBg = "oklch(0.2046 0 0)",
minContrast = WCAG_MINIMUM_CONTRAST_RATIO
) {
const gamma = 1.2; // Controls inversion curve
const chromaFactor = 0.95;
const color = parse(lightColor);
const bg = parse(darkBg);
// 1. Initial guess: perceptual inversion around mid-gray
// Maps L=0.8 → ~0.3, L=0.3 → ~0.6, etc.
let invertedL = Math.pow(1 - Math.pow(color.l, gamma), 1 / gamma);
// Map extremes closer to mid-range for dark mode (avoid pure white)
let baseL = 0.2 + 0.6 * invertedL;
// Clamp to [0,1]
baseL = Math.max(0, Math.min(1, baseL));
// Start candidate
const candidate = {
mode: "oklch",
l: baseL,
c: color.c * chromaFactor,
h: color.h,
};
// 2. Binary search for minimal L that meets contrast
let low = 0;
let high = 1;
let best = baseL;
for (let idx = 0; idx < 20; idx++) {
const mid = (low + high) / 2;
candidate.l = mid;
const contrast = wcagContrast(candidate, bg);
if (contrast >= minContrast) {
best = mid;
high = mid; // Try darker
} else {
low = mid;
}
}
candidate.l = Number(formatNumber(best));
candidate.c = Number(formatNumber(candidate.c));
candidate.h = Number(formatNumber(candidate.h));
return formatCss(candidate);
}
/**
* Test contrast ratio between two OKLCH colors.
*/
function testContrast(color1, color2) {
return wcagContrast(parse(color1), parse(color2));
}
// Example usage:
const lightModeOrig = "oklch(0.3788 0.1813 264.3606)"; // `--primary` in light mode
const darkBg = "oklch(0.2046 0 0)"; // `--background` in dark mode
const contrastOrig = testContrast(lightModeOrig, darkBg);
const darkTextNew = lightToDarkColor(lightModeOrig, darkBg);
const contrastNew = testContrast(darkTextNew, darkBg);
console.log(`\n\nOriginal:\n${lightModeOrig}`);
console.log(
`WCAG Contrast:\n${formatNumber(contrastOrig)}`,
contrastOrig >= WCAG_MINIMUM_CONTRAST_RATIO ? "[PASS]" : "[FAIL]"
);
console.log(`\n\nNew:\n${darkTextNew}`);
console.log(
`WCAG Contrast:\n${formatNumber(contrastNew)}`,
contrastNew >= WCAG_MINIMUM_CONTRAST_RATIO ? "[PASS]" : "[FAIL]"
);
console.log("\n");
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment