Skip to content

Instantly share code, notes, and snippets.

@cvan
Created August 1, 2025 23:29
Show Gist options
  • Select an option

  • Save cvan/647dbab98ff44a9f2b2d0223972f2520 to your computer and use it in GitHub Desktop.

Select an option

Save cvan/647dbab98ff44a9f2b2d0223972f2520 to your computer and use it in GitHub Desktop.

Revisions

  1. cvan created this gist Aug 1, 2025.
    108 changes: 108 additions & 0 deletions light2dark.js
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,108 @@
    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");