Skip to main content

Color difference and contrast

Two families answer different questions. A color difference is one number for "how far apart do these two colors look", and the metric decides the space it is measured in. WCAG contrast is a regulatory ratio for text and graphics, always computed in sRGB.

plt_difference(a, b, metric) and plt_difference(a, b, metric, space)​

Signature: plt_difference(a plt_color, b plt_color, metric VARCHAR) -> DOUBLE Signature: plt_difference(a plt_color, b plt_color, metric VARCHAR, space VARCHAR) -> DOUBLE

The three-argument form measures in CIE L*a*b* (D65) — the space ΔE, HyAb and CIEDE2000 are defined in. The four-argument form converts both colors into space (without clamping) and measures there; if the metric has no definition in that space the statement fails and the message lists the spaces that do work.

SELECT plt_difference(plt_lab(50, 2.6772, -79.7751), plt_lab(50, 0, -82.7485), 'ciede2000') AS delta_e,
plt_difference(plt_lab(50, 2.6772, -79.7751), plt_lab(50, 0, -82.7485), 'ciede2000', 'lch') AS same_in_lch;
MetricMeaningDefined in
euclideanstraight-line distance in the space's componentscam16_ucs_jab, lab, lms, lms_von_kries, luma, luma_linear, luma_gamma, luv, oklab, every RGB space, xyz, xyz_d50, yxy
euclidean_squaredthe same, squared (cheaper to sort, same ordering)same as euclidean
delta_eCIE76 ΔE*abcam16_ucs_jab, cam16_ucs_jmh, lab, lch
improved_delta_eΔE*ab with palette's correctioncam16_ucs_jab, cam16_ucs_jmh, lab, lch
hyabhybrid distance: Lab lightness difference plus a/b (or u/v) distancecam16_ucs_jab, lab, luv, oklab
ciede2000CIEDE2000 ΔE*00, the standard perceptual metriclab, lch
improved_ciede2000CIEDE2000 with palette's discontinuity fixlab, lch

Aliases: cie76, deltae and delta_e_76 mean delta_e; cie2000 and delta_e_2000 mean ciede2000. Names are case-insensitive with - and spaces counting as _.

SELECT plt_difference_metrics() AS metrics;
SELECT plt_difference(plt_srgb(1, 0, 0), plt_srgb(0, 1, 0), 'euclidean', 'oklab') AS oklab_distance,
plt_difference(plt_srgb(1, 0, 0), plt_srgb(0, 1, 0), 'euclidean', 'srgb') AS srgb_distance,
plt_difference(plt_srgb(1, 0, 0), plt_srgb(1, 0.05, 0), 'delta_e') AS nearly_identical;

A metric measured in the wrong space is an error, not a silent conversion:

SELECT plt_difference(plt_srgb(1, 0, 0), plt_srgb(0, 1, 0), 'ciede2000', 'srgb');
SELECT plt_difference(plt_srgb(1, 0, 0), plt_srgb(0, 1, 0), 'lab-distance');

plt_relative_luminance(color)​

Signature: plt_relative_luminance(color plt_color) -> DOUBLE

The WCAG 2.1 relative luminance: 0 for black, 1 for white. The color is converted to sRGB first, because the definition assumes sRGB. This is the quantity the contrast ratio is built from, and useful on its own for deciding whether a color is "light" or "dark":

SELECT plt_relative_luminance(plt_srgb(0, 0, 0)) AS black,
plt_relative_luminance(plt_srgb(0.5, 0.5, 0.5)) AS middle_gray,
plt_relative_luminance(plt_srgb(1, 1, 1)) AS white,
plt_relative_luminance(plt_lab(50, 0, 0)) AS lab_gray;

plt_contrast(a, b)​

Signature: plt_contrast(a plt_color, b plt_color) -> DOUBLE

The WCAG 2.1 contrast ratio (L_lighter + 0.05) / (L_darker + 0.05), from 1 (identical) to 21 (black against white). Both colors are converted to sRGB. The result is symmetric:

SELECT plt_contrast(plt_srgb(1, 1, 1), plt_srgb(0, 0, 0)) AS max_contrast,
plt_contrast(plt_srgb(1, 0, 0), plt_srgb(1, 1, 1)) AS red_on_white,
plt_contrast(plt_srgb(1, 0, 0), plt_srgb(1, 0, 0)) AS identical;
SELECT plt_contrast(plt_srgb(1, 1, 1), plt_srgb(0, 0, 0)) =
plt_contrast(plt_srgb(0, 0, 0), plt_srgb(1, 1, 1)) AS symmetric;

plt_contrast_check(a, b, criterion)​

Signature: plt_contrast_check(a plt_color, b plt_color, criterion VARCHAR) -> BOOLEAN

Answers a WCAG criterion directly, using the same ratio:

CriterionMinimum ratio
min_text4.5
min_large_text3
enhanced_text7
enhanced_large_text4.5
min_graphics3
SELECT plt_contrast_criteria() AS criteria;
SELECT plt_contrast_check(plt_srgb(1, 1, 1), plt_srgb(0, 0, 0), 'min_text') AS white_on_black,
plt_contrast_check(plt_srgb(1, 1, 1), plt_srgb(1, 0, 0), 'min_text') AS white_on_red,
plt_contrast_check(plt_srgb(1, 1, 1), plt_srgb(1, 0, 0), 'min_large_text') AS white_on_red_large;

A useful pattern is to annotate a palette and filter by the criteria it must meet:

SELECT plt_to_hex(c) AS color,
plt_contrast(plt_srgb(1, 1, 1), c) AS on_white,
plt_contrast_check(plt_srgb(1, 1, 1), c, 'min_text') AS readable_on_white
FROM (VALUES (plt_named('rebeccapurple')),
(plt_named('gold')),
(plt_named('teal'))) t(c);
SELECT plt_contrast_check(plt_srgb(1, 1, 1), plt_srgb(0, 0, 0), 'aaa');

plt_most_readable(base, candidates) and overloads​

Signature: plt_most_readable(base plt_color, candidates plt_color[]) -> plt_color Signature: plt_most_readable(base plt_color, candidates plt_color[], criterion VARCHAR) -> plt_color Signature: plt_most_readable(base plt_color, candidates plt_color[], criterion VARCHAR, use_black_and_white BOOLEAN) -> plt_color

Picks the candidate with the highest WCAG contrast against base, comparing the same ratio plt_contrast returns. The default criterion is min_text; it is only consulted when use_black_and_white is true. In that case, if the best candidate still misses the criterion, the function returns whichever of black and white contrasts better with base — tinycolor's mostReadable behaviour. An empty candidate list is an error, and an unknown criterion fails the statement like plt_contrast_check.

SELECT plt_to_hex(plt_most_readable(plt_named('navy'),
[plt_named('teal'), plt_named('gold'), plt_named('tomato')])) AS best,
plt_to_hex(plt_most_readable(plt_named('navy'),
[plt_named('teal')], 'min_text', true)) AS fallback;

This is the "give me a label color that is readable on this background" query, applied per row:

SELECT plt_to_hex(bg) AS background,
plt_to_hex(plt_most_readable(bg, [plt_named('white'), plt_named('black')])) AS label
FROM (VALUES (plt_named('seagreen')), (plt_named('gold')), (plt_named('rebeccapurple'))) t(bg);
SELECT plt_most_readable(plt_srgb(1, 1, 1), []);

Brightness and light/dark​

plt_relative_luminance follows WCAG, which is what contrast ratios use. The three functions below use the older YIQ brightness instead — the value tinycolor exposes as getBrightness. It is cruder and channel-weighted (0.299 R + 0.587 G + 0.114 B), but it is what many design tools use to decide whether text should be light or dark.

plt_brightness(color)​

Signature: plt_brightness(color plt_color) -> DOUBLE

The YIQ brightness, 0 (black) to 1 (white). The color is converted to sRGB and clamped first.

SELECT plt_brightness(plt_srgb(0, 0, 0)) AS black,
plt_brightness(plt_srgb(0.5, 0.5, 0.5)) AS middle_gray,
plt_brightness(plt_srgb(1, 1, 1)) AS white,
plt_brightness(plt_named('gold')) AS gold;

plt_is_light(color) and plt_is_dark(color)​

Signature: plt_is_light(color plt_color) -> BOOLEAN Signature: plt_is_dark(color plt_color) -> BOOLEAN

A threshold on the same YIQ value at 128/255 (≈ 0.502), tinycolor's default. The two are exact complements — a color is either light or dark, never both and never neither.

SELECT plt_is_light(plt_named('gold')) AS gold_light,
plt_is_dark(plt_named('gold')) AS gold_dark,
plt_is_light(plt_named('navy')) AS navy_light,
plt_is_dark(plt_named('navy')) AS navy_dark;

Listing the supported names​

plt_difference_metrics()​

Signature: plt_difference_metrics() -> VARCHAR[]

The seven metric names accepted by plt_difference.

plt_contrast_criteria()​

Signature: plt_contrast_criteria() -> VARCHAR[]

The five criterion names accepted by plt_contrast_check.