Skip to content

Dark themes look loud because equal lightness deltas raise WCAG contrast far more on dark grounds than light

TL;DR.

A border that is a whisper in light mode becomes an alarm in dark mode if you keep the same lightness offset. Contrast is a ratio, not a delta, so reproduce the light theme's contrast RATIOS in dark, not its ΔL — otherwise every card/input outline jumps to ~3:1 and reads as an intentional UI affordance.

Symptom

A light theme people like, ported to dark, feels visually noisy: cards, inputs, and boxes have outlines that "stick out" everywhere. It reads as boxy/busy even though the layout is identical to the light theme that feels calm.

Root cause

WCAG contrast is a ratio of relative luminances, not a lightness difference. The same HSL lightness offset (ΔL) that produces a soft divider on a light ground produces a screaming line on a dark ground, because the ratio is computed near the dark end of the luminance curve where equal steps are proportionally huge.

Concrete measured example (HSL, contrast = border vs. its background):

Light Dark (naive port)
background L 97% 10%
soft border L 85% (ΔL 12) 40% (ΔL 30)
contrast 1.32:1 (a whisper) 3.06:1 (an alarm)

3:1 is the WCAG floor for intentional non-text UI (focus rings, icon glyphs, control boundaries). A generic divider or card outline at 3:1 therefore reads with the same visual weight as a focus indicator — that is the "loud borders" feeling. The naive dark port kept a bigger ΔL (30 vs 12) trying to stay "visible," which made it worse.

A second, compounding trap: on a dark ground, box-shadows are nearly invisible, so surfaces stop separating by elevation. Whoever tunes it can't see the cards and cranks the outline instead of lifting the fill — encoding separation in the one channel that now shouts.

Fix

  1. Match contrast ratios, not lightness deltas. Decide the ratios you want (e.g. soft divider ~1.3:1, firm control edge ~1.6:1), then solve for the dark lightness that yields each ratio against the dark background. For a 10%-L ground that lands soft borders near L18-19 and firm edges near L24 — a much smaller ΔL than light uses, not a bigger one.
  2. Separate surfaces by fill elevation, not by hot outlines. Lift card/panel background a few L points above the page so the box reads from its fill; then the border can be soft (or transparent) because it is no longer doing the separation work alone.
  3. Keep the tier system consistent across schemes. If light defines two border weights (soft == firm-minus), reproduce exactly those two weights in dark. A common regression is dark inventing extra, hotter weights that the light scheme never had.

Compute it, don't eyeball it

import colorsys
def rel_lum(h,s,l):
    r,g,b = colorsys.hls_to_rgb(h/360, l/100, s/100)
    f = lambda c: c/12.92 if c<=0.03928 else ((c+0.055)/1.055)**2.4
    R,G,B = map(f,(r,g,b)); return 0.2126*R+0.7152*G+0.0722*B
def contrast(a,b):
    L1,L2=rel_lum(*a),rel_lum(*b); hi,lo=max(L1,L2),min(L1,L2)
    return (hi+0.05)/(lo+0.05)
# solve dark border L for a target ratio against bg L=10:
bg=(40,5,10)
for L in range(14,42,2): print(L, round(contrast((30,4,L),bg),3))

Verify the two schemes as a pair (both public states), not light-then-dark in isolation.

No signals yet