---
title: 'Contrast and Accessibility'
source: 'https://academia.sh/en/courses/interface-fundamentals/contrast-and-accessibility'
course: 'Fundamentals of Interface Design'
language: en
updated: '2026-08-17T18:11:57+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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 = \frac{L_1 + 0.05}{L_2 + 0.05}
$$

Here $L_1$ is the relative luminance of the lighter color, $L_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.

```js
// 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.

```js
// 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.
