Bhinish Dhiman
Dark mode contrast — why WCAG 2 misleads you
WCAG 2 adds a fixed 0.05 constant to both luminance values. Near black that constant dominates the ratio, so dark-mode pairs can score a comfortable pass while reading as dim. Check dark themes with APCA, and expect to need a lighter variant of every brand colour.
A palette that passes every check on white will not automatically pass when you invert it. Worse, the standard tool will often tell you it did.
Where the problem comes from
The WCAG 2 contrast formula is:
(L1 + 0.05) / (L2 + 0.05)
where L1 and L2 are the relative luminances of the lighter and darker colour. That 0.05 models ambient screen flare — light reflecting off the display that never fully goes away, so nothing on screen is truly zero luminance.
It is a reasonable simplification, and at the light end of the range it barely matters. A luminance of 0.9 plus 0.05 is a 5% adjustment.
At the dark end it dominates completely. A very dark grey might have a luminance of 0.008. Adding 0.05 makes it 0.058 — the constant is now seven times larger than the actual measurement. The formula has stopped describing the colours and started describing the constant.
The practical consequence: small luminance differences between dark colours get inflated into large-looking ratios. Two dark greys that are genuinely hard to tell apart can score a comfortable pass.
What this looks like in practice
The typical dark-mode mistake is taking a brand colour that works on white and using it unchanged on a dark background.
Take a mid-blue like #2563eb. On white it is about 5.17:1 — a clean AA pass for body text. Put it on a near-black #0c0f15 and WCAG 2 gives roughly 3.6:1. Below AA for body text, so you would catch that one.
But nudge it slightly lighter and you can reach 4.5:1 while the text still reads as dim and slightly vibrating against the dark field. The ratio says pass. Your eyes say otherwise. And on a dark background, the perceptual penalty for being marginal is much harsher than it is on white.
We hit exactly this building our own site. Our dark-mode link colour passed WCAG AA comfortably, and when we ran it through APCA it came out at Lc 37 against a requirement of 75. It looked fine in a screenshot and was genuinely hard to read.
APCA handles polarity properly
APCA produces a signed value. Positive means dark text on a light background; negative means light text on dark. It uses different curves for each direction, because the perceptual response is genuinely asymmetric — light text on a dark field appears to bleed outward and gain apparent weight, an effect that does not happen in reverse.
This is why APCA is the more trustworthy figure in dark mode specifically. It was designed with that asymmetry in mind, where WCAG 2 treats both directions as the same arithmetic.
What to do
Give every brand colour two variants. One darkened for light backgrounds, one lightened for dark ones. Same hue, same chroma, two lightness values. This is the single most useful structural change, and it is what shipping in both themes actually requires. One colour cannot serve both.
Do not use pure black as your dark background. #000000 maximises the flare distortion and produces uncomfortable halation with light text. Something in the range of #0c0f15 to #16181d is easier to read against and gives you room to build an elevation scale.
Do not use pure white text either. Full-brightness white on near-black is harsh and causes visible bloom at small sizes. A slightly warm off-white in the #e8ecf2 region reads better and measures barely differently.
Reduce saturation as you go lighter. Highly saturated colours on dark backgrounds vibrate. Pulling chroma down as you raise lightness produces a calmer result that measures the same.
Check dark mode at the real size and weight. The gap between the two models widens as text gets smaller, so a 14px dark-mode label is the worst case on both axes at once.
Getting the variants right
The efficient approach is to hold hue and chroma and move only lightness, because lightness is what drives contrast and hue is what carries your brand. Work in OKLCH, where lightness is perceptually uniform, so equal steps look equal.
That gives you a two-token pattern:
--brand: oklch(55% 0.16 255);
--brand-on-light: oklch(48% 0.16 255);
--brand-on-dark: oklch(78% 0.12 255);
Note the chroma drop on the dark variant. That is the desaturation point above, made concrete.
Our contrast checker shows WCAG 2 and APCA together and flags when they disagree — which on dark backgrounds is often. The lightness track shows which stretches of your hue pass against a given background, so you can pick both variants from the same axis.