Adjust font size with CSS custom properties

As I was setting up a custom font on a side project, I realised that it was making the text much smaller than the default sans-serif used as a fallback.

Screeshot of the project using the fallback font. Screenshot of the project using the custom font.
With the fallback font (first image), the app has large enough text.
The custom font (second image)makes it way too small.

Having different fonts render text with different height for a given font-size is part of how things work. Vincent De Oliveira wrote a comprehensive explanation of what happens behind the scene. If it was just a small shift, I could have lived with it. But the custom font (the lovely Bariol by Atipo) was making things too small to be comfortable, so let's see what can be done!

Adjusting font-size with native CSS

To help handling the issue, the CSS Fonts Module Level 3 introduces font-size-adjust. The property defines how to scale the value of font-size when rendering the text.

Unlike changing font-size, using font-size-adjust will not mess with lengths expressed in em, nor with unitless line-height values. This means bringing the font you load to the same(ish) size as its fallback won't scale the icons you took care to size relative to the font size. It will affect lengths in ex and ch though (as they're based on the font metrics of the applied font).

It also makes the code more expressive. The property describes "this font renders too small/large so I'll scale by X". It makes things much clearer than "I need a new font size of Y" without much more explanation (than a comment that'll likely be not written).

Only hiccup, support is widespread there yet. Firefox has been supporting it for a while, but Blink browsers hold it behind the "Experimental web platform features" flag and Webkit has no one assigned to the issue.

In the meantime, we can use CSS custom properties to get something close.

Loading fonts with JavaScript

The aim is to make the loaded font match the fallback font's height. So until that final font is loaded, we don't want to be scaling anything (either through font-size-adjust or custom properties). Before we get to the CSS, let's take care of that by loading the fonts using JavaScript and set a class on the <html> element when they're done.

This provides a more robust alternative to using @font-face in CSS and the document.fonts.onloadingdone event. Browsers that wouldn't support the document.fonts API (or that JavaScript would have failed to reach) won't end up with the tiny font loaded and no adjustment.

Browsers offer a native API for loading fonts, letting you create FontFace objects to define fonts similarly to how you would with @font-face in CSS. You control when you start loading them and finally add them to documents.fonts so they can be used for rendering. This allows to add extra conditions to when the font loads, for example avoiding to load them over a poor network connection.

if (supportsFontLoading() && shouldLoadFonts()) {
  loadFonts();
}

function supportsFontLoading() {
  return 'fonts' in document;
}

function shouldLoadFonts() {
  return !(
    // In the abscense of navigator.connection
    // we can't say if the connection is good or not
    // so best treat it as if it's not
    navigator.connection &&
    (navigator.connection.saveData || 
      navigator.connection.effectiveType == 'slow-2g' ||
      navigator.connection.effectiveType == '2g')
  );
}

function loadFonts() {
  var font = new FontFace(
    // Using a custom name will avoid showing unscaled text
    // to users that would have the font installed on their machines
    'Remote-Bariol',
    // But we can still look up for installed fonts using the `local` function
    "local(Bariol), url(fonts/bariol_regular-webfont.woff2) format('woff2'), url(fonts/bariol_regular-webfont.woff) format('woff')"
  );

  // FontFace.load returns a promise that resolves
  // once the font is loaded, which we can use to
  // - add the font to our document
  // - and set the class that'll trigger the font-size adjustment
  font.load().then(function(font) {
    document.fonts.add(font);
    document.documentElement.classList.add('font-loaded--bariol');
  });
}

Adjusting the font-size with custom properties

Now we know when the font is loaded, we can tackle the CSS. Sorry for the wait!

Configuring the scaling

With a custom --font-size-scale property, we can express a similar concept to font-size-adjust. Once the font is loaded rules setting the font-family property to the "problematic" font will adjust its value as needed.

:root {
  /* 
    Scaling will happen through multiplication (spoiler!)
    so we'll initialise it to 1
  */
  --font-size-scale: 1;

  /* 
    As the scaling factor for specific fonts
    will likely be applied in more than one place,
    it's worth storing them in their own properties
  */
  --font-size-scale--bariol: 1.25;

  /* 
    And we can create as many properties
    to store scaling factors as there are
    fonts needing adjustments
    --font-size-scale--another-font: 0.75; 
  */
}

.font-family--bariol,
h1,
.h1 {
  /* 
    Use the custom name to avoid picking up installed fonts
    without detecting their loading
  */
  font-family: Remote-Bariol, sans-serif;
}

.font-loaded--bariol .font-family--bariol,
.font-loaded--bariol h1,
.font-loaded--bariol .h1 {
  /*
    Apply a different scale for the font
    once loaded
  */
  --font-size-scale: var(--font-size-scale--bariol, 1);
}

But we also need to take care of rules setting a font-family that does not need scaling. Child elements will inherit the custom property value from their parents. If font stacks that do not need scaling don't reset the factor to 1, they'll get blown up or shrunk when they didn't need to.

.font-family--does-not-need-scaling {
  font-family: serif;

  --font-size-scale: 1;
}

Applying the scaling

Now each font-family is accompanied by its corresponding --font-size-scale, we can start using the property to adjust the font size. First step is replacing all the font-size properties set in rem with the scaling computation.

We'll also want to scale the font-size of the <body> element too. Not the <html> one as it'd affect the value of all rem lengths, possibly used for spacing and what not.

Technically, we'd also want to apply the same computation to absolute units, like px. But rems should be preferred to allow users to set the font-size they need via user stylesheets, so we'll leave them out. This'll have the nice side effect of revealling poor choices of unit.

There's also ems and the other units relative to the "local" font size (ex,ch), but we'll talk about them right after.

body {
  font-size: calc(1rem * var(--font-size-scale, 1));
}

h1 {
  font-size: calc(2rem * var(--font-size-scale, 1));
}

Reverting the scaling

Avoiding to touch the font-size of the <html> element, safeguarded any length expressed in rem. Tweaking the font-size of elements will change any length set in em, ch, ex, though, as well as relative or unitless line-height. To match font-size-adjust, properties using em and unitless line-height will need to run the inverse computation to be scaled back to their proper value.

h1 {
  font-size: calc(2rem * var(--font-size-scale, 1));
  line-height: calc(1.5 / var(--font-size-scale, 1));
  padding: calc(1.2em / var(--font-size-scale, 1));
}

When it's font-size that's expressed in em, things are a little different:

Tooling up

Handling all this manually is doable, but cumbersome and error prone. SASS can aleviate a bit of the burden in two ways.

Tracking which selectors need to get the --font-size-scale property

There'll likely be more than one selector setting the problematic font-family. Placeholder classes and @extend can help spread the application of font-family and the --font-size-scale properties once the font is loaded.

// The placeholder class will "collect" all the selectors
// applying the font-family
%font-family--bariol {
  font-family: Remote-Bariol, sans-serif;
}

// Instead of applying the `font-family` property
// rules use `@extend`
.font-family--bariol {
  @extend %font-family--bariol;
}

h1,
.h1 {
  @extend %font-family--bariol;
}

.font-loaded--bariol {
  // This'll output all the selectors "collected" by the placeholder class.
  // Even those written after. Here it'll generate:
  //   .font-loaded--bariol .font-family--bariol,
  //   .font-loaded--bariol h1,
  //   .font-loaded--bariol .h1
  %font-family--bariol {
    --font-size-scale: $font-size-scale--bariol;
  }
}

Helping write lenghts that get scaled

A function like the following one could also help with applying the scaling (use with caution, it's very lightly tested):

@function font-size-scalable($value, $scale-back-factor: null) {
    // For scaling the `rem`
    @if (unit($value) == rem) {
        @return #{"calc(#{$value} * var(--font-size-scale, 1))"};
    }
    // For scaling back `em` for `font-size`
    @if ($scale-back-factor) {
        @return #{"calc(#{$value} / #{$scale-back-factor} * var(--font-size-scale, 1))"};
    }
    // For other properties in `em` or unitless
    @if (unit($value) == em or unit($value) == "") {
        @return #{"calc(#{$value} / var(--font-size-scale, 1))"};
    }
    @return $value;
}

body {
  font-size: font-size-scalable(1rem);
}

h1 {
  font-size: font-size-scalable(2rem);
  line-height: font-size-scalable(1.5);
  padding: font-size-scalable(0.75em);
}

.font-size-in-em-needing-adjustment {
    font-size: font-size-scalable(1em, var(--font-size-scale--font-of-parent,1));
}

There's still room for forgetting to use the placeholder or the function, but that's already less calc formulas to write manually, and no list of selector to keep track of.

Ultimately, a PostCSS plugin would be ideal to reduce the burden even more:

That's its own bulk of work, though... maybe one day ;) In the meantime, it's SASS or manually writing the right properties. Oh, and there's always the option to tweak the font files too. But that means having them at hand and their license allowing such tampering, which is not always the case.