Adapt typography for dark backgrounds — recalculate contrast ratios, adjust font weight and opacity for antialiasing differences, and build a systematic dark mode token approach.
Dark mode is not simply inverting colours. Text on a dark background renders differently at the pixel level — antialiasing behaves differently, thin strokes appear bolder, and the perceived contrast relationship between font weights changes. A typography system that looks balanced in light mode will often look too heavy and cramped in dark mode without specific adjustments. This lesson covers everything you need to build dark mode typography that feels as considered as your light mode.
On a light background, subpixel antialiasing (RGB striping) smooths font edges by blending into the white background — this adds apparent weight and makes thin strokes look slightly thicker. On a dark background, macOS switches to grayscale antialiasing by default, and the light strokes of the letterform appear to bleed into the dark background (an optical phenomenon called irradiation or halation). The result: a font at weight 400 on a dark background looks bolder, heavier, and denser than the same font at weight 400 on a light background. The practical consequence: body text that looks comfortable at 400 in light mode may need to be 300 in dark mode to maintain the same visual density. Bold headings at 700 in light mode often look fine at 600 in dark mode. Variable fonts make this compensation trivial — you can set font-weight: 350 in dark mode without loading a new file.
1/* Light mode: standard weights */
2body {
3 font-weight: 400;
4 -webkit-font-smoothing: auto; /* Subpixel on macOS */
5 -moz-osx-font-smoothing: auto;
6}
7
8/* Dark mode: reduce weight to compensate for halation */
9@media (prefers-color-scheme: dark) {
10 body {
11 font-weight: 350; /* Variable font: halfway between 300 and 400 */
12 /* Grayscale antialiasing on dark BG */
13 -webkit-font-smoothing: antialiased;
14 -moz-osx-font-smoothing: grayscale;
15 }
16}Contrast ratios that pass WCAG on a white background do not automatically pass on a dark background. Every text/background pair must be recalculated. A common mistake: designers specify a single text colour that worked against white (#ffffff → #374151 passes at 10:1) and simply 'invert' the palette by making text light and background dark without checking the new ratios. For a typical dark background of #0f172a (Tailwind slate-900), your text colours need to be recalculated: #f1f5f9 on #0f172a = 14.6:1 (primary text, excellent), #94a3b8 on #0f172a = 5.27:1 (secondary text, passes AA), #64748b on #0f172a = 3.07:1 (barely passes for large text only). Use Colour Contrast Analyser or coolors.co/contrast-checker for every dark mode pair.
1:root {
2 /* Light mode tokens */
3 --bg: #ffffff;
4 --text-primary: #1e293b; /* 16.3:1 on white */
5 --text-secondary: #64748b; /* 4.58:1 on white — passes AA */
6 --text-muted: #94a3b8; /* 2.85:1 on white — decorative only */
7}
8
9@media (prefers-color-scheme: dark) {
10 :root {
11 --bg: #0f172a;
12 /* Recalculated for dark bg — NOT the same as light mode */
13 --text-primary: #f1f5f9; /* 14.6:1 on #0f172a */
14 --text-secondary: #94a3b8; /* 5.27:1 on #0f172a — passes AA */
15 --text-muted: #64748b; /* 3.07:1 on #0f172a — large text only */
16 }
17}A clean dark mode implementation uses CSS custom properties for every typographic value that changes between modes: colour, font-weight, and optionally line-height. Define light mode values in :root, override them in a prefers-color-scheme: dark block. Apply the tokens throughout your CSS — never hardcode colours or weights directly. This approach ensures every element automatically adapts when the media query fires, without per-component overrides.
1/* Typography token system with dark mode */
2:root {
3 /* Colours */
4 --color-bg: #ffffff;
5 --color-text: #1e293b;
6 --color-text-muted: #64748b;
7 --color-text-code: #1d4ed8;
8
9 /* Weights: adjusted per mode */
10 --weight-body: 400;
11 --weight-heading: 700;
12 --weight-bold: 700;
13
14 /* Antialiasing */
15 --font-smooth: auto;
16 --macos-smooth: auto;
17}
18
19@media (prefers-color-scheme: dark) {
20 :root {
21 --color-bg: #0f172a;
22 --color-text: #f1f5f9;
23 --color-text-muted: #94a3b8;
24 --color-text-code: #93c5fd;
25
26 /* Lighter weights for dark bg halation */
27 --weight-body: 350;
28 --weight-heading: 650;
29 --weight-bold: 650;
30
31 --font-smooth: antialiased;
32 --macos-smooth: grayscale;
33 }
34}
35
36body {
37 background-color: var(--color-bg);
38 color: var(--color-text);
39 font-weight: var(--weight-body);
40 -webkit-font-smoothing: var(--font-smooth);
41 -moz-osx-font-smoothing: var(--macos-smooth);
42}
43
44h1, h2, h3 { font-weight: var(--weight-heading); }
45strong, b { font-weight: var(--weight-bold); }The halation effect in dark mode makes text appear visually denser even when the font weight is adjusted. Slightly increasing line-height in dark mode compensates for this perceived density. The increase needed is small — 0.05–0.1 units — but measurable in user studies. Also consider reducing opacity for secondary text rather than lightening the colour: rgba with alpha gives you a text colour that automatically adapts to any background colour change, which is useful for components that appear over various background colours. However, pure opacity reduction can fail WCAG if the background is not the expected dark colour, so prefer explicit colour tokens for guaranteed compliance.
1/* Optional: slightly looser leading in dark mode */
2@media (prefers-color-scheme: dark) {
3 body {
4 /* +0.05 to compensate for perceived density increase */
5 line-height: 1.7; /* vs 1.65 in light mode */
6 }
7
8 /* Slightly looser letter-spacing for dense text blocks */
9 .dense-text {
10 letter-spacing: 0.01em;
11 }
12
13 /* Code blocks on dark: higher contrast needed */
14 code, pre {
15 /* Dark bg code blocks need sufficient contrast for syntax colours */
16 --color-text-code: #93c5fd; /* Recalculated blue for dark */
17 background-color: #1e293b; /* Slightly lighter than page bg */
18 }
19}DevTools colour scheme emulation (Rendering panel → Emulate CSS media feature prefers-color-scheme) is good for a quick check but does not replicate real device rendering. On macOS with a Retina display, dark mode text looks different from the same screenshot viewed on a non-Retina Windows screen. Test on: (1) a real macOS device — check antialiasing behaviour under System Preferences, (2) a real Windows device — ClearType rendering differs from macOS, (3) an OLED phone screen — on true black (#000000) the contrast is extreme; text can look harsh. If using a pure black background, consider #111111 or #121212 to reduce harsh contrast. Also test at different screen brightness levels — dark mode at full brightness and dark mode at 20% brightness produce very different perceived contrast.
1/* OLED-friendly: avoid pure black to reduce harsh contrast */
2@media (prefers-color-scheme: dark) {
3 :root {
4 /* #000000 is harsh on OLED — use near-black instead */
5 --color-bg: #121212; /* Material Design dark recommended */
6 /* OR for navy-tinted dark: */
7 --color-bg: #0f172a; /* Tailwind slate-900 */
8 }
9}
10
11/* System dark mode class approach: allows user-toggled mode */
12[data-theme='dark'] {
13 --color-bg: #0f172a;
14 --color-text: #f1f5f9;
15 /* ... other tokens */
16}
17
18/* JS toggle to supplement prefers-color-scheme */
19/* document.documentElement.dataset.theme = 'dark' */Full token-based dark mode that adjusts colour, weight, antialiasing, and line-height.
Next lesson covers RTL typography and CSS logical properties — building layouts that work correctly for Arabic, Hebrew, and other right-to-left languages without duplicate CSS.
Your progress is saved in this browser, on this device only.