Created
August 1, 2025 23:29
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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