Skip to content
AtomicReps

Passing contrast ratios that are still hard to read

A lesson from UI and UX for Engineers: Judging the Interfaces You Ship. Play it above, or read it through below.

Level I · Contrast, hierarchy, spacing and type
  • The ratio measures light. It does not measure readability.

    The common read: The contrast ratio tracks how readable a pair is, so against a mid grey the two obvious text colours score about the same.

    The number measures light, not reading. The formula divides the lighter colour's relative luminance plus 0.05 by the darker colour's plus 0.05, and relative luminance is the light a display emits. Dividing by a small number makes the ratio grow fast near black, so black and white text score equally only on a background with a luminance of about 0.18, between #757575 and #767676. #a3a3a3 has a luminance of 0.3663, well above that point, which is why black scored over three times higher than white on the previous screen.

    Two properties follow from the definition itself, and both surprise people who have used the number for years. The ratio is symmetric: the specification takes the lighter colour as L1 and the darker as L2, so swapping the text and the background cannot change the result, and the formula has no input that says which of the two you are setting type in. And hue is deliberately absent. The Understanding document says so directly, and gives the reason.

    The W3C also fixed the colour space and defined nothing beyond it. The luminance definition is written for sRGB, with a note advising authors to evaluate in sRGB unless they know another space is in play, and there is no normative treatment of display-p3, oklch or rec2020 anywhere in WCAG 2.2. Tailwind moved its whole default palette to oklch in January 2025. Two tools gamut-mapping that palette differently will report two different ratios for one token, and nothing in the specification says which is right.

    For example, a developer reports "the contrast check is flaky, it passed locally and failed in CI"; the ticket is assigned to the audit tool, then to the CI cache, and is closed by pinning a version. The check was not flaky: the input was outside the space the formula is defined for.

    Treat the number as what it is, a photometric comparison with a conformance threshold added on top: it will tell you when a pair is definitely too close, and it will not tell you that a pair is comfortable to read.

  • There is no successor, and the current draft says so.

    The common read: APCA is the contrast algorithm WCAG 3 adopted, so the ratio I am using now is a legacy metric with a dated replacement.

    The current draft defines no contrast algorithm, and it says so in an editor's note rather than in a footnote. Its contrast entry is at Exploratory status, the note under it states that the algorithm is yet to be determined, and the document's own boilerplate says it is inappropriate to cite it as anything other than work in progress. No standard or regulation we could find references APCA: not WCAG 2.2, not EN 301 549 v3.2.1, not the United States Department of Justice rule for Title II.

    APCA is serious work, and an accurate summary of it is narrower than either side usually gives. It reports a signed lightness contrast, Lc, so its result depends on which colour is the text, and the 2.x ratio's result does not; its published thresholds are a lookup table with a minimum for each font size and weight, where the 2.x criterion has two size bands (4.5:1 below 18pt, and 3:1 at 18pt or 14pt bold and above); and the divergence between the two is demonstrable rather than argued.

    Nobody has demonstrated that APCA predicts reading performance better for people reading on real screens. Sam Waller of the Cambridge Engineering Design Centre built an interactive comparison of the two algorithms in 2022, published reproducible numbers, and concluded that further research is needed to say which one predicts legibility better. Treat that request as the state of the evidence: the disagreement between the two algorithms is a fact, and the ranking between them is an open question.

    For example, a team adds "migrate design tokens to APCA" to its roadmap, carries the item unfinished across three quarters, and deletes it after a procurement questionnaire asks which version of which standard the product conforms to. Ship against the standard that exists: keep the conformance claim on WCAG 2.x ratios and any Lc figure as supplementary evidence with no normative weight. If you add APCA to a lint rule, pin the reference implementation rather than porting its constants, which move between revisions.

  • A threshold does not round, and it has four exceptions you have not read.

    The common read: 4.5:1 is the accessibility bar, so a checker reporting 4.5 means the text is fine and the work is done.

    The two greys on the previous screen show that the threshold is exact. #767676 cleared by four hundredths and #777777 missed by two, and the Understanding document is explicit that the computed value is not rounded, so 4.499:1 does not meet a 4.5:1 threshold. A pass at 4.51 and a fail at 4.48 look the same to a reader.

    The criterion is narrower than its reputation, and much of a real product's text falls under its exceptions. Large-scale text drops to 3:1, and the definition is in POINTS: at least 18 point, or 14 point bold. The W3C's Understanding text gives approximately 24px for 18pt and 18.5px for 14pt. The 18.66px that appears in nearly every blog post and in several tool interfaces comes from a third party's conversion, not from a number the W3C published, and neither figure is normative because the normative unit is points.

    The same criterion has three more exceptions: incidental text, logotypes, and text that is part of an inactive user interface component. The last exception matters most, and it is undefined. The string "inactive" appears three times in the whole of WCAG 2.2, all three inside criteria, and there is no glossary entry for it. Whether a control carrying aria-disabled="true" with its click handler still attached counts as inactive is your judgement, not the specification's.

    For example, a 400-line audit spreadsheet contains the row "Settings > muted label > 4.47:1 > FAIL"; the finding is assigned to the design system team, escalated because the token is approved, and closed by darkening the token across the whole product. Treat the number as a minimum with no rounding, not as a grade. Check the states nobody checks, which the Understanding document names as placeholder, hover and focus text, and accept the limit: the criterion says nothing about whether your 14px grey is comfortable.

  • The formula overstates contrast on dark backgrounds.

    The common read: The contrast formula penalises dark backgrounds, so a dark theme has to work harder than a light one to reach the same number.

    The formula does not penalise dark themes. It overstates their contrast, which is worse, because nobody re-checks a number that passes. The best-known criticism is on a W3C wiki page and states the direction plainly: the formula is not perceptual and fails to predict contrast appropriately if the background is darker than about #aaa. The +0.05 term models screen flare, and its effect is largest where both luminances are small, which covers every dark pair in a dark theme.

    Separate two claims about dark mode, because the strong claim has no evidence behind it and the weak claim does. The white-text-on-black halation argument that everyone repeats traces to a single attributed blog comment from around 2004, and we found no peer-reviewed study behind it. The literature does support a general polarity effect: dark text on a light background produces measurably better reading performance for people with typical vision, with pupil size as the reported mechanism.

    So dark mode is a preference and an accommodation rather than an accessibility upgrade. It is an accommodation, with reading-rate evidence behind it, for people with cloudy ocular media, and a preference for everyone else. It is not a reason to skip the audit, and because the formula overstates dark contrast, a passing result in the dark theme tells you less.

    For example, a user reports "dark mode is hard to read" with no reproduction steps; three people whose monitors are brighter than the reporter's close the report as not reproducible, and it is reopened every quarter. Audit both themes as separate products, and act on the paragraph above: a dark pair that clears 4.5:1 has cleared a bar the specification's own critics say is set too low there, so add contrast above the minimum in the dark theme rather than stopping at the threshold.

  • The browser can take the palette away.

    The common read: The colours in my stylesheet are the colours on the user's screen, so auditing my palette audits what people see.

    In most products, none of the author's colours remain when Windows applies a contrast theme. In forced-colors mode the browser treats color, background-color, border-color and outline-color as if no author value were specified, forces box-shadow and text-shadow to none, and forces non-url background-image to none. Every focus ring built from a box shadow disappears. Every status dot built from a background colour is replaced by a system colour.

    The legacy media feature for this has been removed, not only deprecated. Edge removed -ms-high-contrast and -ms-high-contrast-adjust completely in version 138, June 2025, so any block in a mature codebase keyed on that media feature is dead CSS. The replacement is forced-colors, and it is a different mechanism: it does not ask you to restate your palette, it removes it.

    In three more places the browser, not your stylesheet, sets the colours. Without color-scheme: light dark the browser still paints a light canvas, light scrollbars and light form controls under a dark page, and light-dark() silently returns its first value with no error and no warning. Chrome on Android can generate its own dark theme over a light site, and the documented opt-out is color-scheme: only light. And contrast-color(), newly available since April 2026, returns black or white and ships with a warning from its own documentation.

    In that warning, the documentation for a CSS function says that the standard the function implements is not enough: mid-tone backgrounds do not provide enough contrast with either black or white, and MDN gives #2277d3 as the failing example. Computed with the WCAG formula, that blue is 4.65:1 against black and 4.52:1 against white, so both directions clear AA and neither is readable for small text. Audit the modes rather than the palette: light, dark, forced colors, and prefers-contrast: less, which almost nobody handles.