Skip to content
academia.sh

Lesson 16 / 22

Contrast and Accessibility

The definition and limits of the contrast ratio formula, working backward from threshold to color, the discontinuity of the large-text boundary, and a bulk audit of the whole catalog interface.

Contents

In the last two lessons, the numbers 4.5 and 3.0 appeared in every computation. These numbers are not criteria the course chose arbitrarily; they are thresholds with a defined measurement method, defined scope, and defined exemptions. This lesson takes up where the thresholds come from, at what point they constrain design, and how the whole catalog interface is audited against them.

The two words in the lesson’s name point to two separate things. Contrast is the perceptual principle introduced in the first lesson: the difference that separates an element from its surroundings. Contrast ratio is the measured quantity of that difference. Contrast is a design tool and can be built in size, weight, shape, and position too; contrast ratio only gives the relationship between two colors’ relative luminances.

The Formula and Its Limits

Contrast ratio is computed from two colors’ relative luminances:

K=L1+0.05L2+0.05K = \frac{L_1 + 0.05}{L_2 + 0.05}

Here L1L_1 is the relative luminance of the lighter color, L2L_2 that of the darker one. The 0.05 constant in the denominator represents ambient reflection off the screen surface; without this constant, the ratio would go to infinity because black’s luminance is zero.

// threshold.mjs — the contrast ratio formula's limits, working backward from threshold to color, and the large-text boundary

function channel(v) {
  const s = v / 255;
  return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
}
const luminance = ([r, g, b]) => 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
function contrast(a, b) {
  const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p);
  return (x + 0.05) / (y + 0.05);
}
function hslRgb(h, s, l) {
  s /= 100; l /= 100;
  const k = (n) => (n + h / 30) % 12;
  const a = s * Math.min(l, 1 - l);
  const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
  return [f(0), f(8), f(4)].map((v) => Math.round(v * 255));
}
const hex = (rgb) => "#" + rgb.map((v) => v.toString(16).padStart(2, "0")).join("");

const BLACK = [0, 0, 0];
const WHITE = [255, 255, 255];
console.log(`luminance(black) = ${luminance(BLACK).toFixed(4)}   luminance(white) = ${luminance(WHITE).toFixed(4)}`);
console.log(`contrast(black, white) = ${contrast(BLACK, WHITE).toFixed(2)}:1   (formula's upper bound)`);
console.log(`contrast(white, white) = ${contrast(WHITE, WHITE).toFixed(2)}:1   (lower bound)`);

// Working backward: the highest relative luminance meeting a threshold on a white ground
console.log("\nthreshold  highest allowed relative luminance  equivalent gray (0-255)");
function fromGray(l) {
  let low = 0, high = 255;
  for (let i = 0; i < 40; i++) {
    const mid = (low + high) / 2;
    if (luminance([mid, mid, mid]) < l) low = mid; else high = mid;
  }
  return (low + high) / 2;
}
for (const threshold of [3.0, 4.5, 7.0]) {
  const highest = 1.05 / threshold - 0.05;
  console.log(`${threshold.toFixed(1).padStart(9)}  ${highest.toFixed(4).padStart(31)}  ${fromGray(highest).toFixed(1).padStart(24)}`);
}

// The formula sees only luminance: colors at different hues that give the same ratio
console.log("\nhue  lightness  hex        relative luminance  against white");
const TARGET = 1.05 / 4.5 - 0.05;
for (const hue of [8, 42, 145, 214, 268]) {
  let low = 0, high = 100;
  for (let i = 0; i < 40; i++) {
    const mid = (low + high) / 2;
    if (luminance(hslRgb(hue, 60, mid)) < TARGET) low = mid; else high = mid;
  }
  const l = (low + high) / 2;
  const rgb = hslRgb(hue, 60, l);
  console.log(
    `${String(hue).padStart(3)} ${l.toFixed(2).padStart(8)}%  ${hex(rgb)}  ${luminance(rgb).toFixed(4).padStart(15)}  ${contrast(rgb, WHITE).toFixed(2).padStart(11)}:1`
  );
}

// WCAG "large text" boundary: 18pt, or bold 14pt
const PT_PX = 96 / 72;
console.log(`\n18pt = ${(18 * PT_PX).toFixed(2)} px, 14pt = ${(14 * PT_PX).toFixed(2)} px`);
const isLarge = (px, weight) => px >= 18 * PT_PX || (weight >= 700 && px >= 14 * PT_PX);
console.log("px  weight  large text  applicable threshold");
for (const [px, w] of [[16, 400], [18, 700], [19, 700], [20, 400], [23, 400], [24, 400], [31, 700]]) {
  const b = isLarge(px, w);
  console.log(`${String(px).padStart(2)} ${String(w).padStart(6)}  ${(b ? "yes" : "no").padStart(11)}  ${(b ? "3.0" : "4.5").padStart(20)}`);
}
luminance(black) = 0.0000   luminance(white) = 1.0000
contrast(black, white) = 21.00:1   (formula's upper bound)
contrast(white, white) = 1.00:1   (lower bound)

threshold  highest allowed relative luminance  equivalent gray (0-255)
      3.0                           0.3000                     148.9
      4.5                           0.1833                     118.7
      7.0                           0.1000                      89.0

hue  lightness  hex        relative luminance  against white
  8    51.12%  #cd4c38           0.1843         4.48:1
 42    35.91%  #927225           0.1828         4.51:1
145    33.21%  #22884c           0.1847         4.47:1
214    50.49%  #3577cd           0.1836         4.50:1
268    59.44%  #935ad6           0.1837         4.49:1

18pt = 24.00 px, 14pt = 18.67 px
px  weight  large text  applicable threshold
16    400           no                   4.5
18    700           no                   4.5
19    700          yes                   3.0
20    400           no                   4.5
23    400           no                   4.5
24    400          yes                   3.0
31    700          yes                   3.0

The first block gives the formula’s range: lowest 1:1, highest 21:1. This range is a ratio measure, not a percentage; the gap between 10:1 and 20:1 is the same factor as the gap between 2:1 and 4:1, but it does not look the same size to the eye. The ratio itself is not a linear measure of perception.

The second block gives the reverse computation, and it is used directly on the design side. A text color that meets the 4.5 threshold on a white ground can have a relative luminance of at most 0.1833; its gray equivalent is roughly 118.7 out of 255. This is the numeric answer to the question “how light can the text color be,” and it serves to generate a color directly instead of testing one.

The third block shows the formula’s most important limit. Five different hues all produce roughly 4.5 contrast against white; but the HSL lightness values that get them there spread between 33.21 percent and 59.44 percent. The formula does not see hue, it only sees relative luminance. This has two consequences: hitting the same threshold at different hues requires different lightness, and two colors clearing the threshold does not show that they are distinguishable from each other — this is the general form of the previous lesson’s finding.

The fourth block shows another limit, a discontinuity. The criterion splits text into two buckets: large and not large. The boundary is 18 points, or 14 points bold; their pixel equivalents are 24 and 18.67. The consequence of this is that 18-pixel bold text is subject to the 4.5 threshold while 19-pixel bold text is subject to the 3.0 threshold. A one-pixel size change lowers the required contrast by a third. The criterion is not a continuous function, it is a stepped rule.

Where Each Threshold Comes From

The thresholds are defined in the WCAG criteria. Four criteria directly bind this course’s decisions:

  • 1.4.1 Use of Color. Color cannot be the only visual means of conveying information, indicating an action, or distinguishing a response. The text, icon, and position channels from the previous lesson are the counterpart of this criterion. This criterion has no threshold; it is a structural requirement.
  • 1.4.3 Contrast (Minimum). At least 4.5:1 for text and images of text; 3:1 for large text.
  • 1.4.6 Contrast (Enhanced). 7:1 for the same distinction, 4.5:1 for large text. This is the higher level; the catalog interface’s body text already meets it at 15.74:1 on neutral step 900, but secondary text does not meet it.
  • 1.4.11 Non-text Contrast. At least 3:1 between the visual information needed to identify a component or its state and the adjacent colors. Border, focus ring, and icon strokes fall under this criterion.

There are two exemptions, and both are decision points. Inactive components — that is, disabled controls — are out of scope for 1.4.3. Text that is purely decorative and carries no information is also out of scope. An exemption is not a permit: if text cannot be read, the interface is failing to give that information even if the criterion exempts it.

Auditing the Whole Interface

Looking at matches one by one is not enough, because a missed match goes unnoticed. The audit is done by listing every foreground–ground pair that actually occurs in the interface.

// audit.mjs — bulk audit of the whole catalog interface against the thresholds

function hslRgb(h, s, l) {
  s /= 100; l /= 100;
  const k = (n) => (n + h / 30) % 12;
  const a = s * Math.min(l, 1 - l);
  const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
  return [f(0), f(8), f(4)].map((v) => Math.round(v * 255));
}
function channel(v) {
  const s = v / 255;
  return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
}
const luminance = ([r, g, b]) => 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
function contrast(a, b) {
  const [x, y] = [luminance(a), luminance(b)].sort((p, q) => q - p);
  return (x + 0.05) / (y + 0.05);
}

const LIGHTNESS = { "000": 100, "050": 97, 100: 92, 200: 84, 300: 74, 400: 62, 500: 50, 600: 40, 700: 31, 800: 22, 900: 14 };
const FAMILY = { neutral: [214, 8], primary: [214, 62], error: [8, 68] };
const color = (family, b) => hslRgb(FAMILY[family][0], FAMILY[family][1], LIGHTNESS[b]);

const ROLE = {
  "surface":            ["neutral", "000"],
  "surface-secondary":  ["neutral", "050"],
  "border":             ["neutral", "500"],
  "text-primary":       ["neutral", "900"],
  "text-secondary":     ["neutral", "600"],
  "text-disabled":      ["neutral", "400"],
  "action-primary":     ["primary", "600"],
  "action-primary-on":  ["neutral", "000"],
  "focus-ring":         ["primary", "500"],
  "selected-ground":    ["primary", "100"],
  "error-text":         ["error", "600"],
};

const PT_PX = 96 / 72;
const isLarge = (px, weight) => px >= 18 * PT_PX || (weight >= 700 && px >= 14 * PT_PX);

// type: "text" | "non-text"
const AUDIT = [
  ["page title",             "text-primary",      "surface",           "text", 31, 700],
  ["record title",           "text-primary",      "surface",           "text", 20, 600],
  ["author name",            "text-secondary",    "surface",           "text", 16, 400],
  ["metadata (shelf code)",  "text-secondary",    "surface-secondary", "text", 13, 400],
  ["borrow button",          "action-primary-on", "action-primary",    "text", 16, 600],
  ["search placeholder",     "text-disabled",     "surface",           "text", 16, 400],
  ["disabled button",        "text-disabled",     "surface-secondary", "text", 16, 600],
  ["error message",          "error-text",        "surface",           "text", 13, 400],
  ["field border",           "border",            "surface",           "non-text", 0, 0],
  ["focus ring",             "focus-ring",        "surface",           "non-text", 0, 0],
  ["selected row ground",    "selected-ground",   "surface",           "non-text", 0, 0],
  ["author in selected row", "text-secondary",    "selected-ground",   "text", 16, 400],
];

console.log("item                    foreground        ground              type      threshold  measured  result");
let remaining = 0;
for (const [item, fg, ground, type, px, weight] of AUDIT) {
  const threshold = type === "non-text" ? 3.0 : isLarge(px, weight) ? 3.0 : 4.5;
  const k = contrast(color(...ROLE[fg]), color(...ROLE[ground]));
  const passed = k >= threshold;
  if (!passed) remaining++;
  console.log(
    `${item.padEnd(23)} ${fg.padEnd(17)} ${ground.padEnd(19)} ${type.padEnd(9)} ${threshold.toFixed(1).padStart(9)} ${k.toFixed(2).padStart(9)}  ${passed ? "passed" : "FAILED"}`
  );
}
console.log(`\ntotal ${AUDIT.length} matches, ${remaining} do not clear the threshold`);

// For the remaining ones: the first step in the same family that clears the threshold
console.log("\nremaining match          current step  lightest step required");
for (const [item, fg, ground, type, px, weight] of AUDIT) {
  const threshold = type === "non-text" ? 3.0 : isLarge(px, weight) ? 3.0 : 4.5;
  if (contrast(color(...ROLE[fg]), color(...ROLE[ground])) >= threshold) continue;
  const [family] = ROLE[fg];
  const groundRgb = color(...ROLE[ground]);
  const fit = Object.keys(LIGHTNESS).find((b) => contrast(color(family, b), groundRgb) >= threshold);
  console.log(
    `${item.padEnd(25)} ${(ROLE[fg][0] + "-" + ROLE[fg][1]).padEnd(16)} ${fit ? family + "-" + fit + " (" + contrast(color(family, fit), groundRgb).toFixed(2) + ":1)" : "not in family"}`
  );
}

// If the selected row's ground is darkened, what happens to the text on it?
console.log("\nselected ground  ground/surface  text on top (neutral-600)  text threshold 4.5");
for (const step of ["100", "200", "300", "400"]) {
  const ground = color("primary", step);
  const groundContrast = contrast(ground, color(...ROLE["surface"]));
  const textContrast = contrast(color("neutral", "600"), ground);
  console.log(
    `${("primary-" + step).padEnd(16)} ${groundContrast.toFixed(2).padStart(9)}:1 ${textContrast.toFixed(2).padStart(28)}:1  ${textContrast >= 4.5 ? "passed" : "FAILED"}`
  );
}
item                    foreground        ground              type      threshold  measured  result
page title              text-primary      surface             text            3.0     15.74  passed
record title            text-primary      surface             text            4.5     15.74  passed
author name             text-secondary    surface             text            4.5      5.89  passed
metadata (shelf code)   text-secondary    surface-secondary   text            4.5      5.51  passed
borrow button           action-primary-on action-primary      text            4.5      6.50  passed
search placeholder      text-disabled     surface             text            4.5      2.74  FAILED
disabled button         text-disabled     surface-secondary   text            4.5      2.56  FAILED
error message           error-text        surface             text            4.5      6.51  passed
field border            border            surface             non-text        3.0      4.11  passed
focus ring              focus-ring        surface             non-text        3.0      4.59  passed
selected row ground     selected-ground   surface             non-text        3.0      1.23  FAILED
author in selected row  text-secondary    selected-ground     text            4.5      4.80  passed

total 12 matches, 3 do not clear the threshold

remaining match          current step  lightest step required
search placeholder        neutral-400      neutral-600 (5.89:1)
disabled button           neutral-400      neutral-600 (5.51:1)
selected row ground       primary-100      primary-400 (3.05:1)

selected ground  ground/surface  text on top (neutral-600)  text threshold 4.5
primary-100           1.23:1                         4.80:1  passed
primary-200           1.53:1                         3.85:1  FAILED
primary-300           2.06:1                         2.86:1  FAILED
primary-400           3.05:1                         1.94:1  FAILED

Three of the twelve matches remain, and the fix for each of the three is different. The audit’s value is not finding failures, it is showing what kind of failure each one is.

Three Failures, Three Different Decisions

The search field’s placeholder text misses the threshold at a 2.74 contrast ratio. The table gives the fix: neutral step 600 produces 5.89. But this fix creates a new problem — at that step, the placeholder text becomes indistinguishable from real input text, and the user assumes the field is filled. The right decision is not to darken the color, it is to stop using the placeholder as a label. The field’s name stays above the field as a persistent label; the placeholder is either removed or shows only a format example. The Label-Field Relationship lesson in the Web Fundamentals and HTML course reached the same conclusion from the accessibility side; here the same conclusion is reached from the contrast computation.

The disabled button carries a 2.56 contrast ratio, and criterion 1.4.3 exempts inactive components. So this row is not a conformance violation. It is, however, still a decision point: if the text on a disabled button cannot be read, the user cannot know what the button does or why it cannot be used. In the catalog interface, the “Borrow” button stays disabled while a record is not on the shelf; what the user needs to read is exactly that button’s text and the explanation next to it. An exemption does not require low contrast; it only permits it. The decision is to keep the button active and say why it does not work, rather than disabling it.

The selected row’s ground falls short at a ratio of 1.23. The last table shows why fixing it by darkening does not work: when the ground moves to primary step 400, it clears the threshold at 3.05, but the secondary text on top of it drops to 1.94, producing a far more severe violation. Darkening the ground trades one problem for another.

The fix is hidden in the wording of criterion 1.4.11 itself: the threshold is required for the visual information needed to identify a state. If the selected state is announced only by the ground color, the ground carries that information and is subject to the threshold. If a second indicator is added to the state — a vertical bar at the start of the row that clears a 3:1 threshold — the ground stops being the sole carrier and can stay pale. The rule from the previous lesson becomes, here, the way to meet the threshold: a second channel produces not only resilience but also conformance.

Where the Audit Belongs

Doing this audit while the system is being built is cheaper than doing it after components are written. When the role table and the match list are kept together, the audit reduces to a data-processing task: the input is role names, the output is which matches pass and which remain.

Writing the match list by hand is a weakness — if a new match is added to the interface and not added to the list, the audit will not see it. What has to happen on the design side is that the list exists, and every new role match gets written into it.

Summary

  • Contrast ratio is the 0.05-offset ratio of two relative luminances; its range runs from 1:1 to 21:1, and it is not a linear measure of perception.
  • A reverse computation from threshold to color is possible: a 4.5 threshold on a white ground allows a text color with a relative luminance of at most 0.1833.
  • The formula does not see hue; colors that give the same ratio sit at very different lightness values, and clearing the threshold does not mean they are distinguishable from each other.
  • The large-text boundary is a stepped rule; the required contrast drops from 4.5 to 3.0 between 18 and 19 pixels.
  • The audit is not done one match at a time but over every foreground–ground pair that actually occurs in the interface; a missed match goes unnoticed.
  • Not every failure is fixed by darkening a color: in some cases an element is removed, in others a second indicator is added so color stops being the sole carrier.

Next Step

This lesson audited every match against a single ground family: light surfaces and dark text. When the same interface runs in a dark theme, this entire relationship inverts. Inverting is not as mechanical as it looks; swapping the ramp’s steps one-for-one does not preserve contrast ratios, and semantic colors miss the threshold on a dark ground. The next lesson builds the dark theme not as a second color list but as a transformation, defines the transformation’s preservation condition, and runs the transformation through the same audit.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close