facebook / facebook/astryx

Dark mode: research, guidelines, and acceptance criteria

Open
#2,150 1 comment 0 reactions 0 assignees View on GitHub
discussion
Dominant language
TypeScript
Stars
13k
Forks
1.1k
Avg merge
1d 15h
Merged PRs (30d)
690

Description

# Dark Mode: An Objective Rubric

## The Core Problem

Most dark modes fail because teams treat it as "invert the colors and ship it." Research shows that approach breaks hierarchy, reduces readability, and can increase eye strain. A good dark mode is not a filter applied to light mode — it's a parallel design language that shares **intent** but differs in **execution**.

This issue is the rubric XDS theme authors (and anyone else) can use to build a dark mode that actually works. Every claim below is either sourced or explicitly flagged as convention.

---

## Why This Matters

- Widely cited survey (Earthweb, aggregated): **~82% of smartphone users use dark mode** ([forms.app statistics](https://forms.app/en/blog/dark-mode-statistics)). Directional, not peer-reviewed.
- **Measurable business impact**: Terra (75M MAU) reduced bounce rate by 60% and increased pages/session by 170% for users who prefer dark mode after shipping a dedicated theme ([web.dev case study](https://web.dev/case-studies/terra-dark-mode)).
- **Accessibility requirement**: Users with photophobia, light sensitivity, and some low-vision conditions rely on dark mode. Conversely, users with astigmatism can struggle with *poorly-tuned* dark modes (halation — see §6 below).

---

## Token Roles

Every dark-mode rule applies to a **role**, not globally. Seven roles matter:

| # | Role | Examples |
|---|------|----------|
| 1 | Neutral surfaces | body, surface, card, muted, popover |
| 2 | Neutral text | primary, secondary, disabled |
| 3 | Elevation (shadows / borders) | card shadow, modal shadow, divider |
| 4 | Accent colors | border, icon, interactive text on tinted surface |
| 5 | Categorical backgrounds | banner bg, badge bg, status surface |
| 6 | Semantic solids | filled success badge, destructive button |
| 7 | Media & iconography | raster images, illustrations, product shots |

Each role has its own derivation rule. Applying one rule across all roles is where most dark modes break.

---

## 1. Background Color

| Approach | Hex Range | Pros | Cons |
|----------|-----------|------|------|
| Pure black | `#000000` | Max OLED battery savings, dramatic | Halation (text bleeds), harsh contrast, fatiguing |
| Near-black | `#0D1117` – `#1A1A2E` | Still deep, reduced halation | Slightly less OLED benefit |
| Dark gray | `#1E1E1E` – `#2D2D2D` | Comfortable, Material standard | Can feel "flat" or washed out |
| Tinted dark | `#1B1F23` (blue-tinted) | Adds warmth/personality | Harder to maintain neutrality across themes |

**Consensus:** Dark gray `#121212` – `#1E1E1E` outperforms pure black for readability. Material Design's recommended base is **`#121212`** ([Google Codelabs — Design a dark theme](https://codelabs.developers.google.com/codelabs/design-material-darktheme/)). Pure black is only justified for OLED-first products where battery is critical (mobile).

---

## 2. Elevation & Depth

Shadows don't work well on dark backgrounds because the ambient contrast is too low. Three valid strategies:

| Approach | Mechanism | Example systems | Brand feel |
|----------|-----------|-----------------|------------|
| **Surface tints / overlays** | Lighter fills per elevation step | Material Design 3 | Soft, layered, architectural |
| **Elevated shadows** | Stronger drop shadows (28% opacity vs light's 14%), often paired with borders | Microsoft Fluent 2 | Dramatic, dimensional |
| **Inset highlight + drop** | Subtle top inset light + soft drop shadow | Radix Themes, Jetpack Compose primitive | Glass-like, crisp, "premium" |

Typical surface tint ramp (Material approach):

```
Base: #121212
Surface 1: #1E1E1E (cards, sheets)
Surface 2: #252525 (raised elements)
Surface 3: #2C2C2C (modals, popovers)
Surface 4: #333333 (highest elevation)
```

Material also supports white overlays at low opacity (4%, 8%, 12%, 16%) on the base color — scales cleanly with theming.

**Sources:** [Google Codelabs](https://codelabs.developers.google.com/codelabs/design-material-darktheme/), [Flutter ElevationOverlay API](https://api.flutter.dev/flutter/material/ElevationOverlay/colorWithOverlay.html), [Fluent 2 Elevation](https://fluent2.microsoft.design/elevation), [Radix Themes Shadows](https://www.radix-ui.com/themes/docs/theme/shadows), [Android Developers — Add shadows in Compose](https://developer.android.com/develop/ui/compose/graphics/draw/shadows).

**Alternative:** Subtle borders instead of elevation shifts. GitHub, Linear, and Notion use this — cleaner but less dimensional (observation; confirm via DevTools before citing).

---

## 3. The General Rule for Picking Colors

Every token in dark mode is picking a tone on a palette ramp. There's one rule that explains every choice:

> **Pick the tone that satisfies required contrast against every surface the token touches.**

That's the whole thing. Every "special case" below is just this rule applied to a different pair of surfaces.

### The formula

For any token `T` sitting on surface `S`, holding content `C` (if any):

1. `contrast(tone_T, S)` ≥ the visibility threshold for T's role
2. `contrast(C, tone_T)` ≥ 4.5:1 if `C` is body text
3. `contrast(C, tone_T)` ≥ 3:1 if `C` is large text, UI indicator, or non-text element

Pick the tone on the palette ramp that satisfies **all** constraints. When mode changes, `S` changes, so `tone_T` must change to re-satisfy.

### WCAG contrast minimums (the thresholds)

| Rating | Body text | Large text (≥18pt / 14pt bold) | Non-text UI |
|--------|-----------|-------------------------------|-------------|
| AA | 4.5:1 | 3:1 | 3:1 |
| AAA | 7:1 | 4.5:1 | — |

**Sweet spot: 7:1 – 12:1** for body text. Contrast above ~15:1 increases halation for users with astigmatism (§5).

Sources: [MDN — Color contrast](https://developer.mozilla.org/en-US/docs/Web/Accessibility/Guides/Understanding_WCAG/Perceivable/Color_contrast), [W3C — Understanding SC 1.4.3](http://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html), [WebAIM — Contrast](https://webaim.org/articles/contrast/) (covers 1.4.11 non-text).

### Applying the rule to each token role

Worked examples on a 0–100 tone palette where T0 = black, T100 = white.

| Token | Sits on | Holds | Target contrast | Light tone | Dark tone |
|-------|---------|-------|-----------------|------------|-----------|
| **Body text** | Body (T95 light / T10 dark) | — | ≥4.5:1 vs body | T10 | T95 |
| **Secondary text** | Body | — | ≥4.5:1 vs body | T40 | T70 |
| **Disabled text** | Body | — | ~2:1 (visibly off) | T60 | T50 |
| **Colored text on body** | Body | — | ≥4.5:1 vs body | T40 | T70 |
| **Solid filled button** | Body | White text | ≥4.5:1 for *white on bg* | **T50** | **T50** |
| **Banner background** | Body | Colored text/icon | ~1.3:1 vs body (subtle tint) | T90 | T20 |
| **Text inside banner** | Banner bg (T90 / T20) | — | ≥4.5:1 vs banner bg | T30 | T80 |
| **Icon inside banner** | Banner bg | — | ≥3:1 vs banner bg | T30 | T70 |
| **Border on banner** | Banner bg | — | ≥3:1 (non-text UI) | T80 | T60 |
| **Focus ring on input** | Body | — | ≥3:1 vs body | T60 | T50 |

### Why some tones shift and others don't

**What looks like "an exception" is the binding constraint changing.**

- *Colored text on body* shifts T40 → T70 in dark mode. Body changes, so the text tone must change to keep 4.5:1. Classic "lighten for dark mode."
- *Solid filled button* stays at T50 in both modes. The binding constraint isn't the body — it's the white text on top, which doesn't change between modes. A lighter bg (T60) would drop white-on-bg contrast to 3.2:1 and fail AA.
- *Focus ring* shifts T60 → T50 in dark mode — counterintuitive. The ring needs visibility against a dark body, and T50 has higher luminance contrast there than T60 does.

The rule never changes. What changes is which constraint is binding for that token.

Sources: [Stripe — Designing accessible color systems](https://stripe.com/blog/accessible-color-systems), [Toptal — The Principles of Dark UI Design](https://www.toptal.com/designers/ui/dark-ui-design), [Muzli — Why simply inverting isn't enough](https://medium.muz.li/dark-mode-design-why-simply-inverting-colors-is-not-enough-e2584ebb139b).

---

## 4. Color & Saturation

**The inversion trap:** `#0070F3` → `#FF8F0C` (orange). Don't invert hex values.

Saturated colors on dark backgrounds vibrate — a separate constraint from contrast. This shapes *how you build the palette ramp*, not which tone you pick from it:

- **Light palette:** full chroma at every tone (vivid brand identity)
- **Dark palette:** chroma reduced ~15% across the ramp (matches rubric §3 "lighten + desaturate" per-mode tuning)
- **Tone choice** within each palette is still governed by §3's contrast rule

```
Light mode blue: hsl(220, 90%, 50%) → bold, vibrant
Dark mode blue: hsl(220, 70%, 65%) → lighter, softer, still identifiable
```

**Perceptual mapping** preserves *intent* (this is the primary action color) rather than the exact hex. If you're using a perceptually uniform color space (OKLCH), tuning chroma per mode is a single multiplier on the ramp.

**Chroma floor:** C ≥ 0.10 (OKLCH) maintains hue identity in dark mode. Below that, yellow and green both collapse to olive.

---

## 5. Categorical & Status Backgrounds

A banner or status surface is just the contrast rule from §3 applied to a specific pair: colored bg at T90/T20, colored text at T30/T80. But there's an extra test that pure math doesn't catch.

### Visual equivalence test

Render the light banner and the dark banner side-by-side. If one feels clearly colored and the other feels gray, the ramp is under-saturated for dark mode (§4) — not a §3 contrast problem.

**Failure modes to avoid:**

- **Pastel-in-both-modes:** light pastel against a dark body reads as a lightbulb (10:1+ contrast). Math passes, visual equivalence fails — banner looks brighter than intended.
- **Dark-tint-over-body at too-low chroma:** bg "disappears" into the page (~1.1:1 vs body). Yellow + green become indistinguishable olive. Math passes the minimum, visual equivalence fails.
- **Composited pastel without flipping text:** the original dark-saturated text designed for the light pastel becomes invisible on the composited dark bg. Classic override bug — catches you when component overrides hardcode text color instead of using `light-dark()`.

These aren't exceptions to §3 — they're what happens when the palette ramp doesn't have usable tones at the right lightness for dark mode. Fix: tune the ramp (§4), then pick tones (§3).

## 6. Halation & Astigmatism

High-contrast white-on-black causes "halation" — light bleeds past character edges. Especially problematic for users with astigmatism.

- Pupil dilation in low-light interfaces increases optical aberrations and can contribute to visual fatigue ([*Immediate Effects of Light/Dark Mode on Visual Fatigue in Tablet Users* — MDPI, 2025](https://www.mdpi.com/1660-4601/22/4/609)).
- Contrast polarity research: [*Dark mode vogue* (PubMed, 2022)](https://pubmed.ncbi.nlm.nih.gov/36533999/), [*Dark Mode or Light Mode? Contrast Polarity and Age Groups* (arXiv, 2024)](https://arxiv.org/abs/2409.10841v2), [ACM ETRA 2025 eye-tracking study](https://dl.acm.org/doi/10.1145/3715669.3725879).
- Foundational: Rubin & Legge, 1989, *Psychophysics of reading VI: The role of contrast in low vision* ([Legge Lab, U of Minnesota](https://legge.psych.umn.edu/contrast-coding)).

**Implication:** avoid pure `#FFFFFF` body text on `#000000` body. Use `#E0E0E0` – `#EBEBEB` on `#121212` – `#1E1E1E`.

---

## 7. Semantic Layers (Token Architecture)

Build dark mode on **semantic tokens**, not raw colors:

```
color-background-body → #121212 (dark) / #FFFFFF (light)
color-background-surface → #1E1E1E (dark) / #F5F5F5 (light)
color-text-primary → #E0E0E0 (dark) / #1A1A1A (light)
color-border-default → #333333 (dark) / #E0E0E0 (light)
```

Theme swapping should be a single token-table change — no component-level conditionals. Teams that hardcode colors or use "dark-" prefixed tokens end up with maintenance nightmares.

**Component override audit:** any hardcoded hex in a component override section must be wrapped in `light-dark()` or use a `var()` token. Raw hex = silent dark-mode bug. Grep for hex literals in your component overrides as a pre-merge check.

---

## 8. Images, Icons & Media

Often overlooked:

- **Icons:** use `currentColor` or theme-aware fills. Outlined icons work better in dark mode than filled ones (less visual weight).
- **Images:** need dimming (80–90% brightness filter) or they blow out the page. Some products use subtle rounded borders to separate images from dark backgrounds.
- **Illustrations:** SVG with theme colors adapts; raster needs separate dark variants or overlay treatment.
- **Shadows on images:** near-invisible or replaced with subtle borders.

---

## 9. Charts & Data Viz in Dark Mode

Charts need a different treatment than UI color. Series colors must stay distinguishable from each other, from the background, and across color-vision deficiencies.

**Dark-mode-specific rules:**

- **Lighten every series ~15%** (same rule as accents, §4). Light-mode palette at L≈0.50 → dark at L≈0.65–0.70 (OKLCH).
- **Reduce saturation ~15%** to avoid vibration.
- **Cap at ~8 series.** Dark mode collapses faster than light — yellow + green become indistinguishable olive past C≈0.10.
- **Equal perceptual lightness across all series** (ΔL ≤ 0.05 OKLCH) so no series dominates visually.
- **Maintain ≥3:1 contrast** between adjacent series and between each series and the dark body.
- **Gridlines subtle:** – (4–10% alpha white) in dark mode.
- **Axis labels & ticks** use ; **value labels** use ; **tooltips** use .
- **Color is never the only indicator.** Pair with stroke patterns (line), shape (scatter), or direct labels (pie/bar). Per WCAG 1.4.1 this is a Level A requirement.
- **Colorblind-safe palettes:** default to published palettes (Okabe-Ito, Paul Tol bright/muted) and tune per-mode — don'''t invent a new categorical palette without testing.

**Sources:** [Okabe-Ito (Wilke, *Fundamentals of Data Visualization*)](https://clauswilke.com/dataviz/color-pitfalls.html), [Paul Tol's color schemes](https://cran.r-project.org/web/packages/khroma/vignettes/tol.html), [*Accessible Color Sequences for Data Visualization* (arXiv 2024)](https://arxiv.org/html/2107.02270v3), [Harvard Digital Accessibility — Data Viz](https://accessibility.huit.harvard.edu/data-viz-charts-graphs), [WebAIM — 1.4.1 Use of Color](https://webaim.org/articles/contrast/).

---

## 10. User Preference & Switching

Usage (directional, not peer-reviewed):

- ~80% of users want the **choice**
- ~30–50% prefer dark mode as default (varies by audience)
- Developer tools, creative tools, and entertainment skew dark (60–80%)
- Productivity and document-heavy products skew light (60–70%)

**Best practice:**

- Support [`prefers-color-scheme`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/prefers-color-scheme) — expose `color-scheme` CSS property / ``.
- Support [`forced-colors`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/forced-colors) for Windows High Contrast Mode.
- Offer three options: Light, Dark, System. Default to System.

---

## 11. What the Best Products Get Right

| Product | What they do well |
|---------|-------------------|
| **Linear** | Tinted dark backgrounds (slight blue), subtle borders for depth, desaturated accents |
| **GitHub** | Multiple dark themes (Dimmed vs Dark), proper image handling, high-contrast option |
| **Figma** | Canvas-appropriate dark; editors need different treatment than dashboards |
| **Stripe Docs** | Syntax highlighting optimized per theme, not just inverted |
| **Apple HIG** | Materials system (translucency + vibrancy) adds context-aware depth |
| **Discord** | Not pure black, consistent across surfaces, good at media-heavy content |
| **Radix Themes** | Inset shadow as a first-class token; alpha-based color scales make dark mode "just work" |

---

## 12. Common Failures

1. **Pure black + pure white text** — halation, eye strain
2. **Same saturated colors in both modes** — vibrate, feel harsh
3. **Shadows unchanged in dark mode** — invisible, breaks depth hierarchy
4. **Forgetting media** — images blow out the layout
5. **No intermediate grays** — everything feels the same priority
6. **Inverting semantic meaning** — "red for success because it's dark mode" — don't
7. **Single dark theme** — high-contrast users and low-contrast users have opposite needs
8. **Hardcoded hex in component overrides** — silently breaks one mode
9. **Pastel-in-both-modes for large surfaces** — works in light, blinding in dark
10. **Pure math derivation without visual check** — passes contrast ratios, still feels wrong

---

## 13. Accessibility Requirements (Non-Negotiable)

- All text passes **WCAG 2.1 AA** (4.5:1 body, 3:1 large)
- All non-text UI indicators (focus, selection, borders) pass 3:1
- Interactive states are distinguishable (focus, hover, active, disabled)
- Color is never the **only** indicator of state
- Respect `prefers-color-scheme` media query
- `forced-colors` mode does not break layouts
- Offer a **high contrast** dark option for users with low vision
- Test with color blindness simulators — dark mode amplifies some confusion patterns

---

## Acceptance Criteria

For any theme shipping a dark mode:

- [ ] Documented base surface color and elevation strategy (tints vs shadows vs inset)
- [ ] All text tokens pass WCAG AA against their intended backgrounds in both themes
- [ ] All non-text UI indicators pass 3:1
- [ ] Accent colors tuned per-theme (not reused across light/dark)
- [ ] Categorical/status backgrounds pass visual equivalence test (same "feel" in both modes)
- [ ] No component-level override hardcodes a hex outside `light-dark()` / `var()`
- [ ] `prefers-color-scheme` support verified; `forced-colors` does not break layouts
- [ ] No pure `#000` background; no pure `#FFF` body text
- [ ] Media components handle dark background without blow-out
- [ ] Chroma floor ≥ 0.10 (OKLCH) maintained on categorical dark backgrounds
- [ ] Published guidance in docs for theme authors extending the system

---

## Summary: The Formula

The best dark mode = **dark gray base** + **elevation via lightness** + **desaturated colors tuned per role** + **reduced text contrast (but above AA)** + **semantic tokens with no hardcoded overrides** + **proper media handling** + **user choice**.

It's not a filter applied to light mode. It's a parallel design language that shares *intent* but differs in *execution*.

---

## Claims Flagged as Less Rigorously Sourced

For transparency:

- **"~50% of the population has astigmatism"** — widely cited, attributed to Jason Harrison (UBC, 2002), but the original source is not easily accessible. Directionally supported; exact percentage should be treated with caution.
- **Specific elevation ramp hex values** (e.g. `#1E1E1E`, `#252525`) — practitioner convention, not formal spec. Material Design's overlays are opacity-based; exact resulting hex depends on base.
- **"Desaturate accents by 10–20%"** — common heuristic without a peer-reviewed basis.
- **Per-product-category user preference splits** — no primary data.
- **"Sweet spot 7:1–12:1 / halation above 15:1"** — halation phenomenon is real (sourced), specific thresholds are practitioner convention.
- **Product characterizations** (Linear, GitHub, Figma, etc.) — based on observation, not cited research.
- **"Chroma floor ≥ 0.10"** — derived from practical testing (Neutral theme), not an external standard.

---

## References

- [Material Design — Design a dark theme (Google Codelabs)](https://codelabs.developers.google.com/codelabs/design-material-darktheme/)
- [Flutter — `ElevationOverlay` API](https://api.flutter.dev/flutter/material/ElevationOverlay/colorWithOverlay.html)
- [Fluent 2 Design System — Elevation](https://fluent2.microsoft.design/elevation)
- [Radix Themes — Shadows](https://www.radix-ui.com/themes/docs/theme/shadows)
- [Android Developers — Add shadows in Compose](https://developer.android.com/develop/ui/compose/graphics/draw/shadows)
- [MDN — `prefers-color-scheme`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/prefers-color-scheme)
- [MDN — Color contrast (WCAG)](https://developer.mozilla.org/en-US/docs/Web/Accessibility/Guides/Understanding_WCAG/Perceivable/Color_contrast)
- [MDN — `forced-colors`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/At-rules/@media/forced-colors)
- [WebAIM — Contrast and Color Accessibility](https://webaim.org/articles/contrast/)
- [W3C — Understanding SC 1.4.3](http://www.w3.org/TR/UNDERSTANDING-WCAG20/visual-audio-contrast-contrast.html)
- [Stripe — Designing accessible color systems](https://stripe.com/blog/accessible-color-systems)
- [web.dev — Terra dark mode case study](https://web.dev/case-studies/terra-dark-mode)
- [MDPI (2025) — Immediate Effects of Light/Dark Mode on Visual Fatigue](https://www.mdpi.com/1660-4601/22/4/609)
- [PubMed (2022) — Dark mode vogue](https://pubmed.ncbi.nlm.nih.gov/36533999/)
- [arXiv (2024) — Contrast polarity and age groups](https://arxiv.org/abs/2409.10841v2)
- [ACM ETRA (2025) — eye-tracking study](https://dl.acm.org/doi/10.1145/3715669.3725879)
- [Toptal — The Principles of Dark UI Design](https://www.toptal.com/designers/ui/dark-ui-design)
- [Muzli — Why simply inverting isn't enough](https://medium.muz.li/dark-mode-design-why-simply-inverting-colors-is-not-enough-e2584ebb139b)
- [Legge Lab — Contrast Coding publications](https://legge.psych.umn.edu/contrast-coding)

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.