---
title: 'Dark Theme'
source: 'https://academia.sh/en/courses/interface-fundamentals/dark-theme'
course: 'Fundamentals of Interface Design'
language: en
updated: '2026-08-17T18:11:57+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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.

```js
// 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:

```css
: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.
