Lesson 17 / 22
Dark Theme
Why mirroring steps distorts the hierarchy ratio, deriving the dark theme through a contrast-preservation condition, why saturation is free while lightness is dependent, and how elevation consumes the contrast budget.
Contents
The previous lesson audited every color match in the catalog interface. The entire audit was built on a single assumption: light surfaces, dark text. When the same interface runs in a dark theme, this relationship inverts, and the entire audit has to be redone.
A dark theme is not a second list in the color system; it is a transformation derived from the first. This lesson’s question is the transformation’s rule: which quantity is preserved, which is set free, and how is the transformation checked for correctness?
What Mirroring Preserves, and What It Distorts
The most direct transformation is to swap the ramp’s steps one-for-one: 000 trades places with 900, 050 with 800, 100 with 700. Role names do not change, only which step they point to changes. This transformation’s outcome needs to be measured.
// mirroring.mjs — auditing a dark theme built by swapping ramp steps one-for-one 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], error: [8, 68] }; const color = (family, b) => hslRgb(FAMILY[family][0], FAMILY[family][1], LIGHTNESS[b]); const MIRROR = { "000": "900", "050": "800", 100: "700", 200: "600", 300: "500", 400: "400", 500: "300", 600: "200", 700: "100", 800: "050", 900: "000" }; const LIGHT_ROLE = { "surface": ["neutral", "000"], "surface-secondary": ["neutral", "050"], "border": ["neutral", "500"], "text-primary": ["neutral", "900"], "text-secondary": ["neutral", "600"], "action-primary": ["primary", "600"], "action-primary-on": ["neutral", "000"], "selected-ground": ["primary", "100"], "error-text": ["error", "600"], }; const DARK_ROLE = Object.fromEntries( Object.entries(LIGHT_ROLE).map(([name, [family, b]]) => [name, [family, MIRROR[b]]]) ); console.log("role light theme dark theme (mirrored)"); for (const name of Object.keys(LIGHT_ROLE)) { const [lf, ls] = LIGHT_ROLE[name]; const [df, ds] = DARK_ROLE[name]; console.log( `${name.padEnd(17)} ${(lf + "-" + ls).padEnd(10)} ${hex(color(lf, ls))} ${(df + "-" + ds).padEnd(10)} ${hex(color(df, ds))}` ); } 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], ["error-text", "surface", 4.5], ["text-secondary", "selected-ground", 4.5], ]; console.log("\nforeground / ground threshold light theme dark theme dark result"); for (const [fg, ground, threshold] of MATCH) { const lightK = contrast(color(...LIGHT_ROLE[fg]), color(...LIGHT_ROLE[ground])); const darkK = contrast(color(...DARK_ROLE[fg]), color(...DARK_ROLE[ground])); console.log( `${(fg + " / " + ground).padEnd(36)} ${threshold.toFixed(1).padStart(4)} ${lightK.toFixed(2).padStart(11)} ${darkK.toFixed(2).padStart(10)} ${darkK >= threshold ? "passed" : "FAILED"}` ); }
role light theme dark theme (mirrored) surface neutral-000 #ffffff neutral-900 #212327 surface-secondary neutral-050 #f7f7f8 neutral-800 #34383d border neutral-500 #757e8a neutral-300 #b7bcc2 text-primary neutral-900 #212327 neutral-000 #ffffff text-secondary neutral-600 #5e656e neutral-200 #d3d6d9 action-primary primary-600 #275ea5 primary-200 #bdd3ef action-primary-on neutral-000 #ffffff neutral-900 #212327 selected-ground primary-100 #dee9f7 primary-700 #1e4980 error-text error-600 #ab3321 error-200 #f2c2ba foreground / ground threshold light theme dark theme dark result text-primary / surface 4.5 15.74 15.74 passed text-secondary / surface 4.5 5.89 10.78 passed text-secondary / surface-secondary 4.5 5.51 8.09 passed border / surface 3.0 4.11 8.23 passed action-primary-on / action-primary 4.5 6.50 10.29 passed action-primary / surface 3.0 6.50 10.29 passed error-text / surface 4.5 6.51 9.89 passed text-secondary / selected-ground 4.5 4.80 6.20 passed
All eight matches clear the threshold. Mirroring produced no conformance problem; in fact, it raised the contrast ratio in most matches. From this, it cannot be concluded that mirroring is the right transformation, because the audit is asking the wrong question.
The threshold audit looks at each match one at a time and asks about the lower bound. Hierarchy, however, depends on the relationship between matches. In the light theme, primary text carries a contrast of 15.74, secondary text 5.89; the ratio between them is 2.67. In the mirrored dark theme, primary is 15.74, secondary 10.78; the ratio is 1.46. The second level sits much closer to the first.
This means the contrast channel measured in the Visual Hierarchy lesson weakens in the dark theme. The channel clears the threshold but loses its distinguishing power. What mirroring preserves is threshold conformance; what it distorts is the ratio structure.
The reason is that the relative luminance function is not linear. Equal steps in HSL lightness do not produce equal steps in luminance; the scale’s dark end is compressed on the luminance axis, its light end is sparse. Swapping the steps moves the compressed end to the sparse end, and every distance in between changes.
The Quantity to Preserve Is the Contrast Ratio
The correct transformation carries not step numbers but contrast ratios. For each role, the ratio measured in the light theme is taken as the target, and the lightness value that produces the same ratio on the dark surface is searched for.
This requires a decision first: how dark will the dark surface be? As the surface darkens, the contrast budget that can fit above it grows, but the budget is not infinite.
// preservation.mjs — deriving a dark theme that preserves the light theme's contrast ratios 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], error: [8, 68] }; const color = (family, b) => hslRgb(FAMILY[family][0], FAMILY[family][1], LIGHTNESS[b]); // 1) Choosing the dark surface: how dark a surface, and how much contrast range does it leave above it? console.log("dark surface candidate hex relative luminance highest contrast on top"); for (const [name, rgb] of [ ["pure black", [0, 0, 0]], ["neutral-900", color("neutral", "900")], ["neutral-800", color("neutral", "800")], ["neutral-700", color("neutral", "700")], ]) { console.log( `${name.padEnd(17)} ${hex(rgb)} ${luminance(rgb).toFixed(4).padStart(15)} ${(1.05 / (luminance(rgb) + 0.05)).toFixed(2).padStart(27)}:1` ); } // 2) Preservation: reproduce each role's light-theme contrast ratio on the dark surface. const LIGHT_SURFACE = color("neutral", "000"); const DARK_SURFACE = color("neutral", "900"); const ROLES = [ { name: "text-primary", family: "neutral", step: "900", saturation: 8 }, { name: "text-secondary", family: "neutral", step: "600", saturation: 8 }, { name: "border", family: "neutral", step: "500", saturation: 8 }, { name: "action-primary", family: "primary", step: "600", saturation: 45 }, { name: "error-text", family: "error", step: "600", saturation: 55 }, ]; function findLightness(hue, saturation, ground, target) { let low = 0, high = 100; for (let i = 0; i < 50; i++) { const mid = (low + high) / 2; if (contrast(hslRgb(hue, saturation, mid), ground) < target) low = mid; else high = mid; } return (low + high) / 2; } console.log("\nrole light target dark lightness saturation dark color measured"); const darkPalette = {}; for (const r of ROLES) { const target = contrast(color(r.family, r.step), LIGHT_SURFACE); const hue = FAMILY[r.family][0]; const l = findLightness(hue, r.saturation, DARK_SURFACE, target); const rgb = hslRgb(hue, r.saturation, l); darkPalette[r.name] = rgb; console.log( `${r.name.padEnd(16)} ${target.toFixed(2).padStart(8)}:1 ${l.toFixed(2).padStart(12)}% ${String(r.saturation).padStart(9)}% ${hex(rgb)} ${contrast(rgb, DARK_SURFACE).toFixed(2).padStart(6)}:1` ); } // 3) Was the hierarchy ratio preserved? The gap between primary and secondary text. const lightPrimary = contrast(color("neutral", "900"), LIGHT_SURFACE); const lightSecondary = contrast(color("neutral", "600"), LIGHT_SURFACE); const darkPrimary = contrast(darkPalette["text-primary"], DARK_SURFACE); const darkSecondary = contrast(darkPalette["text-secondary"], DARK_SURFACE); console.log(`\nlight theme: primary ${lightPrimary.toFixed(2)}:1, secondary ${lightSecondary.toFixed(2)}:1, ratio ${(lightPrimary / lightSecondary).toFixed(3)}`); console.log(`dark theme: primary ${darkPrimary.toFixed(2)}:1, secondary ${darkSecondary.toFixed(2)}:1, ratio ${(darkPrimary / darkSecondary).toFixed(3)}`); // 4) What was the same ratio in the mirrored version? const mirroredPrimary = contrast(color("neutral", "000"), DARK_SURFACE); const mirroredSecondary = contrast(color("neutral", "200"), DARK_SURFACE); console.log(`mirroring : primary ${mirroredPrimary.toFixed(2)}:1, secondary ${mirroredSecondary.toFixed(2)}:1, ratio ${(mirroredPrimary / mirroredSecondary).toFixed(3)}`); // 5) Saturation is free, lightness is dependent: hitting the same target at different saturations console.log("\nfor action-primary, target 6.50:1 -> saturation choice and resolved lightness"); for (const s of [62, 45, 30, 15]) { const l = findLightness(FAMILY.primary[0], s, DARK_SURFACE, 6.5); const rgb = hslRgb(FAMILY.primary[0], s, l); console.log( `saturation ${String(s).padStart(2)}% -> lightness ${l.toFixed(2).padStart(6)}% ${hex(rgb)} ${contrast(rgb, DARK_SURFACE).toFixed(2)}:1` ); } // 6) Elevation in a dark theme: the surface on top gets lighter. console.log("\nelevation lightness hex against surface highest contrast on top"); for (const [name, targetSeparation] of [["base", 1.0], ["card", 1.3], ["popover layer", 1.7]]) { const l = targetSeparation === 1.0 ? LIGHTNESS["900"] : findLightness(FAMILY.neutral[0], FAMILY.neutral[1], DARK_SURFACE, targetSeparation); const rgb = hslRgb(FAMILY.neutral[0], FAMILY.neutral[1], l); console.log( `${name.padEnd(14)} ${l.toFixed(2).padStart(7)}% ${hex(rgb)} ${contrast(rgb, DARK_SURFACE).toFixed(3).padStart(16)} ${(1.05 / (luminance(rgb) + 0.05)).toFixed(2).padStart(27)}:1` ); }
dark surface candidate hex relative luminance highest contrast on top pure black #000000 0.0000 21.00:1 neutral-900 #212327 0.0167 15.74:1 neutral-800 #34383d 0.0390 11.80:1 neutral-700 #494e55 0.0752 8.39:1 role light target dark lightness saturation dark color measured text-primary 15.74:1 99.82% 8% #ffffff 15.74:1 text-secondary 5.89:1 62.78% 8% #989fa8 5.89:1 border 4.11:1 52.08% 8% #7b848f 4.15:1 action-primary 6.50:1 68.22% 45% #89a9d2 6.50:1 error-text 6.51:1 70.24% 55% #dd9589 6.55:1 light theme: primary 15.74:1, secondary 5.89:1, ratio 2.670 dark theme: primary 15.74:1, secondary 5.89:1, ratio 2.672 mirroring : primary 15.74:1, secondary 10.78:1, ratio 1.459 for action-primary, target 6.50:1 -> saturation choice and resolved lightness saturation 62% -> lightness 69.03% #7fa9e1 6.49:1 saturation 45% -> lightness 68.22% #89a9d2 6.50:1 saturation 30% -> lightness 67.38% #93a9c5 6.54:1 saturation 15% -> lightness 66.36% #9ca7b6 6.46:1 elevation lightness hex against surface highest contrast on top base 14.00% #212327 1.000 15.74:1 card 21.60% #33363b 1.298 12.13:1 popover layer 28.34% #42484e 1.700 9.26:1
Surface Choice Sets the Contrast Budget
The first table ties the dark theme’s most basic decision to a number. Above a pure black surface, the highest contrast can reach up to 21:1; at neutral step 900 it is 15.74, at neutral 700 only 8.39.
Pure black gives the widest budget, but it has a cost. White text on pure black produces 21:1; this is distinctly above the light theme’s 15.74, and it violates the transformation’s preservation condition. If preservation is wanted, the surface has to be chosen so that the light theme’s highest ratio can also be produced in the dark theme, but is not necessarily exceeded. Neutral 900 sits exactly at this point: the highest contrast above it is 15.74, exactly the same as the light theme’s primary text ratio.
The second table confirms this. The lightness resolved for the primary text role is 99.82 percent, that is, practically pure white. This is not a coincidence, it is a direct consequence of the surface choice: on a neutral 900 surface, only white can produce a ratio of 15.74.
The third block shows that preservation is achieved. In the derived dark theme, the ratio between primary and secondary text is 2.672; it matches the light theme’s 2.670 value to three decimal places. In the mirrored version, this ratio was 1.459. Hierarchy is preserved when the transformation’s criterion is the contrast ratio, and it is not preserved when the criterion is the step number.
Saturation Is Free, Lightness Is Dependent
The fourth table takes up the transformation’s second parameter. For the primary action color, the target is 6.50:1; this target can be hit at four different saturations, only the required lightness changes: between 66.36 percent and 69.03 percent.
This means saturation is a free parameter. Contrast preservation does not constrain saturation; whatever saturation is chosen, lightness is solved for accordingly and the target ratio is hit. The saturation decision therefore needs a separate rationale.
In the catalog interface, saturation for the dark theme was chosen lower than in the light theme: 45 instead of 62 in the primary family, 55 instead of 68 in the error family. The rationale is the relationship a high-saturation, high-lightness color forms with the dark area around it on a dark ground; the same color’s perceptual footprint on a light ground is not the same as on a dark ground. This is a preference decision and does not affect the rest of the transformation — the table shows this: even with saturation lowered, the ratio is preserved.
The point to notice here is that saturation changes in the ramp definition, not in the
role table. The dark theme never changes role names; it only changes which step values
the roles point to. Components keep writing --action-primary and are never aware of
the theme switch. The cost of role naming from the fourth lesson is paid back here.
Elevation Consumes the Contrast Budget
The fifth table measures a constraint specific to the dark theme. In the light theme, a card is separated from the base surface with a shadow. On a dark ground, a shadow is invisible, because a shadow is already dark; the distinction is built instead by lightening the surface on top.
This cost can be computed. The base surface sits at neutral 900 and has a contrast budget of 15.74 above it. A card surface separated from the base by a ratio of 1.3 rises to 21.60 percent lightness, and the budget above it drops to 12.13. In a popover layer separated by a ratio of 1.7, the budget falls to 9.26.
This means that in a dark theme, every elevation layer takes a cut from the contrast range available to the text that sits on it. In a three-layer interface, the text on the topmost layer cannot hit the same ratio as the text on the base layer. This is a constraint with no counterpart in the light theme: in the light theme, elevation moves toward white, and white is already the upper bound, so the budget does not change.
The practical consequence is that the number of elevation layers in a dark theme is kept lower than in the light theme. Two layers are enough in the catalog interface: the base surface and the layer the record detail opens on. If a third layer is added, the secondary text on that layer cannot hit the 5.89 ratio, and the hierarchy breaks down there.
The Theme Switch’s Written Form
The transformation is written by holding role names fixed and changing the step assignments:
:root { --surface: #ffffff; --text-primary: #212327; --text-secondary: #5e656e; --border: #757e8a; --action-primary: #275ea5; } @media (prefers-color-scheme: dark) { :root { --surface: #212327; --text-primary: #ffffff; --text-secondary: #989fa8; --border: #7b848f; --action-primary: #89a9d2; } }
Because the values are generated by computation, this block is not a hand-written list, it is an output. When a new role is added to the system, its dark counterpart is not guessed; the same preservation computation is run again.
Theme choice is read from the user’s operating system preference. If the interface also needs to offer a theme toggle, that toggle’s choice overrides the system preference and the choice is persisted; this is a state-management decision, not a design decision.
Summary
- A dark theme is not a second color list, it is a transformation derived from the light theme.
- Swapping steps one-for-one preserves threshold conformance but distorts the ratio structure; the distinction between primary and secondary text drops from a 2.67 multiple to a 1.46 multiple.
- The correct transformation’s preserved quantity is the contrast ratio; the dark palette is derived by searching, for every role, for the lightness value that produces the target ratio.
- How dark the dark surface is sets the highest contrast that fits above it; the surface is chosen so that it exactly meets the light theme’s highest ratio.
- Saturation is a free parameter; whatever saturation is chosen, lightness is solved for accordingly and the target ratio is hit.
- In a dark theme, elevation is built by lightening the surface on top, and every layer takes a cut from the contrast budget; this is why the layer count is kept lower than in the light theme.
Next Step
With the typography and color decisions finished, the catalog interface’s static appearance is complete: which text at which size, which color in which role, audited in both themes. But the interface is not static. The “Borrow” button looks different when hovered, focused by keyboard, held down, and disabled. Each of these appearances is a separate design decision, and none of them has been defined so far. The next topic takes up component states; its first lesson defines five interaction states, determines which channel each one is separated in, and shows with a criterion why the focus indicator cannot be removed.
To keep your progress and take notes, Log in
My notes
Log in to take notes.