mwui
GitHub

Color Contrast

WCAG contrast ratios and readable text color selection, with no dependencies.

brandlight · 7.81:1
yellowdark · 12.93:1
bluelight · 4.95:1
mintdark · 12.99:1
slatelight · 4.56:1
rosedark · 5.39:1

Installation

Usage

Three functions built on the WCAG 2.x definitions: relative luminance, the contrast ratio between two colors, and the choice between a light and a dark foreground. No dependencies, no color library.

import { getContrastRatio, getReadableTextColor } from "@/lib/color-contrast";

getContrastRatio("#6400DC", "#ffffff"); // 8.15
getReadableTextColor("#6400DC"); // "light"
getReadableTextColor("#facc15"); // "dark"

Choosing a foreground

getReadableTextColor returns a tone, not a color, so the caller maps it onto whatever it actually applies — a design token, a class, a CSS variable:

const tone = getReadableTextColor(accent, { light: "#fafafa", dark: "#0a0a0a" });

element.style.setProperty("--brand-foreground", tone === "light" ? LIGHT : DARK);

Pass light and dark whenever the real candidates are not pure white and black. Doing so rarely changes which tone wins — swapping pure white and black for a near-white and near-black moves the tie point from a luminance of 0.1791 to 0.1810, not enough to flip any 8-bit grey — but it does make the ratios themselves correct, which matters as soon as you compare them against an AA or AAA threshold rather than just against each other.

Why not a lightness threshold

The tempting shortcut is to read one number — HSL lightness, or OKLCH L — and compare it against a constant. It is close, but it is not the same thing, because the point where white and black tie depends on hue as well as lightness:

BackgroundIdeal OKLCH L crossover
Neutral grey0.566
Saturated yellow0.556
Saturated red0.592
Saturated blue0.628

No single threshold sits on all of those, so a fixed constant is guaranteed to pick the worse foreground for some slice of the color wheel. Comparing the two ratios costs a few lines and is exact.

If you do reach for a threshold anyway, use OKLCH L rather than HSL L: OKLCH lightness is perceptual, so a yellow and a blue that share an L genuinely look equally light.

Parsing

parseRgb accepts #abc, #aabbcc, #aabbccdd, and rgb() / rgba(), returning 0-255 channels or null. Alpha is parsed but ignored — contrast is only meaningful against an opaque backdrop, and guessing what sits behind a translucent color would be less accurate than not trying. Composite the color yourself first if it is translucent.

Unparseable input yields a luminance of 0 rather than an exception, so a bad value at runtime degrades to "assume dark, use light text" instead of breaking a render.

Functions

FunctionDescription
getRelativeLuminanceWCAG relative luminance, 01.
getContrastRatioWCAG contrast ratio, 121. Argument order does not matter.
getReadableTextColor"light" or "dark", whichever contrasts better. Candidates are configurable.
parseRgbHex or rgb() string to { r, g, b } in 0-255, or null.

For reference, WCAG AA wants 4.5 for body text and 3 for large text or UI components; AAA wants 7 and 4.5.