Skip to content
academia.sh

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.

Start typing to search.

↑↓ Esc navigate · open · close