Jacob
does
code
Apps
Pocket JamPiano TabsTechniCalcCalipersFreebies
Developement
BlogGithub

Font Scaling in CSS

For quite some time, there’s been two kinds of units in CSS: those that don’t scale — px and the like — and those that do — rem.

When we talk about scaling here, we’re talking about a browser setting that allows users to change the default font size of web pages. When the user does this, 1px will remain a single pixel, but 1rem will change from the default of 16px to whatever they set their font size to. If the setting was set to 32px, 1rem would be exactly that.

Now when we say pixels can’t scale, it’s not entirely true. In addition to being able to adjust the font size, browsers also implement a zoom feature, that scales everything. If you zoom in to 200%, 1px will become 2px, and 1rem will become twice the users font size setting.

These features might sound very similar, but they work together really well: aside from using one or the other, a user who suffers from motion disorders but no issues reading could increase the browser zoom, and then reduce their font size to get it back to a normal level. This would make buttons bigger, but keep text unchanged.

These features also aren’t particularly unique to browsers — nor are they particularly new. Even as far back as Windows 95, you were able to change the font sizes across the UI. The browser zoom was be achieved by changing the screen resolution.

On iOS, it’s very common for users to increase their font size — and it’s usually very well supported throughout the system and apps. In fact, PSPDFKit found that more than a quarter of users change the setting.

On the web, however, it’s much less common. I see this as disappointing, because it really comes down to how badly it’s supported on websites. The truth is many websites just don’t support scaling text at all.

On the flip side, some frameworks and websites try to scale correctly, but get it wrong. One of many examples is the default Tailwind setup, where everything uses rems, so everything scales with the font size setting — and in doing so, they make the font size setting perfectly replicate the browser zoom functionality. That is not what the user wanted.

Ok, So What’s the Right Approach?

Simply put, only user rems for font-size related properties: that’s line-height, and letter-spacing. Use pixels for margins, paddings, or borders, or any other value.

This behaviour matches the behaviour of native apps in almost every modern operating system.

There are ways to make rems a bit easier to use. In React Native, you set all properties in their pixel values — including font-related ones. Now if the user changes their font size, the font-related properties (and only the font-related properties) are scaled accordingly.

We can do the same thing in the web if make use of CSS custom properties.

* {
  font-size: calc(var(--font-size) * var(--font-scaling-factor));
  line-height: calc(var(--line-height) * var(--font-scaling-factor));
  letter-spacing: calc(var(--letter-spacing) * var(--font-scaling-factor));
  /*
   * The default font size is 16px, so 1rem / 16 gives a pixel scaled by their
   * font setting. I.e. if their font size was unchanged, you'd get 1px. But if
   * their font size was 32px - twice the default - you'd get 2px.
   */
  --font-scaling-factor: calc(1rem / 16);
}

Now when you want to change the (for example) font size, set the custom property to the px value, and omit the px unit. All other properties work as normal.

h1 {
  --font-size: 24;
  --line-height: 32;
  --letter-spacing: -0.5;

  padding: 12px;
}

In reality, there are some cases where font scaling will break the design, and you need a way to opt out. Maybe your site didn’t support font scaling before, and you can’t add it all in one go. Or maybe it’s not feasible to get a design that works when the font is scaled.

In either case, we can add a property --allow-font-scaling, which we’ll set to zero to disable font sizing.

:root {
  /*
   * Set this to 0 to disable font-sizing
   */
  --allow-font-scaling: 1;
}

* {
  font-size: calc(var(--font-size) * var(--font-scaling-factor));
  line-height: calc(var(--line-height) * var(--font-scaling-factor));
  letter-spacing: calc(var(--letter-spacing) * var(--font-scaling-factor));

  /*
   * We use the `--allow-font-scaling` property to create a scale between the
   * scaled pixel from before and an unscaled pixel (literally 1px).
   *
   * When `--allow-font-scaling` is set to 1, we only include the scaled pixel.
   * When it's set to is set to 0, we only include the unscaled pixel.
   */
  --font-scaling-factor: calc(
    (var(--allow-font-scaling) * 1rem / 16) /* Font scaling enabled */ +
      ((1 - var(--allow-font-scaling)) * 1px) /* Font scaling disabled */
  );
}

/*
 * Since CSS custom properties cascade, this doesn't need setting on _every_
 * element you need to disable font scaling on. It only needs setting on a
 * parent element.
 */
.disable-font-scaling {
  --allow-font-scaling: 0;
}

This should still rarely be used, and only suitable for cases where the breaking of the layout is worse than the user being unable to read the text.

Design Considerations

It’s tempting for a button to set a line-height, and add some vertical padding to achieve the desired height. This approach has a tendency to make buttons get way bigger than they need to when the font size is increased.

Instead, reduce the amount of padding you apply to the absolute minimum, and use min-height to achieve the desired height. This way, when the font size increases a little bit, you can keep your button the same height — and will only in height when it absolutely needs to grow.

.iffy {
  --font-size: 16;
  --line-height: 20;
  padding: 12px 24px;
  /* Height: 12px + 20px + 12px = 44px */
}

.improved {
  --font-size: 16;
  --line-height: 20;
  padding: 4px 24px;
  min-height: 44px;
}

Below is a series of buttons using the .iffy method with their font sizes ranging from the normal size to three times larger. Notice the button grows in size at every step. You can scroll the row of buttons if you’re viewing this on mobile.

ButtonButtonButtonButtonButton

Now we’ll switch to the .improved method — notice the button only grows towards the end, and when it does, it is still smaller than before.

ButtonButtonButtonButtonButton

This technique works more broadly than buttons: it’s great anywhere where you’re applying padding around some text — like text inputs or drop downs. It’s how iOS sizes the rows in its table view component.

Becoming Responsive

On the web, the most common approach to scaling font sizes across multiple screen sizes is to use media queries and make the font decrease in size as the the screen width decreases.

However, when looking at OSs across different form factors, font sizes don’t change all that much. For example, the font size on an iPhone is identical to the font size on an iPad — and the font size on a MacBook is smaller than both.

More recently, there’s been a more extreme approach to responsive design, called fluid typography, where fonts are sized to some percentage of the width of the screen. This really is an engineer’s solution to a design problem. It also can’t really be called responsive, because it’s not responding to anything: it’s the same design on every screen.

I find a a better approach is to change font sizes as little as possible. Your designs may already have font sizes that are suitable for mobile, in which case you don’t need to do anything. If just your headers are too large on mobile, you can reduce just their size, and leave the body text unchanged.

The End

If you have any comments or suggestions, you can find me on twitter @jacobp100.

Published on