Lesson 14 / 22
Color System
Defining color as role, generating a ramp from a single hue along the lightness axis, binding roles to steps, and auditing matches through relative luminance.
Contents
The previous three lessons fixed the form of the text. On the color side, only two values remain from the first lesson: dark gray text and a white ground. The catalog interface cannot run on this. That the borrow button is the primary action, that a selected record is selected, that a record is not on the shelf — all of these need separate surfaces and separate text colors.
The real risk here is not choosing colors, it is choosing colors one by one. If a color is added every time a need arises, the interface soon carries dozens of values close to each other, and a list forms that nobody knows where to use which of. Just as the scale solved this problem in typography, the color system solves it the same way: values are not chosen one by one, they are derived; and where they are used is named not by value but by role.
Color Carries a Role, Not a Value
#275ea5 is a value. “The primary action’s ground” is a role. The difference is that
the second one stays true even when the interface changes.
In a system named by value — “blue,” “light blue,” “dark blue” — three problems arise. First, the same value gets used in unrelated places, and it cannot later be separated out when only one of them needs to change. Second, when the hue changes, every name starts lying; a constant named “blue” gets set to green. Third, in a dark theme the values invert, and a name like “light blue” points to a dark color. Dark theme is the subject of this course’s seventh lesson, and that is where role naming pays back its cost.
Role naming is built from three parts: the color of what (surface, text, border,
action), at which level (primary, secondary), and if needed, on top of what (an -on
suffix). The role of text that sits on a given role is named separately, because that
text is audited together with its ground.
A Ramp Is Derived From a Single Hue
The steps that make up a family are not chosen one by one. Hue and saturation are held constant, and only lightness changes. This approach was introduced in the Visual Presentation with CSS course; here, the relative luminance value of every step in the generated ramp is also computed, because that is the criterion for matching roles to each other.
// ramp.mjs — a color ramp along the lightness axis from a single hue, and the ramp's relative luminance // HSL -> RGB (the same conversion as the CSS color notation lesson) 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(""); // WCAG relative luminance and contrast ratio 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 STEP = [ { name: "000", l: 100 }, { name: "050", l: 97 }, { name: "100", l: 92 }, { name: "200", l: 84 }, { name: "300", l: 74 }, { name: "400", l: 62 }, { name: "500", l: 50 }, { name: "600", l: 40 }, { name: "700", l: 31 }, { name: "800", l: 22 }, { name: "900", l: 14 }, ]; const FAMILY = { neutral: { hue: 214, saturation: 8 }, primary: { hue: 214, saturation: 62 }, secondary: { hue: 268, saturation: 46 }, }; const ramp = {}; for (const [name, { hue, saturation }] of Object.entries(FAMILY)) { ramp[name] = {}; for (const s of STEP) { ramp[name][s.name] = hslRgb(hue, saturation, s.l); } } const WHITE = [255, 255, 255]; for (const name of Object.keys(FAMILY)) { console.log(`\n--- ${name} (hue ${FAMILY[name].hue}, saturation ${FAMILY[name].saturation}%) ---`); console.log("step lightness hex relative luminance against white"); for (const s of STEP) { const rgb = ramp[name][s.name]; console.log( `${s.name.padStart(7)} ${String(s.l).padStart(7)}% ${hex(rgb)} ` + `${luminance(rgb).toFixed(4).padStart(15)} ${contrast(rgb, WHITE).toFixed(2).padStart(11)}:1` ); } }
--- neutral (hue 214, saturation 8%) ---
step lightness hex relative luminance against white
000 100% #ffffff 1.0000 1.00:1
050 97% #f7f7f8 0.9307 1.07:1
100 92% #e9eaec 0.8223 1.20:1
200 84% #d3d6d9 0.6695 1.46:1
300 74% #b7bcc2 0.4993 1.91:1
400 62% #969da6 0.3335 2.74:1
500 50% #757e8a 0.2054 4.11:1
600 40% #5e656e 0.1281 5.89:1
700 31% #494e55 0.0752 8.39:1
800 22% #34383d 0.0390 11.80:1
900 14% #212327 0.0167 15.74:1
--- primary (hue 214, saturation 62%) ---
step lightness hex relative luminance against white
000 100% #ffffff 1.0000 1.00:1
050 97% #f3f7fc 0.9260 1.08:1
100 92% #dee9f7 0.8052 1.23:1
200 84% #bdd3ef 0.6364 1.53:1
300 74% #94b7e6 0.4588 2.06:1
400 62% #6296da 0.2947 3.05:1
500 50% #3075cf 0.1786 4.59:1
600 40% #275ea5 0.1115 6.50:1
700 31% #1e4980 0.0660 9.05:1
800 22% #15335b 0.0328 12.68:1
900 14% #0e213a 0.0149 16.19:1
--- secondary (hue 268, saturation 46%) ---
step lightness hex relative luminance against white
000 100% #ffffff 1.0000 1.00:1
050 97% #f7f4fb 0.9144 1.09:1
100 92% #eae1f4 0.7787 1.27:1
200 84% #d5c3e9 0.5906 1.64:1
300 74% #bb9edb 0.4013 2.33:1
400 62% #9b72cb 0.2331 3.71:1
500 50% #7c45ba 0.1209 6.15:1
600 40% #633795 0.0755 8.36:1
700 31% #4d2b73 0.0454 11.00:1
800 22% #361e52 0.0232 14.34:1
900 14% #231334 0.0107 17.30:1
The three families carry the same step names and were generated from the same lightness values. Despite this, the relative luminances of the colors at the same step are not equal. At step 500, neutral carries a luminance of 0.2054, primary 0.1786, secondary 0.1209. The difference is not small: the secondary family’s step 500 is about forty percent darker than the neutral family’s same step.
The reason is that the lightness axis in HSL notation is not perceptual. Relative luminance does not weight the three channels equally; it gives green a coefficient of 0.7152, red 0.2126, blue 0.0722. A blue-weighted hue produces a darker luminance at the same lightness value.
The practical consequence of this is that equal step numbers do not mean equal contrast. Binding a role to a step is not enough; whether the match actually works has to be computed separately.
Roles Are Bound to Steps and Matches Are Audited
A role table enumerates the jobs the interface needs and gives each one a step. Then it is listed which role falls on which ground, and every match is audited.
// roles.mjs — binding color roles to ramp steps and auditing the matches 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 = { "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], secondary: [268, 46] }; const color = (family, step) => hslRgb(FAMILY[family][0], FAMILY[family][1], LIGHTNESS[step]); // Roles: each role names a job, not a color. const ROLE = { "surface": ["neutral", "000"], "surface-secondary": ["neutral", "050"], "border": ["neutral", "300"], "text-primary": ["neutral", "900"], "text-secondary": ["neutral", "600"], "action-primary": ["primary", "600"], "action-primary-on": ["neutral", "000"], "accent-pale": ["primary", "100"], "accent-pale-on": ["primary", "800"], "action-secondary": ["secondary", "600"], }; console.log("role family step hex"); for (const [name, [family, step]] of Object.entries(ROLE)) { console.log(`${name.padEnd(20)} ${family.padEnd(9)} ${step.padStart(4)} ${hex(color(family, step))}`); } // Matches: which role falls on which ground, and which threshold applies. const MATCH = [ ["text-primary", "surface", 4.5], ["text-secondary", "surface", 4.5], ["text-secondary", "surface-secondary", 4.5], ["border", "surface", 3.0], ["action-primary-on", "action-primary", 4.5], ["action-primary", "surface", 3.0], ["accent-pale-on", "accent-pale", 4.5], ["action-secondary", "surface", 3.0], ]; console.log("\nforeground ground contrast threshold result"); for (const [fg, ground, threshold] of MATCH) { const k = contrast(color(...ROLE[fg]), color(...ROLE[ground])); console.log( `${fg.padEnd(20)} ${ground.padEnd(18)} ${k.toFixed(2).padStart(7)}:1 ${threshold.toFixed(1).padStart(9)} ${k >= threshold ? "passed" : "FAILED"}` ); } // If two action colors are separated only by hue, they blend together in grayscale. const a = color(...ROLE["action-primary"]); const b = color(...ROLE["action-secondary"]); console.log( `\ncontrast between action-primary (${hex(a)}) and action-secondary (${hex(b)}): ${contrast(a, b).toFixed(3)}:1` ); console.log(`relative luminances: ${luminance(a).toFixed(4)} and ${luminance(b).toFixed(4)}`); // How many steps are needed to build the same distinction on the lightness axis? console.log("\naction-secondary step contrast (against action-primary)"); for (const step of ["200", "300", "400", "500", "600", "700", "800"]) { const k = contrast(a, color("secondary", step)); console.log(`${step.padStart(22)} ${k.toFixed(3).padStart(7)}:1 ${k >= 3.0 ? "separates" : "-"}`); } // If the border role does not clear the threshold: which neutral step first provides 3.0? console.log("\nneutral step contrast against surface 3.0 threshold"); for (const step of ["200", "300", "400", "500", "600"]) { const k = contrast(color("neutral", step), color(...ROLE["surface"])); console.log(`${step.padStart(12)} ${k.toFixed(2).padStart(23)}:1 ${k >= 3.0 ? "passed" : "FAILED"}`); }
role family step hex
surface neutral 000 #ffffff
surface-secondary neutral 050 #f7f7f8
border neutral 300 #b7bcc2
text-primary neutral 900 #212327
text-secondary neutral 600 #5e656e
action-primary primary 600 #275ea5
action-primary-on neutral 000 #ffffff
accent-pale primary 100 #dee9f7
accent-pale-on primary 800 #15335b
action-secondary secondary 600 #633795
foreground ground contrast threshold result
text-primary surface 15.74:1 4.5 passed
text-secondary surface 5.89:1 4.5 passed
text-secondary surface-secondary 5.51:1 4.5 passed
border surface 1.91:1 3.0 FAILED
action-primary-on action-primary 6.50:1 4.5 passed
action-primary surface 6.50:1 3.0 passed
accent-pale-on accent-pale 10.33:1 4.5 passed
action-secondary surface 8.36:1 3.0 passed
contrast between action-primary (#275ea5) and action-secondary (#633795): 1.287:1
relative luminances: 0.1115 and 0.0755
action-secondary step contrast (against action-primary)
200 3.966:1 separates
300 2.794:1 -
400 1.753:1 -
500 1.058:1 -
600 1.287:1 -
700 1.693:1 -
800 2.206:1 -
neutral step contrast against surface 3.0 threshold
200 1.46:1 FAILED
300 1.91:1 FAILED
400 2.74:1 FAILED
500 4.11:1 passed
600 5.89:1 passed
The audit produced two findings. The rationale for the thresholds and which criterion they come from is the subject of the sixth lesson; what matters here is that the audit is done while the system is being built.
First Finding: The Border Role Does Not Clear the Threshold
The border role was bound to neutral step 300 and produces a 1.91:1 contrast ratio against the surface. Since the threshold is 3.0, this border does not reliably show the line the interface uses to announce a component boundary.
The last table gives the measure of the fix: the first step in the neutral family to clear the threshold is 500, at a ratio of 4.11:1. Step 400 still falls short at 2.74. The border role is moved to neutral 500.
This fix has a side effect that needs to be noticed: to the eye, neutral 500 is a distinctly gray line, far from the subtle distinction step 300 gave. If the border’s only job is to show a component boundary, and that boundary is already built by a surface difference or by spacing, the right decision is not to darken the border but to remove it. The rule from the Proximity and Grouping lesson returns here: a distinction built with a line is often built more cheaply with space. When an audit fails, there are two options — move the role to the threshold, or make the role unnecessary.
Second Finding: A Second Hue Is Not a Hierarchy Channel
The primary action color and the secondary action color sit at the same step, and the contrast ratio between them is 1.287:1. This means the two colors sit at almost the same point on the relative luminance axis. For a reader with limited color distinction or a grayscale output, these two buttons are the same button.
The third table shows why the fix is not on the hue axis. Separating the secondary family from the primary action by a contrast of 3.0 requires dropping to step 200, that is, a light purple; but that step cannot carry white text, because it itself sits at only a 1.64:1 contrast against the surface. None of the steps in between produce the distinction: at step 500 the ratio is 1.058, meaning the two colors are practically identical in luminance.
The rule that follows is the color side’s counterpart to the two-channel rule from the first lesson: the primary and secondary actions are not separated by hue difference. The distinction is built in the surface channel — the primary action carries a filled surface, the secondary action is drawn only as a border or plain text. A hue difference can be added on top of this distinction; it cannot carry the distinction alone.
A second hue does have a place in the system, but that place is not action hierarchy: marking categories that are independent of each other, distinguishing one type of information from another, or forming a ground in accent areas. In the catalog interface, the secondary family is used in labels that show collection type; the distinction there is not hierarchical but categorical, and the text on the label already carries the distinction.
The System’s Written Form
The ramp and the roles are written as two separate layers. The first layer is the raw steps, the second layer is the roles, and roles only reference steps:
:root { /* steps */ --neutral-000: #ffffff; --neutral-050: #f7f7f8; --neutral-500: #757e8a; --neutral-600: #5e656e; --neutral-900: #212327; --primary-100: #dee9f7; --primary-600: #275ea5; --primary-800: #15335b; /* roles */ --surface: var(--neutral-000); --surface-secondary: var(--neutral-050); --border: var(--neutral-500); --text-primary: var(--neutral-900); --text-secondary: var(--neutral-600); --action-primary: var(--primary-600); --action-primary-on: var(--neutral-000); }
Keeping the two layers separate lets components use only role names. The moment a
component writes --neutral-600, the system is punctured: that component is no longer
affected by a hue change, a theme change, or a role reassignment. A component that
sticks to role names, by contrast, inherits all of these changes for free.
Summary
- Color decisions are named by role, not value; a role is a name that stays true when the interface changes, and it is the structure that makes theme switching possible.
- A color family is derived from a single hue along the lightness axis; step names are shared across families.
- The same step number does not give the same contrast; because relative luminance does not weight its channels equally, the same lightness produces different luminance across hues.
- Role matches are audited while the system is built; when an audit fails, the role is either moved to the threshold or questioned for whether it is needed at all.
- The primary and secondary actions are not separated by hue difference; two hues at the same step carry nearly identical luminance and collapse into a single color in grayscale.
- The system is written as two layers: raw steps and role names. Components reference only role names.
Next Step
This lesson tied color to a visual task: surface, text, border, action. There is a second area where the interface uses color, and there color carries not a visual task but a direct meaning. That the borrow transaction completed successfully, that a record is not on the shelf, that a due date has passed — the interface reports this information with color. The next lesson takes up which colors these meanings get bound to, why binding alone is not enough, and how every semantic color has to carry its own ground-and-text pair.
To keep your progress and take notes, Log in
My notes
Log in to take notes.