Skip to content
academia.sh

Lesson 15 / 22

Semantic Colors

Binding success, warning, error, and info meanings to color families, why the same step produces different contrast across families, auditing the notification triad against its own ground, and why color cannot be a single channel.

Contents

The previous lesson tied color to a visual task: surface, text, border, action. These roles determine how the interface looks, not what it says. In the catalog interface, color does a second job. When a borrow transaction completes, a notification appears; if a record is not on the shelf, a warning; when an invalid value is entered in the ISBN field, an error; a line of information saying a due date is approaching.

These four states are four separate things the interface tells the user, and they are marked with color. The question to ask is this: how are these colors chosen, how are they audited once chosen, and can color do this job alone?

Four Meanings, Four Families

Functional colors report status, not appearance. Four base meanings are distinguished:

  • Success. The desired outcome occurred. In the catalog interface, completion of a borrow transaction.
  • Warning. The operation can continue, but a condition needs attention. A record being available at another branch instead of on the shelf.
  • Error. The operation cannot continue; a correction is expected from the user. An invalid ISBN entry.
  • Info. A neutral notification about status; no action is needed. Reporting a due date.

The match between these four meanings and colors is not a natural relationship, it is a learned convention. The convention is not universal either; the same color reads differently across cultures. The consequence that follows from this directly shapes the system’s design: color can be used as a cue, it cannot be used alone as a carrier. The last two sections of this lesson account for this.

Each meaning gets not a color but a family. The reason is that a meaning does not appear in a single form in the interface: the same error state appears in a notification box’s pale ground, in a form field’s border, and in a line of text. All three are different steps of the same family.

The Same Step Does a Different Job Across Families

The finding from the previous lesson becomes decisive here: the same lightness value produces different relative luminance across different hues. For the four functional families, this difference directly changes which step can carry text.

// functional.mjs — auditing that semantic color families do not do the same job at the same step

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("");
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 = { 100: 92, 200: 84, 300: 74, 400: 62, 500: 50, 600: 40, 700: 31, 800: 22 };
const FAMILY = {
  success: [145, 54],
  warning: [42, 92],
  error:   [8, 68],
  info:    [214, 62],
};
const WHITE = [255, 255, 255];
const color = (family, step) => hslRgb(FAMILY[family][0], FAMILY[family][1], LIGHTNESS[step]);

console.log("family  " + Object.keys(LIGHTNESS).map((b) => b.padStart(8)).join(""));
for (const family of Object.keys(FAMILY)) {
  const row = Object.keys(LIGHTNESS)
    .map((b) => contrast(color(family, b), WHITE).toFixed(2).padStart(8))
    .join("");
  console.log(`${family.padEnd(7)} ${row}`);
}

console.log("\nfamily  text step (>=4.5:1)      fill step (>=3.0:1)");
for (const family of Object.keys(FAMILY)) {
  const steps = Object.keys(LIGHTNESS);
  const text = steps.find((b) => contrast(color(family, b), WHITE) >= 4.5);
  const fill = steps.find((b) => contrast(color(family, b), WHITE) >= 3.0);
  console.log(
    `${family.padEnd(7)} ${(text + " (" + hex(color(family, text)) + ")").padStart(24)}` +
      ` ${(fill + " (" + hex(color(family, fill)) + ")").padStart(24)}`
  );
}
family       100     200     300     400     500     600     700     800
success     1.13    1.30    1.52    1.86    2.25    3.44    5.31    8.67
warning     1.11    1.24    1.41    1.65    1.92    2.96    4.68    7.78
error       1.26    1.59    2.17    3.20    4.54    6.51    9.07   12.72
info        1.23    1.53    2.06    3.05    4.59    6.50    9.05   12.68

family  text step (>=4.5:1)      fill step (>=3.0:1)
success            700 (#247a48)            600 (#2f9d5d)
warning            700 (#986c06)            700 (#986c06)
error              500 (#d64029)            400 (#e06e5c)
info               500 (#3075cf)            400 (#6296da)

The table measures the most common setup mistake in semantic colors. If a rule is set as “let step 500 be the text color in every family,” the threshold is met in the error and info families but not in success and warning: success produces a contrast of 2.25 at step 500, warning 1.92. The difference is more than double.

Yellow-leaning hues are the extreme case. In the warning family, the 3.0 threshold required for a fill is not met at any middle step; the first step to clear it is 700 — the same as the text step. In this family, creating a step gap between fill and text requires dropping to the family’s darker end.

The reason is the channel coefficients in the relative luminance formula. The green channel’s coefficient is 0.7152, red’s is 0.2126, blue’s is 0.0722. Because a yellow hue keeps both the red and green channels high, its luminance rises fast; a blue-weighted hue, by contrast, stays much darker at the same lightness value. A step number makes no promise about contrast; every family finds its own threshold through its own computation.

The Notification Triad Is Audited Against Its Own Ground

A notification box carries three colors: a pale ground, a border, and the text that falls on the ground. The text steps found in the previous section were computed against white; the notification box’s ground, however, is not white, it is the family’s step 100.

// notification.mjs — the functional-color notification triad and separability without the hue channel

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("");
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 = { 100: 92, 200: 84, 300: 74, 400: 62, 500: 50, 600: 40, 700: 31, 800: 22, 900: 14 };
const FAMILY = { success: [145, 54], warning: [42, 92], error: [8, 68], info: [214, 62] };
const color = (family, b) => hslRgb(FAMILY[family][0], FAMILY[family][1], LIGHTNESS[b]);
const SURFACE = [255, 255, 255];

// Notification triad: pale ground (100), border (?), and text (family-specific step)
const TEXT_STEP = { success: 700, warning: 700, error: 500, info: 500 };

console.log("family  ground     text       text/ground  thr 4.5  border    border/surface  thr 3.0");
for (const family of Object.keys(FAMILY)) {
  const ground = color(family, 100);
  const text = color(family, TEXT_STEP[family]);
  const border = color(family, 200);
  const textContrast = contrast(text, ground);
  const borderContrast = contrast(border, SURFACE);
  console.log(
    `${family.padEnd(7)} ${hex(ground)}  ${hex(text)}  ${textContrast.toFixed(2).padStart(9)}:1  ` +
      `${(textContrast >= 4.5 ? "passed" : "FAILED").padStart(7)}  ${hex(border)}  ${borderContrast.toFixed(2).padStart(12)}:1  ${borderContrast >= 3.0 ? "passed" : "FAILED"}`
  );
}

// With the hue channel set aside, can the four meanings still be told apart?
console.log("\npair                  contrast  without hue channel");
const names = Object.keys(FAMILY);
for (let i = 0; i < names.length; i++) {
  for (let j = i + 1; j < names.length; j++) {
    const a = color(names[i], TEXT_STEP[names[i]]);
    const b = color(names[j], TEXT_STEP[names[j]]);
    const k = contrast(a, b);
    console.log(
      `${(names[i] + " - " + names[j]).padEnd(21)} ${k.toFixed(3).padStart(7)}:1  ${k >= 1.5 ? "separates" : "DOES NOT SEPARATE"}`
    );
  }
}

// The info role and the primary action color were built from the same hue; do they blend together?
const actionPrimary = hslRgb(214, 62, 40);
const infoText = color("info", 500);
console.log(
  `\ncontrast between action-primary (${hex(actionPrimary)}) and info text (${hex(infoText)}): ${contrast(actionPrimary, infoText).toFixed(3)}:1`
);

// Fix: text and border steps are chosen against their real grounds.
console.log("\nfamily  text step (against pale ground >=4.5)  border step (against surface >=3.0)");
for (const family of Object.keys(FAMILY)) {
  const ground = color(family, 100);
  const steps = Object.keys(LIGHTNESS).map(Number);
  const t = steps.find((b) => contrast(color(family, b), ground) >= 4.5);
  const bd = steps.find((b) => contrast(color(family, b), SURFACE) >= 3.0);
  console.log(
    `${family.padEnd(7)} ${(t + " (" + hex(color(family, t)) + ", " + contrast(color(family, t), ground).toFixed(2) + ":1)").padStart(42)}` +
      ` ${(bd + " (" + hex(color(family, bd)) + ", " + contrast(color(family, bd), SURFACE).toFixed(2) + ":1)").padStart(37)}`
  );
}
family  ground     text       text/ground  thr 4.5  border    border/surface  thr 3.0
success #e0f6e9  #247a48       4.69:1   passed  #c0ecd3          1.30:1  FAILED
warning #fdf2d8  #986c06       4.21:1   FAILED  #fce5b1          1.24:1  FAILED
error   #f8e0dd  #d64029       3.61:1   FAILED  #f2c2ba          1.59:1  FAILED
info    #dee9f7  #3075cf       3.74:1   FAILED  #bdd3ef          1.53:1  FAILED

pair                  contrast  without hue channel
success - warning       1.134:1  DOES NOT SEPARATE
success - error         1.170:1  DOES NOT SEPARATE
success - info          1.157:1  DOES NOT SEPARATE
warning - error         1.032:1  DOES NOT SEPARATE
warning - info          1.020:1  DOES NOT SEPARATE
error - info            1.012:1  DOES NOT SEPARATE

contrast between action-primary (#275ea5) and info text (#3075cf): 1.415:1

family  text step (against pale ground >=4.5)  border step (against surface >=3.0)
success                      700 (#247a48, 4.69:1)                 600 (#2f9d5d, 3.44:1)
warning                      800 (#6c4d04, 6.99:1)                 700 (#986c06, 4.68:1)
error                        600 (#ab3321, 5.17:1)                 400 (#e06e5c, 3.20:1)
info                         600 (#275ea5, 5.29:1)                 400 (#6296da, 3.05:1)

The first table misses the text threshold in three of the four notifications. The cause is not a computation error, it is computing against the wrong ground: the steps were chosen against white, but the text falls not on white but on the family’s pale ground. Because the pale ground is slightly darker than white, the ratio between text and ground narrows.

The border column gives a sharper result. All four families’ step 200 produces a contrast between 1.24 and 1.59 against the surface; none comes close to the 3.0 threshold. A pale border does not do the job of announcing the notification box’s boundary.

The last table gives the fix. When text steps are reselected against their own grounds, warning moves to 800, error and info to 600; success stays at 700 because it already passed. Border steps, meanwhile, spread between 400 and 700 across families — giving a single step number for all four families is, again, not possible.

Written as a rule: every contrast computation is done against the ground the color will actually fall on. A value computed against white is not valid on a ground that is not white.

Color Cannot Be a Single Channel

The second table turns the convention warning made at the start of this lesson into a number. The pairwise contrast ratios of the four functional text colors fall between 1.012 and 1.170. All are very close to 1, meaning the four colors sit at almost the same point on the relative luminance axis.

This means that when the hue channel is disabled, the four meanings collapse into a single color. The hue channel being disabled is not an exceptional case — a reader with limited color distinction, a grayscale output, a low-saturation screen in sunlight, and high-contrast mode are all instances of it.

The same result is written into the WCAG 1.4.1 criterion: color cannot be the only visual means of conveying information. The criterion is not a design recommendation, it is a conformance requirement.

In the catalog interface, this means every functional state carries at least one channel in addition to color:

  • Text. The notification states the status in words. The difference between “Borrow completed” and “This record is not on the shelf” is not read from color, it is read from the text.
  • Icon. Each state is assigned a separate shape, and the shape is distinguishable independent of color. Under what conditions an icon carries meaning is the question of the Icon Usage lesson in the Component States topic.
  • Position. A field-level error message sits directly below the field; the space between it and its field is smaller than the space between it and other fields.

Having at least one of these three channels present alongside color both meets the criterion and continues the two-channel rule set in the first lesson.

The Info Role Collides With the Action Color

The output’s third block catches a collision that was overlooked while the system was being built. The info family and the primary family established in the previous lesson were generated from the same hue. The contrast between the info text and the primary action color is 1.415:1; the two colors are too close to be told apart.

The fix table sharpens the situation further: when info text is moved to its correct step, its value becomes #275ea5 — the exact same as the primary action color. A clickable action and an unclickable info line appear in the same color in the interface.

This is the cost of two roles sharing the same hue in the color system. There are three solutions, and the choice is made according to the interface’s priorities.

The first is to move the info family to a different hue; but the convention for the info meaning has settled on bluish hues, and leaving that hue weakens the meaning.

The second is to move the primary action color instead. If the action color carries no convention — and in the catalog interface it does not — it is the one that can be moved.

The third is to move the distinction to a channel other than color. The primary action carries white text on a filled surface; the info line is colored text on a plain ground. The surface channel already separates the two, and the hue collision never turns into a visible problem.

In the catalog interface, the third path is chosen, because the distinction is already in place and it is consistent with the previous lesson’s finding: action hierarchy is built on surface difference, not hue difference. This choice, however, comes with a condition, and the condition has to be written down: the info color cannot be used as a filled surface anywhere. The moment it is, the collision becomes visible.

Summary

  • Functional colors report status, not appearance; success, warning, error, and info are the four base meanings, and each gets not a single value but a family.
  • The relationship between meaning and color is a learned convention; this is why color can be a cue but cannot be the sole carrier.
  • A step number makes no promise about contrast; yellow-leaning hues produce much higher luminance at the same lightness value and meet the threshold only at the dark end.
  • Every contrast computation is done against the ground the color will actually fall on; a text color computed against white can miss the threshold on a pale ground.
  • The four semantic colors sit very close to each other on the relative luminance axis; when the hue channel is disabled, all of them collapse into the same color, which is why at least one of the text, icon, or position channels accompanies color.
  • Two roles generated from the same hue produce a collision; the collision is resolved either by moving a hue or by handing the distinction to the surface channel, and if the second option is chosen, the condition is written down.

Next Step

Throughout the last two lessons, the numbers 4.5 and 3.0 appeared in every computation, but where they come from was not said. These thresholds are not numbers the course chose arbitrarily; they are criteria with a defined measurement method, scope, and exceptions. The next lesson takes up which criterion these thresholds come from, why large text is subject to a separate threshold, which rule non-text elements fall under, and how a whole interface can be audited against these thresholds in bulk.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close