How to dark-mode typography implementation in production
- Step 1Tier 0 — colour-only flip (most sites) — Swap text and background colours under `prefers-color-scheme: dark`, leave the type alone. Cheapest, and acceptable for content-light UIs, but body text will look heavier in dark mode because nothing compensates for halation. This is the baseline most production sites stop at.
- Step 2Tier 1 — add the weight drop (recommended minimum) — Add one declaration: drop the body `font-weight` in the dark media query (400 → 300). This is the single highest-leverage change and costs one line. Generate it with the adjuster and keep only the `font-weight` line if you want to start minimal.
- Step 3Tier 2 — full weight + spacing + line-height — Use the adjuster's complete output: weight − 100, letter-spacing + 0.01em, line-height + 0.05. This is the right tier for reading apps, documentation, and brand-led marketing sites where typography quality is part of the product.
- Step 4Add the colour layer the tool does not emit — On top of any tier, pull dark text to an off-white (~#EAEAEA) and the background to an off-black (~#1C1C1E) rather than pure white on pure black. This reduces halation at its source. The adjuster outputs no colour, so add this by hand.
- Step 5Decide on a manual switcher — If users can force a theme, duplicate your dark declarations under a `[data-theme="dark"]` selector so the toggle wins over the OS preference. The adjuster's output keys on `prefers-color-scheme` only — extend it once after pasting.
- Step 6Verify on real devices and roll out — Check the chosen tier on an OLED phone (worst-case halation) and an LCD laptop. Confirm body text reads at matched density across modes. Then ship the media query globally — it is additive and degrades gracefully on browsers without dark mode.
The three implementation tiers
How real sites tier their dark-mode typography effort. Snippets are what the adjuster emits for a 400/1.5/0 body.
| Tier | What changes | Effort | Best for |
|---|---|---|---|
| Colour-only flip | Text/background colour only, weight unchanged | Minimal | Content-light UIs, MVPs |
| Weight drop | font-weight 400 → 300 in dark | One line | Most sites — the recommended minimum |
| Full treatment | Weight − 100, +0.01em tracking, +0.05 line-height | One media-query block | Reading apps, docs, brand sites |
| Full + variable axis | Above, plus a tuned wght axis | Block + manual axis edit | Premium consumer apps |
Effort vs payoff per tier
Where the diminishing returns are. The biggest jump in perceived quality comes from Tier 0 to Tier 1; Tier 2 polishes.
| Tier | Lines of CSS | Perceived improvement | Tool covers it? |
|---|---|---|---|
| Colour-only | ~2 | None for type weight | No — colour not emitted |
| Weight drop | +1 | Large | Yes (keep the weight line) |
| Full | +3 | Large + refined | Yes (full output) |
| Full + axis | +3 and manual | Refined further | Partial — weight number only |
Generated dark values by body weight
The dark-mode values the adjuster produces for common production body weights, so you can pick your tier and paste.
| Light body weight | Dark weight (Tier 1+) | Dark letter-spacing | Dark line-height (from 1.5) |
|---|---|---|---|
| 400 Regular | 300 | 0.01em | 1.55 |
| 450 | 350 | 0.01em | 1.55 |
| 500 Medium | 400 | 0.01em | 1.55 |
| 350 | 250 | 0.01em | 1.55 |
| 300 Light | 250 | 0.01em | 1.55 |
Cookbook
The three tiers as paste-ready CSS. The Standard and Full snippets are verbatim adjuster output for a 400/1.5/0 body; the colour layer is added by hand because the tool does not emit it.
Tier 0 — colour-only flip
ExampleWhat most sites ship. Colours invert, type is untouched, so body text looks heavier in dark mode. Acceptable for glanceable UIs, not for reading.
@media (prefers-color-scheme: dark) {
body {
color: #EAEAEA;
background: #1C1C1E;
/* weight unchanged — text will read heavier */
}
}Tier 1 — weight drop only
ExampleThe recommended minimum: one extra line. Take the adjuster output and keep only the font-weight change for the lowest-cost real improvement.
body { font-weight: 400; }
@media (prefers-color-scheme: dark) {
body { font-weight: 300; }
}Tier 2 — full treatment (verbatim tool output)
ExampleThe complete adjuster output for a 400/1.5/0 body. Weight, tracking, and leading all compensate for halation. This is the tier reading-heavy and brand sites should use.
body {
font-weight: 400;
letter-spacing: 0em;
line-height: 1.5;
}
@media (prefers-color-scheme: dark) {
body {
font-weight: 300;
letter-spacing: 0.01em;
line-height: 1.55;
}
}Tier 2 + colour layer (the production-complete version)
ExampleThe full typographic treatment plus the colour layer that attacks halation at its source. This combination is what polished production sites actually ship.
body { font-weight: 400; letter-spacing: 0em; line-height: 1.5; }
@media (prefers-color-scheme: dark) {
body {
font-weight: 300;
letter-spacing: 0.01em;
line-height: 1.55;
color: #EAEAEA; /* not pure white */
background: #1C1C1E; /* not pure black */
}
}Full + variable axis (premium)
ExampleFor a variable font you can replace the static weight with a tuned axis. The adjuster gives the number (300); you write the axis declaration. Smoother during the system theme transition.
@media (prefers-color-scheme: dark) {
body {
/* static fallback from the tool */
font-weight: 300;
/* manual axis for variable fonts */
font-variation-settings: "wght" 320;
letter-spacing: 0.01em;
line-height: 1.55;
}
}Edge cases and what actually happens
Every row below was probed against the live API. Some documented requirements (alphabetical axis order, numerical tuple order) are not actually enforced in practice — useful to know if you've been blaming the wrong thing for a 400.
Colour-only flip leaves text looking heavy
Expected — no compensationInverting only the colours is the most common production pattern, but it does nothing about halation, so body text reads heavier in dark mode than in light. It is acceptable for content-light or glanceable UIs. For anything people read, add at least the Tier 1 weight drop — one line of CSS from the adjuster.
Full treatment looks like too much on a dashboard
Right-size the tierA data-dense dashboard skimmed in seconds does not need the full +0.01em / +0.05 refinement; the weight drop alone is usually enough. Match the tier to how the interface is used — reserve the full treatment for reading-heavy and brand-led surfaces. The tool emits the full set; trim lines you do not want.
Variable axis tuning is manual, not from the tool
By design — number onlyThe 'Full + axis' tier needs a font-variation-settings: 'wght' … line, which the adjuster does not emit — it outputs static font-weight. Use its number as your starting axis value and adjust by eye. For axis-driven CSS at scale use css-variable-generator-font or freeze instances with variable-font-freezer.
Tool targets body; production needs per-role rules
Run per role, rename selectorReal sites tune body and headings differently. The adjuster writes body each run, so generate once per role (body, h1, h2) and rename the selector after pasting. Heavier heading weights gain the most from the −100 drop, so do not skip headings.
Manual theme toggle ignores the media query
Add a data-attribute blockIf users can force dark mode with a switcher, the prefers-color-scheme query alone will not respond to it. Duplicate the dark declarations under [data-theme="dark"] so the toggle wins. The adjuster only emits the media-query form — extend it once after pasting.
Pure white on pure black despite the weight drop
Out of scope — fix colour tooEven with the weight reduced, #FFF on #000 glares because it is the maximum-contrast case that drives halation. Pair any tier with off-white text and an off-black background. The adjuster emits no colour, so this is always a hand-added layer.
Two font files loaded for two modes
Avoid — one font, adjustedSome teams swap to a different typeface in dark mode. This is jarring during the system transition and doubles font bandwidth. The production-correct pattern is one font with adjusted weight/spacing/line-height — exactly what the tool produces. Do not ship a second font for dark mode.
Browser without dark-mode support
Supported — degrades gracefullyBrowsers that do not understand prefers-color-scheme simply ignore the media query and use the light body {} baseline. The output is additive and safe to ship globally — no feature detection or fallback CSS needed.
Frequently asked questions
What's the minimum useful effort?
The weight drop: light 400 → dark 300 in a prefers-color-scheme: dark query. One line of CSS, visible improvement, negligible cost. Generate it with the adjuster and keep just the font-weight line if you want to start minimal. Most sites should at least do this.
Is a colour-only flip good enough?
For content-light or glanceable UIs, often yes. But it leaves body text looking heavier in dark mode because nothing compensates for halation. If people read on your site for more than a glance, add the weight drop — it is the highest-leverage single change.
When is the full treatment worth it?
Reading-heavy interfaces (documentation, reading apps, long-form articles) and brand-sensitive sites where typography is part of the product. There the +0.01em tracking and +0.05 line-height refinements matter. For a quick dashboard, the weight drop alone is usually enough.
Do real sites use different weights for headings and body?
Yes. Heavier weights bloom more, so headings benefit most from the −100 drop. Run the adjuster once per role and rename the selector (it always writes body). A common pattern is body 400 → 300 and headings 700 → 600.
Should I swap fonts in dark mode?
Almost never. A different typeface is jarring during the OS theme transition and doubles font bandwidth. Keep one font and adjust its weight, tracking, and leading — which is exactly the adjuster's output. Same font, compensated, is the production-correct approach.
How do I handle a theme switcher alongside the OS preference?
Duplicate your dark declarations under [data-theme="dark"] so an explicit user choice beats the system setting, and keep the prefers-color-scheme block for users who have not toggled. The tool emits only the media-query form, so add the data-attribute variant after pasting.
Does the adjuster emit the colour layer too?
No — only font-weight, letter-spacing, and line-height. For the production-complete pattern, add off-white text (~#EAEAEA) on an off-black background (~#1C1C1E) yourself. Pure white on pure black re-introduces the glare the weight drop was meant to reduce.
What about high-contrast accessibility mode?
Optional and separate. prefers-contrast: more users want maximum legibility, which can mean keeping or raising weight rather than lowering it. Handle it in its own media query; the adjuster covers only prefers-color-scheme: dark. Some users prefer high-contrast over dark mode entirely.
Will this break browsers without dark mode?
No. Browsers that do not support prefers-color-scheme ignore the media query and render the light baseline. The output is additive — safe to deploy globally with no fallback handling.
Can I use a variable font axis like the premium tier?
Yes, but you write the axis line yourself. The adjuster outputs a static font-weight number; use it as the starting font-variation-settings: 'wght' value for a smoother transition. For axis CSS at scale, see css-variable-generator-font.
How does this fit with my font-loading strategy?
Independently — dark-mode adjustment is a rendering concern, font loading is a delivery concern. Pair this with font-display-strategy for FOUT/FOIT control and preload-tag-builder for critical-font preloads. They do not conflict.
What's the rollout risk?
Low. The change is a single additive media query that degrades gracefully. Verify on an OLED device first (worst-case halation), confirm matched density across modes, and ship globally. There is no destructive change to existing light-mode CSS.
Privacy first
Every JAD Font tool runs entirely in your browser using opentype.js and the wawoff2 WASM Brotli encoder. Your fonts never leave your device — verified by zero outbound network requests during processing.