Color Contrast
WCAG contrast ratios and readable text color selection, with no dependencies.
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:
| Background | Ideal OKLCH L crossover |
|---|---|
| Neutral grey | 0.566 |
| Saturated yellow | 0.556 |
| Saturated red | 0.592 |
| Saturated blue | 0.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
| Function | Description |
|---|---|
getRelativeLuminance | WCAG relative luminance, 0–1. |
getContrastRatio | WCAG contrast ratio, 1–21. Argument order does not matter. |
getReadableTextColor | "light" or "dark", whichever contrasts better. Candidates are configurable. |
parseRgb | Hex 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.