Skip to content
academia.sh

Lesson 12 / 27

Color and Contrast

A batch audit of component states against contrast thresholds, the focus ring's test against two neighbors at once, why color cannot be a single channel, and the thresholds that text scaling introduces.

Contents

The previous lesson established how information is conveyed through non-visual channels. The visual channel’s counterpart question is this: can a piece of text on screen, or a component’s boundary, be distinguished from what surrounds it? The answer to this question depends on a measurable quantity, and it is the most direct of the countable criteria on this quality axis.

The definition, formula, and bounds of the contrast ratio were established in the Contrast and Accessibility lesson of the Fundamentals of Interface Design course: the 0.05-offset ratio of two colors’ relative luminances ranges from 1:1 to 21:1, is blind to hue, and is not a linear perceptual measure. This lesson uses the same formula on the implementation side and takes up two places a design audit skips: a component’s states and the focus indicator’s two neighbors.

States Are Audited Too

When a color palette is audited, what gets audited is usually the default state: text color, background color, button fill. But a component does not sit on screen with a single pair of colors. When the button is hovered, the fill changes; when it becomes disabled, both fill and text change; when it is focused, a ring appears around it. Every state is a new color pair, and every pair is a separate match to check.

The script below audits the component states of the measurement list interface.

// contrast.mjs — contrast audit of component states and the focus ring's two-sided test

const rgb = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
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(rgb(a)), luminance(rgb(b))].sort((p, q) => q - p);
  return (x + 0.05) / (y + 0.05);
}

const COLOR = {
  surface: "#ffffff",
  surfaceSecondary: "#f2f4f7",
  text: "#16202a",
  textSecondary: "#5b6b7a",
  textDisabled: "#9aa7b2",
  action: "#1f5fa8",
  onAction: "#ffffff",
  actionEmphasis: "#17497f",
  alertSurface: "#fdf0c8",
  alertText: "#6b4e00",
  border: "#c3ccd6",
  sortArrow: "#b4bcc4",
};

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

// [name, foreground, background, kind, px, weight]
const STATE = [
  ["button default", "onAction", "action", "text", 16, 600],
  ["button hover", "onAction", "actionEmphasis", "text", 16, 600],
  ["button disabled", "textDisabled", "surfaceSecondary", "text", 16, 600],
  ["filter label", "text", "surface", "text", 16, 400],
  ["filter hint", "textSecondary", "surface", "text", 13, 400],
  ["table heading", "text", "surfaceSecondary", "text", 14, 700],
  ["row text", "text", "surface", "text", 16, 400],
  ["alert badge", "alertText", "alertSurface", "text", 13, 600],
  ["field border", "border", "surface", "non-text", 0, 0],
  ["sort indicator", "sortArrow", "surfaceSecondary", "non-text", 0, 0],
];

console.log("state                foreground background threshold measured  result");
let failing = 0;
for (const [name, fg, bg, kind, px, weight] of STATE) {
  const threshold = kind === "non-text" ? 3.0 : isLarge(px, weight) ? 3.0 : 4.5;
  const k = contrast(COLOR[fg], COLOR[bg]);
  if (k < threshold) failing++;
  console.log(
    name.padEnd(20) + " " + COLOR[fg].padEnd(11) + COLOR[bg].padEnd(11) +
      threshold.toFixed(1).padStart(6) + k.toFixed(2).padStart(9) + "  " + (k >= threshold ? "passed" : "FAILED"),
  );
}
console.log(`${failing} of ${STATE.length} matches fail the threshold`);

// The focus ring faces two neighbors at once: the inner fill and the outer surface
console.log("\nsingle-color focus ring, two-sided test (threshold 3.0):");
console.log("ring       vs fill        vs surface    result");
for (const ring of ["#2a7de1", "#16202a", "#ffffff", "#000000"]) {
  const a = contrast(ring, COLOR.action);
  const b = contrast(ring, COLOR.surface);
  console.log(
    ring.padEnd(11) + a.toFixed(2).padStart(13) + b.toFixed(2).padStart(14) +
      "  " + (a >= 3 && b >= 3 ? "passed" : "FAILED"),
  );
}

// Which grays pass both sides at once
const okGrays = [];
for (let v = 0; v <= 255; v++) {
  const g = "#" + v.toString(16).padStart(2, "0").repeat(3);
  if (contrast(g, COLOR.action) >= 3 && contrast(g, COLOR.surface) >= 3) okGrays.push(v);
}
console.log(`\ngrays passing both sides at once: ${okGrays.length} / 256`);
console.log(`  permitted range: ${okGrays[0]}–${okGrays[okGrays.length - 1]}`);

// Two-band ring: each band faces its own neighbor
const innerBand = "#ffffff", outerBand = "#16202a";
console.log("\ntwo-band ring:");
console.log(`  inner band ${innerBand} — vs fill (${COLOR.action}) ${contrast(innerBand, COLOR.action).toFixed(2)}`);
console.log(`  outer band ${outerBand} — vs surface (${COLOR.surface}) ${contrast(outerBand, COLOR.surface).toFixed(2)}`);
console.log(`  between bands ${contrast(innerBand, outerBand).toFixed(2)}`);

// 1.4.1: is the information carried by color alone?
console.log("\nmeasurement attribute indicators (1.4.1):");
for (const [name, channels] of [
  ["threshold exceeded", ["color"]],
  ["device error", ["color", "icon", "text"]],
  ["manually entered", ["color"]],
  ["verified", ["color", "text"]],
]) {
  const singleChannel = channels.length === 1 && channels[0] === "color";
  console.log(`  ${name.padEnd(19)} channels: ${channels.join(", ").padEnd(22)} ${singleChannel ? "FAILED" : "passed"}`);
}
state                foreground background threshold measured  result
button default       #ffffff    #1f5fa8       4.5     6.44  passed
button hover         #ffffff    #17497f       4.5     9.15  passed
button disabled      #9aa7b2    #f2f4f7       4.5     2.23  FAILED
filter label         #16202a    #ffffff       4.5    16.48  passed
filter hint          #5b6b7a    #ffffff       4.5     5.48  passed
table heading        #16202a    #f2f4f7       4.5    14.96  passed
row text             #16202a    #ffffff       4.5    16.48  passed
alert badge          #6b4e00    #fdf0c8       4.5     6.80  passed
field border         #c3ccd6    #ffffff       3.0     1.62  FAILED
sort indicator       #b4bcc4    #f2f4f7       3.0     1.74  FAILED
3 of 10 matches fail the threshold

single-color focus ring, two-sided test (threshold 3.0):
ring       vs fill        vs surface    result
#2a7de1             1.57          4.10  FAILED
#16202a             2.56         16.48  FAILED
#ffffff             6.44          1.00  FAILED
#000000             3.26         21.00  passed

grays passing both sides at once: 14 / 256
  permitted range: 0–13

two-band ring:
  inner band #ffffff — vs fill (#1f5fa8) 6.44
  outer band #16202a — vs surface (#ffffff) 16.48
  between bands 16.48

measurement attribute indicators (1.4.1):
  threshold exceeded  channels: color                  FAILED
  device error        channels: color, icon, text      passed
  manually entered    channels: color                  FAILED
  verified            channels: color, text            passed

The hover state passes, because when the fill darkens, the white text’s contrast increases. An emphasis in the opposite direction — a hover state that lightens the fill — would miss the threshold with the same text; this is the reason the list of states is audited.

Three Failures

The disabled button fails with a ratio of 2.23. The criterion excludes non-enabled components from scope, so this row is not a conformance failure. But the rule established in the Keyboard Access lesson adds a second cost here: a disabled control does not take focus either. The user can neither read the button nor navigate to it; there is no way left to learn why it does not work. Even though the criterion permits it, the right decision is to keep the button enabled and report the reason when it is pressed.

The field border fails with a ratio of 1.62, and this is a 1.4.11 failure. The border is the only thing that shows where the search field in the filter panel begins and ends: when the field is empty it has no text inside, and its background matches the surface. If the border is not visible, the field is not visible either.

The sort indicator fails with a ratio of 1.74. The arrow that reports which direction a column is sorted in is non-text visual information, and it is required to identify the state. In the previous lesson this information was written into the tree with the aria-sort declaration; for a sighted user on screen, its counterpart is the arrow itself, and the two are two channels of the same information.

The Focus Ring’s Two Neighbors

The focus indicator raises a problem unlike anything else: it is drawn around an element and sits on the boundary between two different colors. Inside is the component’s fill, outside is the page’s surface. Because the criterion requires contrast against adjacent colors, the test is two-sided.

The output’s second section runs four candidate rings through this test. A blue ring related to the component’s own primary color scores 1.57 against the fill — invisible on top of the button. A dark ring scores 16.48 against the surface but falls below the threshold at 2.56 against the fill. A white ring is the reverse. Only pure black passes both sides at once.

The gray sweep shows how narrow the margin is: of 256 shades of gray, only 14 — the ones from 0 to 13 — provide 3:1 against both the button fill and the white surface. This means a single-color focus ring practically has to be close to black — and if the palette has more than one fill color, the range narrows further, and often empties out entirely.

The solution is not leaving the ring as a single color. In a two-band ring, the inner band faces the component’s fill and the outer band faces the page’s surface; each band only has to meet the threshold against its own neighbor. The output’s last section shows this: the inner band scores 6.44 against the fill, the outer band 16.48 against the surface. The ring is now visible on both sides, and when the component’s fill color changes, only the inner band needs to be re-audited.

Color Cannot Be a Single Channel

The last section applies criterion 1.4.1, and it is independent of the contrast computation: if a piece of information is conveyed through a distinction carried by color alone, that information does not reach a user who cannot distinguish that color. Two of the four attribute indicators in the measurement table are reported by row color alone.

There are three ways to add a second channel: text (writing “threshold exceeded” inside the badge), shape (placing a mark at the start of the row), and position (grouping flagged measurements into a separate section). Text is the most reliable, because it also reaches the accessibility tree — the second visual channel and the declaration in the tree are provided in a single move.

An audit rule follows from this: alongside every color indicator, there must be a channel that can convey the same information in a colorless document. The test is simple — when the interface’s screenshot is converted to grayscale, which information disappears?

Assuming Text Will Scale

Two more criteria are measurable and fall under the same audit as contrast. Criterion 1.4.4 requires that text be resizable up to 200 percent without losing content or function in the process. Criterion 1.4.12 requires that the user be able to increase line height, paragraph spacing, and letter and word spacing to specific values without text being clipped.

Both have the same counterpart on the implementation side: the height of containers that hold text is not fixed. A fixed-height badge clips text at double the font size; a declaration that hides overflow makes the clipped text unreachable. The content-based sizing from the Layout Systems and Responsive Design course is the direct counterpart of these two criteria.

Summary

  • Contrast auditing cannot stop at the default state; every state of a component is a new color pair and is audited as a separate match.
  • Disabled components are exempt from the contrast criterion, but because they also cannot take focus, the exemption is usually a cover for the wrong decision.
  • Non-text visual information like a field border or a sort indicator is subject to the 3:1 threshold; these are the only signal that identifies a component’s presence or state.
  • The focus ring sits on the boundary between two neighboring colors; a single-color ring passing both sides at once is possible only in a very narrow range, and a two-band ring tests each band against its own neighbor.
  • Color cannot be the sole carrier; a second channel is text, shape, or position, and choosing text reaches the accessibility tree in the same move.
  • Text being resizable to double size and its spacing being increasable are measurable requirements; their counterpart is avoiding fixed-height containers.

Next Step

Every audit in this lesson ran through a script: color pairs went in, passing and failing matches came out. The same was true for part of the previous lessons — the accessible name computation, tab order, and plane defects were also computable. This raises an unavoidable question: how much of accessibility can be audited automatically? The next lesson gives a numeric answer to this question, names the classes of criteria an automated checker cannot see by structure, and establishes the order in which manual verification is done.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close