Dark Mode Makes Text Look Heavier Than It Is

When I recently rolled out a dark mode option on a site I maintain, something looked off. Every heading and line of body copy seemed bulkier, as if all the letterforms had suddenly put on weight. The effect was consistent — it didn’t matter which font I loaded or which browser I fired up.

Below is what Adobe’s Source Sans Pro looks like in Chrome for Windows when toggled to dark mode:

See those blurry edges when we switch to dark mode?

The change isn’t an optical illusion. Against a dark background, light characters genuinely display a heavier stroke weight. The difference becomes clearer when you zoom in:

The characters really are thicker in dark mode!

Inverting the dark-mode crops makes the contrast between type weights unmistakable:

We can really see the difference when putting the characters side-by-side on the same white background.
We can really see the difference when putting the characters side-by-side on the same white background.

Three Ways to Thin Out Text in Dark Mode

Variable fonts are well supported across browsers, which means they give us workable tools to counteract this unwanted bolding. Below are the main approaches. The first is generic; the other two depend on the font you choose to use. The panels below preview the outcome we’re aiming for:

The top shows us some light text on a dark background. The middle panel shows what happens in dark mode without changing any font weight settings. And the bottom panel demonstrates dark mode text that we’ve thinned out a bit. That third panel is adjusted to match the weight of its light counterpart, which is what we’re trying to accomplish here.
  1. Lower the font-weight in dark mode. You can do this either by hand-editing each weight declaration inside a dark-mode media query, by introducing a single --font-weight-multiplier custom property whose value shifts in dark mode, or by applying that same custom-property calculation globally via the universal selector (*) so you don’t have to touch individual rules.
  2. Adjust the grade (GRAD) axis. Some variable fonts expose a grade axis that changes apparent stroke weight without altering the space the glyphs occupy. Roboto Flex supports this. You’ll need to dial in the right axis value, but letter-spacing remains unaffected.
  3. Switch on a darkmode axis. Dalton Maag’s Darkmode typeface ships with a dedicated "DRKM" axis. Turning it on thins the font; turning it off returns it to its regular weight. Unlike grade, you flip a single on/off — no value fiddling required.

The first strategy is the most portable, and is the one Robin Rendle applies in his original write-up. The latter two rely on font-variation-settings axes that fewer fonts implement, so your choice of type sets the limit.

A demonstration page I put together lets you cycle between light mode, unadjusted dark mode, and all of these thinned-out dark-mode variants with different variable fonts. You can view it here.

And if you can stomach a slight doubling of stroke weight in such conditions, you can also simply do nothing at all. That’s a valid choice when you don’t have time to deal with reflow, element resizing, inconsistent browser rendering, and the extra CSS such a fix can bring. Skip the tweak now with the freedom to come back later.

Reducing font-weight Values Directly

Most variable text fonts expose a weight axis that allows you to assign any value within the font’s declared range (for instance, 0–1000 or 300–800). Each variant in the first strategy capitalizes on this fine-grained control so that dark mode can carry a lighter load. That same requirement for fine weight steering is why non-variable fonts are mostly out of the running here.

If fonts are installed locally, you can inspect each family’s axes and possible values with Wakamai Fondue:

At Wakamai Fondue, you can view any local font’s variable axes and ranges.

One caveat for local fonts loaded with @font-face: give each font an explicit font-weight range at the same time you define it:

@font-face {
  src: url('Highgate.woff2') format('woff2-variations');
  font-family: 'Highgate';
  font-weight: 100 900;
}

Ignoring that step can keep some variable fonts in Chromium browsers from reflecting specific font-weight values correctly.

Dalton Maag Highgate’s font-weight set to 800 in Chrome without (left) and with (right) a font-weight range specified in the @font-face rule.

A CSS Custom Property Approach

Rather than hard-coding alternate weights inside a prefers-color-scheme media query, we can define a single custom property and use it in a calculation wherever we set font-weight. The media query then only needs to change that one variable.

Start by defining a multiplier in the dark mode query:

@media (prefers-color-scheme: dark) {
  :root {
    --font-weight-multiplier: .85;
  }
}

Then, instead of writing a static weight, multiply your desired default weight by the variable:

body {
  font-weight: calc(400 * var(--font-weight-multiplier, 1));
}

The var() function includes a fallback of 1. Since --font-weight-multiplier is only set inside the dark mode query, the body text stays at 400 in light mode (400 * 1), then drops to 340 (400 * 0.85) in dark mode. Bold text follows the same pattern:

strong, b, th, h1, h2, h3, h4, h5, h6 {
  font-weight: calc(700 * var(--font-weight-multiplier, 1));
}

That takes bold from 700 to 595 in dark mode. A multiplier of 0.85 works well for many faces—Adobe Source Sans Pro, used in the demos here, responds nicely to it, but the value is easy to tune per font.

Here's the full pattern in context:

/* DARK-MODE-SPECIFIC CUSTOM PROPERTIES */
@media (prefers-color-scheme: dark) {
  :root {
    --font-weight-multiplier: .85;
  }
}

/* DEFAULT CSS STYLES... */
body {
  font-weight: calc(400 * var(--font-weight-multiplier, 1));
}

strong, b, th, h1, h2, h3, h4, h5, h6 {
  font-weight: calc(700 * var(--font-weight-multiplier, 1));
}

Inverting the Pattern

The approach above still requires you to wrap every font-weight declaration in calc() and var(), remember the multiplier's name, and supply a fallback. An alternative is to set weights everywhere on a --font-weight custom property, then apply them once at the end.

First, replace each font-weight rule with a corresponding --font-weight custom property holding the default value:

h1 {
  font-weight: calc(800 * var(--font-weight-multiplier, 1);
}

summary {
  font-weight: calc(600 * var(--font-weight-multiplier, 1);
}

Then apply the calculation one time, with a universal selector:

h1 {
  --font-weight: 800;
}

summary {
  --font-weight: 600;
}

* {
  font-weight: calc(var(--font-weight, 400) * var(--font-weight-multiplier, 1);
}

The calc() multiplies each --font-weight value by the multiplier variable, and the font-weight property assigns the result to the matching element.

This "set everywhere, apply once" technique is cleaner to edit, but it has a significant risk: the universal selector affects everything, including elements you might not want thinned out (for instance, form fields that keep dark text on light backgrounds in dark mode). There are two ways to handle that:

  • Opt in: replace * with a selector list of only the elements that should be lightened.
  • Opt out: hard-code font-weight for the elements you want to exclude.
* {
  font-weight: calc(var(--font-weight, 400) * var(--font-weight-multiplier, 1));
}

button, input, select, textarea {
  font-weight: 400;
}

For projects where only some elements need varying weights, the more explicit first technique may still be the safer choice. Both are valid, and the right call depends on how many weights you manage and how confident you are in the universal selector's side effects.

The final code for this variation:

/* DEFAULT CUSTOM PROPERTIES */
:root {
  --font-weight: 400;
  --font-weight-multiplier: 1;
}
strong, b, th, h1, h2, h3, h4, h5, h6 {
  --font-weight: 700;
}

/* DARK-MODE-SPECIFIC CUSTOM PROPERTIES */
@media (prefers-color-scheme: dark) {
  :root {
    --font-weight-multiplier: .85;
  }
}

/* APPLYING THE CUSTOM PROPERTIES... */
* {
  font-weight: calc(var(--font-weight, 400) * var(--font-weight-multiplier, 1));
}

The default --font-weight: 400 and --font-weight-multiplier: 1 assignments aren't strictly required, since fallback values in the var() functions cover them, but setting them explicitly in one spot makes later edits easier to locate.

If your font supports a "wght" axis, the same logic works through font-variation-settings, which can be helpful for typefaces with multiple axes (Roboto Flex, for example, has 13):

* {
  --wght: calc(var(--font-weight, 400) * var(--font-weight-multiplier, 1));
  font-variation-settings: "wght" var(--wght);
}

Compensating with Letter Spacing

Lowering a font's weight also narrows its characters for most non-monospaced typefaces. Your layout and element sizes—button widths, for instance—will shift. Whether that's a problem is a design decision, but a common fix is to open up letter spacing slightly in dark mode.

Define another variable with a default of 0:

:root {
  /* ...other custom variables... */
  --letter-spacing: 0;
}

Then raise it in the dark mode query. A value of .02ch pairs well with a weight multiplier of 0.85:

@media (prefers-color-scheme: dark) {
  :root {
    /* ...other custom variables... */
    --letter-spacing: .02ch;
  }
}

Apply it with the same universal selector, using a fallback of 0:

* {
  /* ...other property settings... */
  letter-spacing: var(--letter-spacing, 0);
}

The ch unit works well, though em is equivalent in practice; for Source Sans Pro, .009em is roughly equal to .02ch. Full solution:

/* DEFAULT CSS CUSTOM PROPERTIES */
:root {
  --font-weight: 400;
  --font-weight-multiplier: 1;
  --letter-spacing: 0;
}

strong, b, th, h1, h2, h3, h4, h5, h6 {
  --font-weight: 700;
}

/* DARK MODE CSS CUSTOM PROPERTIES */
@media (prefers-color-scheme: dark) {
  :root {
    /* Variables to set the dark mode bg and text colors for our demo. */
    --background: #222;
    --color: #fff;

    /* Variables that affect font appearance in dark mode. */
    --font-weight-multiplier: .85;
    --letter-spacing: .02ch;
  }
}

/* APPLYING CSS STYLES... */
* {
  font-weight: calc(var(--font-weight, 400) * var(--font-weight-multiplier, 1));
  letter-spacing: var(--letter-spacing, 0);
}

body {
  background: var(--background, #fff);
  color: var(--color, #222);
}

Fonts That Don't Need Letter-Spacing Fixes

Some typefaces are designed with constant character widths regardless of weight, sometimes called "multi-plexed" fonts. In Recursive Sans from Arrow Type, an "i" keeps its width at 400 and 700, as does a "w". For faces like this, no letter-spacing compensation is necessary, and your page flow stays intact.

Adjusting the Grade Axis

The variable font grade axis ("GRAD") changes apparent weight without affecting character widths. For Roboto Flex, grade values run from -1 (thinnest) through 0 (normal) to 1 (thickest). A grade of about -0.75 for dark mode is a good starting point:

Roboto Flex in light mode, dark mode default, and dark mode with “GRAD” set to -.75
:root {
  --GRAD: 0;
}

@media (prefers-color-scheme: dark) {
  :root {
    --GRAD: -.75;
  }
}

body {
  font-variation-settings: "GRAD" var(--GRAD, 0);
}

If your font has a grade axis, this can feel like an ideal solution—but there are caveats. Grade scales are not universal:

  • Some fonts range from 0 to 1 instead of -1 to 1.
  • At least one typeface (Amstelvar) uses percents, with 100 as the default.
  • Other fonts align grade with font weights, so the scale is 100-900.

In the last case, you may need to set all your weights to 400 and rely on grade for any variation. For dark mode, treat grade like the weight multiplier. The second caveat: some fonts won't let you set grade below their default weight. Apple's San Francisco, for example, has a grade axis (as of macOS Catalina) scaled to font weights, with a minimum of 400. You can't lighten it from a default of 400 in dark mode—you'd have to lower the "wght" axis instead.

San Francisco’s grade and weight axes use the same scale, but have different ranges.

A Dedicated Darkmode Axis

Only one font family currently ships with a darkmode axis: Dalton Maag's Darkmode. The "DRKM" axis works like a binary version of grade: set it to 1 for a thinner appearance in dark mode, 0 (the default) for normal display.

Darkmode in light mode, in dark mode with “DRKM” unset, and in dark mode with “DRKM” set to 1.
:root {
  --DRKM: 0;
}

@media (prefers-color-scheme: dark) {
  :root {
    --DRKM: 1;
  }
}

body {
  font-variation-settings: "DRKM" var(--DRKM, 0);
}

The font is commercial; Dalton Maag offers a trial version for "academic, speculative, or pitching purposes only." If more foundries adopt this axis, it could become a simple, standard solution for variable font dark mode styling.

Screen Density and Font Mixing

High-Resolution Displays

On "retina" screens with higher pixel densities, the thickening effect of dark mode is often less pronounced. You may want a smaller adjustment, or none at all. Add another media query below the original one (order matters—media queries don't add specificity, so the later rule wins) and tune the multiplier accordingly:

@media (prefers-color-scheme: dark) and (-webkit-min-device-pixel-ratio: 2), 
       (prefers-color-scheme: dark) and (min-resolution: 192dpi) { 
  :root {
    --font-weight-multiplier: .92;
    /* Or, if you're using grade or darkmode axis instead: */
    /* --GRAD: -.3; */
    /* --DRKM: 0; */
  }
}

If you want no adjustment on high-density screens, modify the original dark mode query to exclude them:

@media (prefers-color-scheme: dark) and (-webkit-max-device-pixel-ratio: 1.9), 
       (prefers-color-scheme: dark) and (max-resolution: 191dpi) { 

  /* Custom properties for dark mode go here. */

}

Multiple Typefaces

When a site mixes fonts with different axes—or variable with non-variable fonts—the effects can stack accidentally. Reducing grade and weight simultaneously for one font while only affecting weight for another produces inconsistent results.

If your stylesheet includes solutions for several typefaces/axes, then the effect on fonts that have multiple axes (like this example’s Roboto Flex, which has both grade and weight axes) may be cumulative.

Here's a practical guide, depending on your font stack:

  • All variable fonts with matching grade axes: grade is the cleanest option, but revisit it if you later add a face that doesn't comply.
  • Variable fonts with different axes: the --font-weight-multiplier custom property approach is your safest bet.
  • Mixed variable and static fonts: non-variable fonts won't respond to any of these strategies. If you use font-weight for the calculation, some of your weights may shift to the next lower named instance (e.g., bold displaying as semi-bold), while lower weights remain static. Applying via font-variation-settings avoids affecting the static fonts entirely—they'll stay at their default weight in dark mode.