Black isn’t always slimming.
When recently testing a dark mode option for one of my sites, I experienced first-hand the issue that Robin Rendle addresses in this article. All of my page text — headings and body copy — appeared to bulk up when I switched to dark mode. And it didn’t matter what fonts I used or which browsers I tried. The same thing happened with all of them.
For example, here’s what happens with Adobe’s Source Sans Pro in Chrome for Windows:
It’s not an illusion. The light characters really are heavier against dark backgrounds. We can zoom in to see better:
And it becomes really obvious when we invert the dark mode portions of those images:
One solution
Since variable fonts enjoy wide browser support, we can use them to help us address this issue. The three panels below demonstrate a solution we’ll be working toward:
Here’s how we can get this improved effect:
- Reduce
font-weight
properties in dark mode via one of the following methods:- Manually changing each
font-weight
assignment directly in a dark mode media query. - Creating a single
--font-weight-multiplier
custom property that changes its value in dark mode, and by which we can then multiply by each element’s defaultfont-weight
value. - Same thing, but instead of calculating each element’s
font-weight
property individually, we take advantage of CSS variable scoping and the universal selector (*
) to apply our multiplier calculation everywhere at once.
- Manually changing each
- Adjust a variable font’s grade (“GRAD”) axis. Not all variable fonts support this specific feature, but Roboto Flex, does. Altering this axis value changes the font’s apparent weight, without affecting the width of the letters.
- Adjust a variable font’s darkmode (
"DRKM"
) axis. Dalton Maag’s aptly-named Darkmode, with its eponymous darkmode axis, is uniquely suited for this. As with the Roboto Flex’s grade axis, adjusting Darkmode’s darkmode axis changes the font’s apparent weight. But while the grade axis requires some fine-tuning of values, the darkmode axis is simply switched on (thinner) or off (regular).
The techniques in the first group work for most variable fonts. The solution Robin uses in his article is actually the very first item in the group. I’ll expand on the second and third items in the group by introducing custom properties that help us automatically adjust font weights in dark mode.
The second and third groups involve less common font-variation-settings
axes. Though these strategies apply to fewer typefaces, they may be preferable when available. The trick is knowing what a variable font supports before choosing it.
I’ve made a demonstration page including all the strategies covered in this article. You can see what some different variable fonts look like in light mode, in dark mode with no adjustment, and in dark mode with our solutions for thinning out characters.
In addition to the strategies listed above, there’s always one more option: don’t do anything! If you think your fonts look good enough in light and dark mode, or you don’t have the bandwidth right now to wrestle with reflow, element resizing, browser/display inconsistencies, and extra CSS to maintain, then you may not have to change a thing. Focus on the rest of your site and leave yourself open to the possibility of revisiting this topic later.
font-weight
value
Strategy 1: Reducing the Most variable text fonts have a weight axis, which lets us assign any specific font-weight
value within the weight range available to that font (e.g. 0-1000, 300-800, etc.). Each technique in this strategy takes advantage of this fine control over the weight axis to reduce font-weight
values in dark mode. (The need for such font-weight
precision is also what renders most non-variable fonts unsuitable for this solution.)
If you’re using variable fonts you have locally, you can check their axes and value ranges at Wakamai Fondue:
Keep in mind that, if you’re using the @font-face
rule to load fonts, you should set a font-weight
range for each of them at the same time:
@font-face {
src: url('Highgate.woff2') format('woff2-variations');
font-family: 'Highgate';
font-weight: 100 900;
}
If you neglect this step, some variable fonts may not properly reflect specific font-weight
values in current Chromium browsers.
The basic solution: Manually entering each weight
Here’s the technique most of us may reach for. We create a dark mode media query in which we enter some font-weight
values that are a bit lower than their defaults.
/* Default (light mode) CSS */
body {
font-weight: 400;
}
strong, b, th, h1, h2, h3, h4, h5, h6 {
font-weight: 700;
}
/* Dark mode CSS */
@media (prefers-color-scheme: dark) {
body {
font-weight: 350;
}
strong, b, th, h1, h2, h3, h4, h5, h6 {
font-weight: 600;
}
}
It works, and it’s no problem to maintain — so long as we’re not planning on adding or editing any other weights at our site! But if we do start incorporating more weights, it can get unwieldy fast. Remember to enter each selector/property combo both outside and inside the prefers-color-scheme
media query. We’ll have to do some manual calculations (or guesswork) to determine the dark mode property values for each element.
Creating a weight multiplier custom property and using it in a calculation when setting an element’s weight
I generally try to adhere to Mike Riethmuller’s credo that “media queries are only used to change the value of custom properties.” And that’s the improvement we make in this solution. Instead of having to enter font weights for all our elements in and out of dark mode, the only thing we’re putting in our media query is a --font-weight-multiplier
custom property:
@media (prefers-color-scheme: dark) {
:root {
--font-weight-multiplier: .85;
}
}
Then, for all our font-weight
properties throughout the stylesheet, we’ll multiply the variable’s value by our preferred default weight value for each element — thus lowering the font weight by 15% in dark mode. If we’re not in dark mode, we’ll multiply the default weight by 1, meaning it doesn’t change at all.
Here’s what I mean. Normally, we’d use this to set a body font weight of 400:
body {
font-weight: 400;
}
For this solution, we use this:
body {
font-weight: calc(400 * var(--font-weight-multiplier, 1));
}
In the var()
function, notice that our variable has a fallback value of 1. Because --font-weight-multiplier
is only set in dark mode, this fallback value will be used the rest of the time. So, by default, our font weight for body text stays at 400 (400*1
). But in dark mode, the weight decreases to 340 (400*.85
).
We’ll also do this with bold elements:
strong, b, th, h1, h2, h3, h4, h5, h6 {
font-weight: calc(700 * var(--font-weight-multiplier, 1));
}
These weights will decrease from 700 to 595 (700*.85
) in dark mode.
And we can use the same technique for any other elements where we want to set the font-weight
to something other than 400 by default.
I’m using a value of .85 for --font-weight-multiplier
, because I’ve found that to be a good general value for most fonts (like Adobe Source Sans Pro, the free typeface I use in most of this article’s demos). But feel free to play around with that number.
Here’s how this looks put together:
/* 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));
}
Creating a weight multiplier variable and automatically calculating and applying it to all elements at once.
When using many CSS custom properties, I think many of us stick to a “set as needed and manually apply everywhere” approach. That’s what the previous solution does. We set our custom property value in the :root
(and/or use a fallback value), set it again in a media query, then apply it with calc()
and var()
functions throughout our stylesheet each time we assign a font-weight
value.
The code might look something like this:
h1 {
font-weight: calc(800 * var(--font-weight-multiplier, 1);
}
summary {
font-weight: calc(600 * var(--font-weight-multiplier, 1);
}
But when we use this technique for various elements, you can see we have to do these three things every time we assign font-weight
values:
- Include the
calc()
function - Include the
var()
function - Remember the
--font-weight-multiplier
custom property’s name and default value
Instead, I’ve recently started inverting this approach for certain tasks, taking advantage of CSS variable scope with a “set everywhere and apply once” method. For this technique, I replace every font-weight
property in the stylesheet with a --font-weight
variable, keeping the name the same except for the dashes, for simplicity’s sake. I then set this value to the default weight for that particular selector (e.g. 400 for body text). Neither calc()
nor var()
is needed — yet. This is how we set everywhere.
Then we apply once, with a lone font-weight
property in our stylesheet that sets every text element’s weight via the universal selector. Modifying our snippet above, we’d now have this:
h1 {
--font-weight: 800;
}
summary {
--font-weight: 600;
}
* {
font-weight: calc(var(--font-weight, 400) * var(--font-weight-multiplier, 1);
}
The calc()
function multiplies each of our --font-weight
custom properties by our multiplier variable, and the font-weight
property then applies the value to its appropriate element.
It’s unnecessary to use only a single var()
for each custom property in the stylesheet. But I often like doing so when performing calculations and/or using a helper variable, as we do here. That said, while this is certainly the cleverest technique for adjusting font weights, that doesn’t mean it’s the best technique for all projects. There is at least one serious caveat.
The primary advantage of using the universal selector technique — that it applies to everything — also introduces its chief risk. There may be some elements that we don’t want thinned out! For example, if our form elements retain dark text on light backgrounds in dark mode, they may still get steamrolled by the universal selector.
There are ways to mitigate this risk. We could replace *
with a long selector string containing a list of only elements to thin out (having them opt-in to the calculation). Or we could hard-code font weights for the elements that we don’t want affected (opt-out):
* {
font-weight: calc(var(--font-weight, 400) * var(--font-weight-multiplier, 1));
}
button, input, select, textarea {
font-weight: 400;
}
Such fixes may ultimately make code just as complicated as the previous technique. So, you’ll have to gauge which is appropriate for your project. If you still have concerns over performance, code complexity, or think this technique might introduce undesired (even unpredictable) results, the previous technique might be safest.
The final code:
/* 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));
}
We’re not required to set the default --font-weight: 400
and --font-weight-multiplier: 1
custom properties in the above code, because we’ve included the fallback values in the var()
functions. But as code gets more complicated, I often like assigning them in a logical place, just in case I want to find and alter them later.
A final note on this strategy: we can also apply weights with the font-variation-settings
property and a "wght"
axis value, instead of font-weight
. If you’re using a typeface with several axes, maybe you find it more manageable to do all your font tweaking that way. I know of at least one font (Type Network’s Roboto Flex, which we’ll be using later in this article) that has 13 axes!
Here’s how to apply our solution via a font-variation-settings
property:
* {
--wght: calc(var(--font-weight, 400) * var(--font-weight-multiplier, 1));
font-variation-settings: "wght" var(--wght);
}
letter-spacing
Strategy 1 Addendum: Handling One side effect of lowering our type weight is that, for most non-monspaced fonts, it also narrows the characters.
Here again is what happens when we lighten Source Sans Pro with our multiplier. The top two panels below show Source Sans Pro in light and dark mode by default. And the lower panel shows the lighter version.
With no adjustments, the characters in light mode and dark mode are the same width. But when we lower the font weight, those characters now take up less space. You may not like how this change affects your flow or element sizes (e.g. narrower buttons). And some designers think it’s a good idea to add letter spacing in dark mode, anyway. So, if you want, you can create another custom property to add some space.
Implementing a custom property for letter spacing
Just like we did with our font-weight
multiplier variable, we’re going to create a letter spacing variable with a default value that gets overridden in dark mode. In our default (light mode) :root
, we set our new --letter-spacing
custom property to 0 for now:
:root {
/* ...other custom variables... */
--letter-spacing: 0;
}
Then, in our dark mode query, we raise the value to something greater than 0. I’ve entered it as .02ch
here (which combines pretty well with a --font-weight-multiplier
value of .85). You could even get clever and fine-tune it with some calculations based on your font weights and/or sizes, if you like. But I’ll use this hard-coded value for now:
@media (prefers-color-scheme: dark) {
:root {
/* ...other custom variables... */
--letter-spacing: .02ch;
}
}
Finally, we apply it via our universal selector (with a fallback value of 0):
* {
/* ...other property settings... */
letter-spacing: var(--letter-spacing, 0);
}
Note: Though I use the ch
unit in this example, using em
also works, if you prefer. For Source Sans Pro, a value of .009em
is about equal to .02ch
.
Here’s the full code for a font weight multiplier with letter spacing:
/* 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 with constant-width characters (aka multi-plexed fonts)
In addition to monospaced fonts, there are some other typefaces specifically designed so that their individual characters take up the same amount of horizontal space, regardless of weight. For example, if an “i” occupies five horizontal pixels of space at a weight of 400, and a “w” occupies thirteen pixels at the same weight, they will still occupy five and thirteen pixels, respectively, when their weights are increased to 700.
Arrow Type’s Recursive Sans is one such typeface. The following image shows how Recursive’s characters maintain the same widths in light mode, default dark mode, and dark mode with our font weight multiplier, respectively:
Multi-plexed typefaces, like Recursive, are designed so you won’t need to adjust letter spacing when changing their font weights in dark mode. Your element sizes and page flow will remain intact.
Strategy 2: Adjust a variable font’s grade axis
The grade axis ("GRAD"
) changes a font’s apparent weight without changing its actual font-weight
value or the widths of its characters. When using fonts with this axis, you may not need our font weight multiplier variable at all.
For Type Network’s free Roboto Flex font, a grade of -1 is thinnest, 0 (default) is normal, and 1 is thickest. With this font, I start by assigning its grade axis a value of around -.75 for dark mode.
:root {
--GRAD: 0;
}
@media (prefers-color-scheme: dark) {
:root {
--GRAD: -.75;
}
}
body {
font-variation-settings: "GRAD" var(--GRAD, 0);
}
So, adjusting the grade axis seems like the perfect solution if it’s available to you, right? Well, maybe. There are a few things to keep in mind when considering it.
First, the scale for all fonts doesn’t always go from -1 to 1. Some range from 0 to 1. At least one typeface uses percents, making 100 the default. And other fonts align the grade scale with font weights, so the range may be something like 100-900. If you want to use the grade axis in the latter case, you may have to set all your font weights everywhere to a default of 400, and then use the grade axis for all weight changes. For dark mode, you’ll then want to treat grade essentially like we do in our font weight multiplier solution — applying the multiplier to the "GRAD"
axis in font-variation settings
.
The second caveat is that some typefaces don’t let you grade a font to a value below its default weight. So, grade can’t lighten it at all. Apple’s San Francisco typeface (which can be tested via font-family: system-ui;
on Apple devices) has both of these issues. As of macOS Catalina, San Francisco has a grade axis. It’s scaled to line up with font weights, and its minimum value is 400.
Because we can’t set the grade to a value lower than 400, we can’t lighten fonts from a default of 400 in dark mode. If we want to go lower, we’ll need to lower the weight axis value, instead.
Strategy 3: Adjusting a variable font’s darkmode axis
There’s currently only one typeface with a darkmode ("DRKM"
) axis at the time of this writing: Dalton Maag’s Darkmode.
The darkmode axis is essentially a grade axis without any fine-tuning. Just turn it on (1
) for a thinner appearance in dark mode, and leave it off (0
, the default) for normal display.
:root {
--DRKM: 0;
}
@media (prefers-color-scheme: dark) {
:root {
--DRKM: 1;
}
}
body {
font-variation-settings: "DRKM" var(--DRKM, 0);
}
I like the Darkmode font a lot. But beware that it is a commercial license that’s required for professional use. Dalton Maag offers a trial version that can be used for “academic, speculative, or pitching purposes only.” I’m hoping this typeface is a pilot for more Dalton Maag families to get a darkmode axis, and that other font foundries will then follow suit!
Other factors to consider
We’ve covered a few big strategies for working with variable fonts in a dark mode context. But, as with most things, there are other things to consider that might sway you toward one solution or another.
Dark mode on high-resolution (“retina”) screens
On screens with higher pixel densities (e.g. most modern phones, MacBooks, iMacs, etc.), the thickening effect of dark mode is often less pronounced. Therefore, you may not want to thin the fonts on these screens out as much — if at all!
If you still want to lighten fonts a bit, you can add another media query to make the effect less severe. Depending which solution you’re using, you can raise the --font-weight-multiplier
value closer to 1, raise the --GRAD
value closer to 0, or disable --DRKM
altogether (since it’s either on or off, with no in-between).
If you add this query, remember to place it below the original prefers-color-scheme
query, or it may have no effect. Media queries don’t add CSS specificity, so their order matters!
@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 don’t want to lighten fonts at all on high density screens in dark mode, you can update your original dark mode prefers-color-scheme
query to the following, to omit these screens:
@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. */
}
Mixing fonts with different axes (and mixing variable fonts with non-variable fonts)
If you’re using more than one typeface on your site, you’ll need to consider what effects these adjustments may have on all of them. For example, if you’re using multiple fonts with intersecting axes, you could wind up accidentally combining the effects of multiple strategies (e.g. reducing both grade and weight simultaneously):
If all the fonts on your site are variable and have a grade axis with a matching scale and range (e.g. if they all range from -1 to 1), that’s the solution I’d recommend. However, you’ll have to revisit this if you plan to add other fonts later that don’t meet those criteria. Same goes for the darkmode axis, too, if it becomes more widespread.
If all your fonts are variable, but they don’t all share the same axes (e.g. grade and darkmode), then using only the --font-weight-multiplier
custom property may be your safest bet.
Finally, if you’re mixing variable and non-variable fonts, know that the non-variable fonts will not change appearance with any of these solutions — with some exceptions. For example, if you’re using the font weight multiplier with the font-weight
property, it is possible that some — but maybe not all — of your font weights will change enough to move to the next lower weight name.
Say your site includes a font with three weights: regular (400), semi-bold (600), and bold (700). In dark mode, your bold text may lighten up enough to display as semi-bold. But your regular font will still stay regular (as that’s the lowest weight included on the site). If you want to avoid that inconsistency, you could apply your variable font weights via font-variation-settings
, and not font-weight
, so your non-variable fonts aren’t affected at all. They’ll just always maintain their default weight in dark mode.
In closing
It’s always a happy coincidence when complementary technologies attain common usage near the same time. With the rise in popularity of both dark mode and variable fonts, we have the luxury of using the latter to mitigate one of the challenges of the former. Using CSS custom properties in conjunction with weight, grade, and darkmode axes, we can bring some consistency to the look of our text in both light and dark modes.
You can visit my interactive demo with the fonts and axes from this article.
very helpful. how would you automate inverted parts of a design, like dark color buttons? this would need a way of applying the opposite rules within a context.
Thanks! That’s a really good question. If you want to target only certain elements without running dark mode on your whole page (e.g., some buttons), there are a few options. Here are a couple of them.
Solution 1: If you want to make all elements of a certain type (e.g., all h2 and button elements) inverted:
Solution 2: If you only want to target elements with a specific class:
There are other ways, too. But those might get you started.
Pretty clever. But have you played with different values of
-webkit-font-smoothing
(and-moz-osx-font-smoothing
), to see if they will do the trick, even without custom fonts? I think that would better attack the root of the problem, which is due to how the font anti-aliasing algorithms work with light text on dark backgrounds.Thanks for reading. You’re right that prefixed font-smooth can be useful for light text on dark backgrounds. I don’t include it here as a catchall solution, because it’s macOS-specific and has been removed from the CSS standards track (last I saw): https://caniuse.com/font-smooth. Keeping that in mind, if that solution helps you with the browsers your project targets, you can certainly consider it!