LW IT Solutions logo LW IT Solutions logo LW IT Solutions
« Blog Overview /Web Development/Tutorials / Light and Dark Palettes: Checking Contrast and...

Light and Dark Palettes: Checking Contrast and Its Limits

Light and Dark Palettes: Checking Contrast and Its LimitsLight and Dark Palettes: Checking Contrast and Its Limits

Management Summary

Contrast is the visible difference between a website element and its background. In light and dark themes, sufficient contrast helps text, controls and selected items remain recognisable. A complete colour palette alone does not establish this: the actual pair of colours, text size, transparency and interaction state matter. This explains why the same design can be readable in one view and difficult in another. Colour calculations are useful guidance, but the displayed page and its operation determine the practical result.

Contents
  1. Which threshold applies?
  2. A reproducible example
  3. Four limits of palette-only checking
  4. Focus and form labels
  5. What the tool can establish
  6. Questions and answers
  7. Sources

Light and dark designs can use the same CSS variable names with different colour values. Readability, however, depends on the foreground/background pair actually displayed. A palette supplies possible colours; usage on the page determines which contrasts require checking.

Applicable contrast threshold, actual rendering and a calculated opaque sRGB pair
Applicable contrast threshold, actual rendering and a calculated opaque sRGB pair.

Which threshold applies?

WCAG 2.2 generally requires at least 4.5:1 for normal text at level AA. Large text needs 3:1: at least 18 points or 24 CSS pixels, or 14 points bold, approximately 18.67 CSS pixels. Level AAA requires 7:1 for normal text and 4.5:1 for large text. Exceptions include logos, purely decorative text and text in inactive controls.

Visual information required to identify controls, states or meaningful graphics also falls under non-text criterion 1.4.11. The threshold is 3:1 against adjacent colours, subject to the criterion’s exceptions. Every individual border does not necessarily need that contrast if other sufficiently distinguishable features identify the component.

A low number therefore does not automatically establish a WCAG failure. Function, size, state and actual background come first. Conversely, a variable name such as --border-soft cannot turn a necessary boundary into decoration.

A reproducible example

For opaque foreground #5e7184 on #f1f5f9, the WCAG formula gives approximately 4.595593:1. This particular pair meets AA for normal text. A two-decimal display reads 4.60:1, but the comparison still uses the unrounded value.

#777777 on white gives approximately 4.478089:1 and misses 4.5:1. #767676 on white reaches approximately 4.542225:1. A small colour change can cross the threshold. A target of 4.6:1 provides some margin, but cannot guarantee a pass after later background changes.

The following Python example accepts only opaque six-digit sRGB hex colours. The linearisation breakpoint is 0.04045. The older 0.03928 value selects the same branch for integer 8-bit channels; the example uses the current definition. Alpha, gradients, the CSS cascade and other colour spaces are outside its scope.

Python
"""WCAG 2.2 ratio for two opaque six-digit sRGB hex colours only."""
import re

def luminance(value):
    if not isinstance(value, str) or not re.fullmatch(r"#?[0-9a-fA-F]{6}", value):
        raise ValueError("Expected six-digit opaque sRGB hex colour")
    value = value.removeprefix("#")
    channels = [int(value[i:i+2], 16) / 255 for i in (0, 2, 4)]
    linear = [c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
              for c in channels]
    return sum(w * c for w, c in zip((0.2126, 0.7152, 0.0722), linear))

def contrast(foreground, background):
    a, b = luminance(foreground), luminance(background)
    return (max(a, b) + 0.05) / (min(a, b) + 0.05)

if __name__ == "__main__":
    ratio = contrast("#5e7184", "#f1f5f9")
    print(f"{ratio:.4f}:1; AA normal text: {ratio >= 4.5}")

Four limits of palette-only checking

  1. Names do not replace usage. --on-accent suggests text on an accent surface, but CSS can use that value elsewhere. A value named soft can also provide important text.
  2. A missing override does not automatically trigger a fallback. A dark theme can inherit values from a shared base. Selectors, cascade and inheritance determine the computed value. A var() fallback applies under specific conditions; absence of a second declaration alone does not establish a defect.
  3. Transparency and gradients need the actual background. Alpha specifies transparency. The visible colour is calculated by combining the partly transparent colour with the layers beneath it. Images or gradients require checking the least favourable relevant area behind the text. That area can be inside a gradient, not just at an endpoint.
  4. States change the pairs. Focus, hover, selection, error messages and raised cards can introduce different backgrounds. A correct default pair does not approve every state or the entire website.
Two identical glass suncatchers hang in a window, one in front of a curtain, one in front of a wall, and look quite different. Likewise, the visible shade of a see-through area depends on what really lies beneath it.
Two identical glass suncatchers hang in a window, one in front of a curtain, one in front of a wall, and look quite different. Likewise, the visible shade of a see-through area depends on what really lies beneath it.

Focus and form labels

A focus indicator marks the control currently selected for keyboard input, for example with a visible outline. An author-designed focus indicator must meet the applicable requirements for distinguishable states. WCAG 2.4.13 at level AAA adds indicator-area requirements and at least 3:1 between the same pixels in focused and unfocused states. This differs from adjacent-colour checking under 1.4.11. Unmodified browser styling and other explicitly listed cases have specific exceptions.

Placeholder text in an active field needs the applicable text contrast. A readable placeholder still does not replace an appropriate label because the hint disappears during input. Purpose and examples should remain understandable while editing.

What the tool can establish

The linked palette auditor calculates colour pairs locally in the browser. The current implementation extracts declarations with a text pattern and guesses roles from names. It does not resolve the real CSS cascade: the first occurrence wins when a name is repeated, and declarations inside comments can also be read. Values containing var() or oklch() are not resolved.

Alpha values are flagged but excluded from the contrast calculation. An isolated test with completely transparent black on white therefore reports 21:1 and a good overall verdict despite an alpha warning. Another test treats --text-soft as decoration and excludes that value from findings. A good result is consequently only a clue about recognised hypothetical pairs, not acceptance testing of the rendered interface.

Six isolated tests of the tool logic and twelve tests of the Python example support these specific statements. The calculations do not replace visual and state checks in a browser. A defensible review covers both themes, actual colour combinations and relevant control states.

Questions and answers

Can a displayed ratio of 4.50 still fail?

Yes. A value just below 4.5 can round to 4.50. The decision must precede rounding.

Is a faint border always decoration?

No. Function matters, not colour or variable name. Information needed for operation or understanding requires the applicable checks.

Do two complete palettes prove accessibility?

No. Palette completeness and contrast calculations cover only part of the requirements. Actual rendering, states, labels and other WCAG criteria still need separate assessment.

Lukas Wojcik

Lukas Wojcik

Systems architect and technology enthusiast specializing in scalable tracking solutions, GMP Stack (GA4 & GTM), and robust backend architectures. Advocate for clean code and privacy-first design.

Get in touch

A question or an idea for a web project? Tell me a little about it. This form uses reCAPTCHA and formconfirmed to protect against spam.

Write a comment

Edge cases the article misses and questions about the code are welcome here.

The email address is not published. Required fields are marked with an asterisk.

LUKASWOJCIK.COM · NEWSLETTER

Fresh ideas in your inbox

New articles, practical tools and updates from the projects.

Choose topics (optional)
No selection: all topics.

Subscription starts after confirmation by email. · Privacy

Privacy · Newsletter

Email address, language, selected topics and subscription/confirmation dates are stored to deliver the newsletter. Double opt-in is required. Every issue includes an immediate unsubscribe link. Formconfirmed checks the signup for spam. No email open or click tracking.

Read the full privacy policy ↗