Skip to content

Contrast

You have a background color and need a text color that stays readable on it. Every RgbColor and every palette entry answers that with a boolean:

RgbColor bg = RgbColor.readColor("#2b7fff");
String text = bg.isBlackContrastingColor() ? "#000000" : "#ffffff";

true means black text, false means white. The same method sits on ColorCode, so it works directly on a palette value:

TailwindPalette.BLUE.base().isBlackContrastingColor();
ColorPalette.SUNSHINE.isBlackContrastingColor();

Every swatch in these docs draws its label with the color isBlackContrastingColor() returns, which is why the pages are also a full test sheet for the method.

isBlackContrastingColor() reads the six hex digits as one integer and compares it against two thirds of white:

Integer.valueOf(getHexCode(), 16) > 0xffffff / 1.5

Because red occupies the top two hex digits, it dominates the comparison: red weighs 65 536 times as much as blue. Anything with a strong red channel is called light early, anything blue-dominant stays dark far longer than it looks. It is cheap and it has been in the library since 0.5.0.

isBlackContrastingColor2() computes relative luminance the way WCAG defines it — gamma-expand each channel, weight them 0.2126 / 0.7152 / 0.0722, compare against the 0.179 threshold that sits at the crossover between a 4.5:1 contrast against black and against white:

double luminance = 0.2126 * gamma(r) + 0.7152 * gamma(g) + 0.0722 * gamma(b);
return luminance > 0.179;

Green carries most of the weight there, which matches how the eye works. For anything user-facing this is the method to use. The first one is kept because changing it would silently redraw every UI that already relies on it.

Across the 480 colors of the three bundled palettes the two methods disagree on 108 — 49 in Tailwind, 44 in Open Color, 15 in ColorPalette. The pattern is consistent: saturated greens, teals and blues, which the hex-integer test calls dark and luminance calls light, plus deep reds and magentas the other way around.

blue-500 #2b7fff
isBlackContrastingColorwhite
isBlackContrastingColor2black
green-400 #05df72
isBlackContrastingColorwhite
isBlackContrastingColor2black
red-600 #e7000b
isBlackContrastingColorblack
isBlackContrastingColor2white
cyan-300 #53eafd
isBlackContrastingColorwhite
isBlackContrastingColor2black
pool #9cd6f5
isBlackContrastingColorwhite
isBlackContrastingColor2black
rasberry #ac2c69
isBlackContrastingColorblack
isBlackContrastingColor2white

Read the tiles above and the second column wins nearly every time. cyan-300 (#53eafd) is a light color by any measure, yet the hex-integer test puts white text on it.

public String badge(String label, String backgroundColor) {
RgbColor background = RgbColor.readColor(backgroundColor);
if (background == null) {
background = RgbColor.hex2rgb("f1f5f9");
}
String text = background.isBlackContrastingColor2() ? "#000000" : "#ffffff";
return String.format("<span style=\"background:%s;color:%s\">%s</span>",
background.getHexCodeWithLeadingHash(), text, label);
}

Two details worth keeping: parse defensively, since readColor returns null for junk, and render the background from the parsed color rather than the raw input, so rgb(43, 127, 255) and 2b7fff end up producing the same markup.