Define colours as semantic tokens (text, text-dim, text-mute, surface, surface-2), set separate values for each theme, and check every text token against every surface it can sit on. WCAG requires 4.5:1 for normal text and 3:1 for UI components and large text. The failures nearly always hide in the muted tokens, not the main text.
Key takeaways
- Check the muted colours, not just the body text. On this site body text was at 14:1 or better in both themes while the muted token failed in both.
- Check every surface, not one. A colour that passes on the page background can fail on a card or a raised surface.
- Name colours by role. Tokens such as
--text-muteand--surface-2let you retune one theme without touching the other. - WCAG 1.4.3 asks for 4.5:1 for normal text and 3:1 for large text; 1.4.11 asks for 3:1 for UI components. These are minimums.
- Do not simply invert. Dark surfaces need their own lightness steps, since shadows disappear and elevation is shown by lighter surfaces instead.
Tokens by role
A themed site needs a small, named set: a background and two or three surface steps; text at three strengths (main, dim, mute); an accent and a stronger accent for hover. Each theme assigns its own value to every name. Components then refer to names, never to hex values, so a theme change is a change to one block.
The contrast check
WCAG defines contrast ratio from relative luminance. The script reads the token blocks from your stylesheet and prints every text token against every surface, starring anything under 4.5:1:
import re, sys
def luminance(hexcolor):
h = hexcolor.lstrip('#')
r, g, b = (int(h[i:i + 2], 16) / 255 for i in (0, 2, 4))
f = lambda c: c / 12.92 if c <= 0.03928 else ((c + 0.055) / 1.055) ** 2.4
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b)
def ratio(a, b):
la, lb = sorted((luminance(a), luminance(b)), reverse=True)
return (la + 0.05) / (lb + 0.05)
def tokens(block):
return dict(re.findall(r'--([\w-]+):\s*(#[0-9A-Fa-f]{6})\b', block))
css = open(sys.argv[1], encoding='utf-8').read()
light = tokens(re.search(r':root\s*\{(.*?)\n\}', css, re.S).group(1))
dark = tokens(re.search(r'prefers-color-scheme:\s*dark\)\s*\{\s*:root:not\(\[data-theme="light"\]\)\s*\{(.*?)\n \}', css, re.S).group(1))
TEXT = ['text', 'text-dim', 'text-mute', 'accent', 'accent-hi', 'accent2', 'accent2-hi']
SURFACES = ['bg', 'surface', 'surface-2', 'surface-3']
for name, t in (('light', light), ('dark', dark)):
print('== %s theme' % name)
for fg in TEXT:
row = []
for bg in SURFACES:
r = ratio(t[fg], t[bg])
row.append('%5.2f%s' % (r, ' ' if r >= 4.5 else '*'))
print(' %-11s on %s' % (fg, ' '.join('%s %s' % (bg, v) for bg, v in zip(SURFACES, row))))
print('\n* = below 4.5:1 (WCAG 1.4.3 AA for normal text)')
This site, before and after
| Theme | Before (range) | After (range) | Result |
|---|---|---|---|
| Light | 3.40 to 3.93:1 | 4.66 to 5.38:1 | Passes |
| Dark | 3.46 to 4.03:1 | 4.57 to 5.33:1 | Passes |
Every other text token already passed: main text sat between 13.8 and 16.9:1, dim text above 6.7:1, and the accents above 4.9:1. All four failures were in one token that I had chosen by eye as "quiet but readable". That is the usual pattern, and it is why the check has to cover every token on every surface.
Rules I use
- Avoid pure black and pure white as a pair if the page is text-heavy; it is a style preference, not a WCAG rule.
- Raise elevation by lightening in dark themes: each surface step slightly lighter than the one below.
- Keep accents readable as text in both themes; the test above treats them as text because links use them.
- Give inputs and buttons a border or fill that meets 3:1 against the surface, since WCAG 1.4.11 asks for that when the boundary is what identifies the component.
- Apply the theme before paint so the visitor never sees the wrong one: dark mode flash on load.