Dark Mode Colors: Surface, Border, and Text Ratios That Actually Pass

阅读时间 8 分钟更新于 2026-09-15
📘 本文内容为英文原文,提供最准确的技术信息。中文解读和实操指南正在完善中。你也可以使用页面顶部的翻译工具。

The standard advice for dark mode is a stack of surfaces, each a few percent lighter than the last: base, card, raised, overlay. It produces a good-looking UI. It also fails WCAG 2.2 SC 1.4.11, and almost nobody measures it.

I ran the numbers on the canonical 4-step stack (#0B0D10 → #14171C → #1C2027 → #252A33). Adjacent surfaces measure 1.08:1, 1.10:1, and 1.13:1. The non-text contrast requirement for a component boundary is 3:1. Elevation-only depth is far too weak to count as a boundary — it is a visual effect, not an accessible one. If a card in your dark UI is defined only by being slightly lighter than the page, its edge does not exist as far as the standard is concerned.

This is not an argument against elevation. Elevation is how dark mode gets its depth, and it works for most sighted users. The argument is that elevation cannot be the only thing carrying structure, in exactly the way color cannot be the only thing carrying a form error. This guide gives you the measured numbers: which grays actually reach 3:1 on each dark surface, why naive inversion breaks specific brand hues, what pure black really costs, and a token set where every value has been computed rather than eyeballed.

Run your own pairs through the Contrast Checker. For the token-by-surface audit workflow, see WCAG Contrast Checker for Dark Mode. For the design-strategy view, see UI Dark Mode Color Strategy. For the full cluster, start at the Color Accessibility Hub.

审计数据

Every number below is computed with the WCAG relative-luminance formula on sRGB values. You can verify any row in the Contrast Checker.

The canonical dark elevation stack, measured against itself:

Adjacent surface pairHex pairMeasured ratioSC 1.4.11 needsResult
base → card#0B0D10 → #14171C1.08:13:1Fail
card → raised#14171C → #1C20271.10:13:1Fail
raised → overlay#1C2027 → #252A331.13:13:1Fail
base → overlay (skipping two steps)#0B0D10 → #252A331.35:13:1Fail

Jumping the entire stack in one leap still only reaches 1.35:1. To hit 3:1 from #0B0D10 you would need a surface around #5E5E5E — a mid-gray that no longer reads as dark mode at all. Elevation and non-text contrast are mutually exclusive goals on dark surfaces. Choose elevation for depth, then add a border to carry the accessibility requirement.

The minimum neutral gray that reaches 3:1 on each dark surface:

SurfaceMin gray for 3:1 (real boundary)MeasuredMin gray for 1.5:1 (decorative only)Measured
base #0B0D10#5E5E5E3.00:1#3131311.50:1
card #14171C#6464643.04:1#3737371.51:1
raised #1C2027#6A6A6A3.02:1#3D3D3D1.50:1
overlay #252A33#7373733.04:1#4545451.50:1

The typical dark-mode border sits around #2A2F37 or #333A45. Measured against base, #333A45 gives 1.70:1 — decorative. It looks like a border and it reads as a border, and it fails. When the boundary is the only way to tell an interactive component from the page, you need the #5E5E5E-and-up column, and it has to get lighter as the surface gets lighter.

A border that passes on the page can fail inside a modal. #333A45 measures 1.70:1 on base but only 1.43:1 on raised. Borders have to be tokenized per surface, the same way text is.

Naive inversion — what breaks when you flip light-mode tokens onto #0B0D10:

Light-mode tokenRole in light modeOn #0B0D10Verdict
#6B7280muted text (4.6:1 on white, passes AA)4.02:1Fails AA for body text
#2563EBprimary brand blue / button fill3.76:1Fails as text, holds as a large shape
#1D4ED8brand blue hover state2.90:1Fails everything, including the 3:1 floor
#DC2626error red4.03:1Fails AA — error text must be lighter
#16A34Asuccess green5.90:1Passes, but reads harsh at full saturation

The pattern: mid-tone tokens are the casualties. Near-black text inverts to near-white and stays fine. Saturated mid-tones designed to sit on white have nowhere to go — they were already darker than the midpoint, so on a dark surface they lose the contrast they had. Brand blue at #1D4ED8 fails the 3:1 non-text floor, which means a button in that fill has no reliably visible shape on a dark page.

The pure-black question, measured:

Background#F2F4F7 text ratioTrade-off
#00000019.06:1Highest ratio, worst halation on OLED
#0B0D1017.66:17% less contrast, visibly less glow
#14171C16.30:115% less contrast, calmest for long reading

Moving from #000000 to #0B0D10 costs 1.4 points out of a 19-point ratio. That is noise. You keep an enormous surplus and you lose the halation effect where light text on true black appears to bleed for readers with astigmatism. Near-black is nearly free. The real reason to avoid #000000 is that it leaves no room underneath — there is no darker surface available for a sunken state.

真实案例

Every mature design system tokenizes borders separately from surfaces, and the math above is why. Vercel's dark dashboard increments each nested panel by roughly 4% lightness, and the visible card edges come from a dedicated border token rather than the surface delta. Linear runs four surface levels (#111 → #191919 → #222 → #2A2A2A) with explicit border values on top. Both teams landed where the arithmetic forces you: surface deltas on dark backgrounds sit near 1.1:1, so structure needs a second mechanism.

GitHub's launch-day dark mode is the reference case for mid-tone failure. Secondary text shipped at #8b949e on #0d1117, which measures 6.15:1 — a clean AA pass for body text, and still the most-complained-about element in the release. The fix went two directions at once: a brighter default muted token (#9198a1, 6.5:1) and a separate "dark high contrast" theme with text at #f0f6fc (17.4:1). The lesson is the uncomfortable one: a passing ratio is not the same as a comfortable one. Mid-tone gray on near-black clears the arithmetic and still reads as washed out, because WCAG 2 does not model the polarity penalty that light-on-dark text carries. If your dark-mode muted token sits in the 5:1–6:1 band, users will complain even though your audit is green.

Notion's 2025-2026 dark rollout hit a hue problem, not a contrast problem. Their original surfaces were warm grays with an amber undertone, and blue links rendered on them skewed toward purple on some displays. Contrast was fine. Perceived hue was not. They moved to neutral grays (#1A1A1A, #252525) and re-measured every link token, landing on #6AB0F3 (7.6:1 on #1A1A1A). Undertone in your dark surfaces shifts the apparent hue of everything placed on them — settle the surface family before tuning accents.

Figma published the number that kills the inversion shortcut. Their Aug 2025 accessibility-tokens post reported 15 of 42 text tokens needing genuinely different values in dark mode. Not scaled, not inverted — different. Roughly 36% of a mature text token set cannot be derived mathematically, which is close to what the mid-tone analysis above predicts.

The perceptual trap underneath all of this: on dark backgrounds saturated colors appear to emit light (the Hunt effect), so accents read brighter than they measure. Designers respond by desaturating until it looks calm, which drops the measured ratio while the color still feels vivid. That is the mechanism behind most "it looked fine in Figma" dark-mode failures.

MechanismFeels likeMeasures asWhat to do
Elevation-only card edgeA clear boundary1.08–1.13:1Add a per-surface border token
Hunt effect on accentsBrighter than it isOften below 4.5:1Trust the number, not the eye
Warm-gray surfacesPremium, softShifts accent hueUse neutral grays, re-measure links
Pure black baseMaximum contrast19:1 but halatesNear-black, keep the surplus
Inverted mid-tonesConsistent with light mode2.9–4.0:1Re-pick, do not re-scale

测试方法

The 20-minute dark-mode measurement pass. Deliberately narrower than a full audit — it only chases the failures the elevation math predicts, which happen to be the ones tooling misses.

1. Eyedrop your real surface stack (5 min). Not the Figma values, the rendered ones. Semi-transparent overlays composite against whatever sits behind them, so a modal scrim over a card produces a surface value that exists in no token file. Screenshot the running app in dark mode and sample every distinct background: page, card, raised panel, modal, tooltip, plus each tinted status surface. Expect 6–10 real values where your tokens list 4.

2. Measure every border against every surface it lands on (5 min). This is the step almost no team runs. One border token across four surfaces is four different ratios. Build the row, then mark each cell as boundary (≥3:1) or decorative (below). Finally, check whether anything in the decorative column is the only thing defining an interactive control's edge. Those are your SC 1.4.11 failures.

3. Check focus rings against the lightest surface, not the darkest (3 min). Rings get colored against the page, then break inside modals. Measured against this stack:

Ring colorOn base #0B0D10On card #14171COn raised #1C2027On overlay #252A33
#2563EB3.76:13.48:13.16:12.79:1 fail
#3B82F65.29:14.88:14.44:13.92:1
#60A5FA7.65:17.07:16.43:15.67:1
#7CC0FF10.04:19.27:18.43:17.44:1

Unmodified brand blue #2563EB clears 3:1 on the page and fails inside a modal at 2.79:1 — it passes where you check and fails where users tab. One step lighter (#3B82F6) holds across the whole stack. SC 2.4.13 also wants at least a 2px perimeter area, so a 1px ring at 3.1:1 is failing on both axes simultaneously.

4. Verify on OLED hardware at 40% brightness (5 min). Low-brightness OLED crushes the bottom of the range: the 1.08:1 gap between base and card, already invisible to the standard, becomes invisible to everyone. Pure-black halation shows up here too. No emulator reproduces either effect.

5. Force-colors sanity check (2 min). Toggle Windows High Contrast (forced-colors: active). Every surface delta collapses to system colors. If the UI becomes unusable, structure was living entirely in elevation.

What each method actually catches:

FailureAutomated CIDevToolsMeasured matrixOLED at 40%
Muted text below AA on a cardyesyesyesyes
Border below 3:1 vs its surfacenopartialyesyes
Focus ring failing only in modalsnopartialyesyes
Composited overlay surface valuenoyesyesyes
Elevation carrying structure alonenonoyesyes
Halation on pure blacknononoyes

Automated tooling scans the DOM for text pairs. It does not know which border is load-bearing, and it cannot tell that a card edge is the only cue marking a clickable surface. The measured matrix is the only method that catches the elevation failure, and it is manual.

Pre-ship checklist for dark surfaces:

  1. Real composited surface values sampled from the running app, not the token file
  2. Every border token measured against every surface it appears on
  3. No interactive component whose only boundary is an elevation delta
  4. Focus ring ≥3:1 on the lightest surface it can appear on, with ≥2px perimeter
  5. Muted text measured on card and raised, not only on base
  6. Mid-tone brand accents re-picked for dark, not inverted or scaled
  7. Error and warning text measured on their own tinted surfaces
  8. Base is near-black rather than #000000, leaving room for a sunken state
  9. Surfaces neutral gray unless every accent has been re-measured against a warm undertone
  10. Verified on OLED hardware at 40% brightness and under forced-colors

Dark-mode token set with computed ratios, per-surface borders, and a CI audit script

:root[data-theme='dark'] {
  /* Surfaces. Near-black base leaves room for a sunken state.
     Deltas between these are 1.08-1.13:1 — depth only, never a boundary. */
  --surface-sunken:  #07080A;
  --surface-base:    #0B0D10;
  --surface-card:    #14171C;
  --surface-raised:  #1C2027;
  --surface-overlay: #252A33;

  /* Text, measured against base / card / raised / overlay */
  --text-primary:   #F2F4F7; /* 17.66 / 16.30 / 14.83 / 13.08 */
  --text-secondary: #CDD3DC; /* 12.92 / 11.93 / 10.85 /  9.57 */
  --text-muted:     #9AA3B0; /*  7.63 /  7.05 /  6.41 /  5.65  passes AA everywhere */
  --text-disabled:  #6B7480; /*  4.11 /  3.79 /  3.45 /  3.04  decorative only */

  /* Accents re-picked for dark, NOT inverted from light mode */
  --link:    #7CC0FF; /* 10.04 on base. Light-mode #2563EB would be 3.76 */
  --error:   #FF9B96; /*  9.62 on base. Light-mode #DC2626 would be 4.03 */
  --success: #6FDDA0; /* 11.61 on base */
  --warning: #F7C24A; /* 11.83 on base */

  /* Borders are per-surface: one token cannot pass on all four.
     Decorative tier (~1.5:1) is fine for visual separation only. */
  --border-decorative-base:    #313131;
  --border-decorative-card:    #373737;
  --border-decorative-raised:  #3D3D3D;
  --border-decorative-overlay: #454545;

  /* Boundary tier: >=3:1 per SC 1.4.11. Required whenever the edge is the
     only cue that an interactive component exists. */
  --border-boundary-base:    #5E5E5E; /* 3.00 on base */
  --border-boundary-card:    #646464; /* 3.04 on card */
  --border-boundary-raised:  #6A6A6A; /* 3.02 on raised */
  --border-boundary-overlay: #737373; /* 3.04 on overlay */

  /* Focus ring: #2563EB drops to 2.79 on overlay. One step lighter holds. */
  --focus-ring: #3B82F6; /* 5.29 / 4.88 / 4.44 / 3.92 */
}

/* Interactive controls take the boundary tier matched to their surface. */
.card { background: var(--surface-card); border: 1px solid var(--border-decorative-card); }
.card--interactive { border-color: var(--border-boundary-card); }
.modal .card--interactive { border-color: var(--border-boundary-overlay); }

:focus-visible {
  outline: 2px solid var(--focus-ring); /* 2px minimum for SC 2.4.13 */
  outline-offset: 2px;
}

/* -----------------------------------------------------------------
 * CI audit script. Fails the build when a boundary border or focus
 * ring drops below 3:1 on any surface it can render against.
 * This is the check automated a11y scanners skip.
 * ----------------------------------------------------------------- */

function luminance(hex) {
  const c = hex.replace('#', '').match(/.{2}/g).map(function (v) {
    const n = parseInt(v, 16) / 255;
    return n <= 0.03928 ? n / 12.92 : Math.pow((n + 0.055) / 1.055, 2.4);
  });
  return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
}

function ratio(a, b) {
  const x = luminance(a);
  const y = luminance(b);
  return (Math.max(x, y) + 0.05) / (Math.min(x, y) + 0.05);
}

const surfaces = {
  base:    '#0B0D10',
  card:    '#14171C',
  raised:  '#1C2027',
  overlay: '#252A33',
};

/* Anything whose visibility is required by SC 1.4.11 or SC 2.4.13 */
const nonTextTokens = [
  { name: 'border-boundary', perSurface: {
      base: '#5E5E5E', card: '#646464', raised: '#6A6A6A', overlay: '#737373' } },
  { name: 'focus-ring', flat: '#3B82F6' },
];

let failures = 0;

for (const token of nonTextTokens) {
  for (const entry of Object.entries(surfaces)) {
    const surfaceName = entry[0];
    const surfaceHex = entry[1];
    const value = token.flat || token.perSurface[surfaceName];
    const score = ratio(value, surfaceHex);
    const pass = score >= 3;
    if (!pass) failures++;
    console.log(
      (pass ? 'PASS  ' : 'FAIL  ') + token.name + ' (' + value + ') on ' +
      surfaceName + ': ' + score.toFixed(2) + ':1' + (pass ? '' : '  <- below 3:1')
    );
  }
}

/* Elevation deltas are reported, never asserted: ~1.1:1 is expected.
   The point is to prove no component relies on them alone. */
const order = Object.entries(surfaces);
console.log('Elevation deltas (informational, not valid boundaries):');
for (let i = 0; i < order.length - 1; i++) {
  console.log('  ' + order[i][0] + ' -> ' + order[i + 1][0] + ': ' +
    ratio(order[i][1], order[i + 1][1]).toFixed(2) + ':1');
}

if (failures) {
  console.error(failures + ' non-text contrast failure(s) — blocking release.');
  process.exit(1);
}

复制粘贴到项目即可使用。

💡 高手技巧

免费工具推荐

用这些免费工具实操你学到的知识: