Skip to main content

Careful scaling of my heading sizes

Post #17 published on by Tobias Fedder

One of my mantras that allowed me to get this site online in 2023 is: browser default styles work, except for line lengths. No, no, stop! I didn’t⁠⸻ Stop!
I didn’t say great. I said they work.
Then, over time, I added some style declarations here and there, overriding defaults to my liking. This time it’s a fluid type scale.

My goals for that type scale are:

  • bigger headings, if there is enough space
  • but smaller headings, if there isn’t
  • while respecting users’ font size preference

The browser styles setting 2em for a <h1> worked, but they weren’t popping on mid‑size to large viewports. At the same time double the base text size was too much on very narrow viewports. Lastly, because I am a weirdo myself, having set the font‐size in my browser to 18px, I want my prose to honor that user choice. I am my #1 reader, after all.

User preferred font size

I’m not saying it’s wrong to use larger text sizes for prose. I went into my browser settings because I like bigger text. I get it. The defaults can be too small. I’d be surprised to learn that lots of people know they could change the font size in their browser settings as well. In their desktop browser settings that is. The mobile browsers that I know don’t let you set a font size.

Furthermore, as I learned when I heard about the proposed meta tag "text-scale", they don’t even bring the font size you set on the OS level. When I change the display size in the settings menu on my Android, browsers will reduce the CSS pixels of the viewport. However if I only change the font size, then the rendering in Chrome stays unaffected. I heard it is the same on iOS and all of its single one browser engines. As the Firefox fanboy I — surprisingly — still am, I was under the impression that at least Firefox would honor my choices. However, taking a closer look revealed that instead it uses the OS font size sometimes as another indicator to further adjust the viewport’s CSS pixels. Different devices I tried had differing results. Adjusting the CSS resolution is better than doing nothing, I think, but still means partaking in the 1rem=16px lie.

The proposed meta tag for text scaling is meant to let webpages opt‐in to getting the OS font size. Josh Tumath, who is involved in this, has a great blogpost about the text scaling tag. Maybe it will help us honor users’ font size preferences even on mobile, in the long run. It was about time I put it in here.

So far, and maybe even with that tag, we can not know whether the font-size is a user’s explicit choice, or a browser default. All this to say: there are plenty of reasons to add styles that change the base font size. It’s just that I’m not comfortable with doing that at the moment on my site. Okay, back to the scale.

My fluid type scale

I’m not going to explain fluid type scales in detail. There are many excellent articles about it. Search for it and you will find people you know and trust explaining it well (searching, in these trying times, yeah, I know). In short, the font size changes, one level to the next, not by an equal absolute amount, but by the same factor. That’s called a scale. So the size (s) results from the base size (b), the factor (f) and the level (l).
s=b·fl

As I said in the beginning, the default of 2em for <h1> felt too small in most cases, yet too big on very narrow viewports. A type scale does not fix that. But by changing the factor based on the context — let’s say a bigger factor on a larger viewport — the scale adjusts fluidly to its context.

The numbers

Before I descend further and further into magic numbers, here is what I started with. The base size is the font size of the prose. Which is what the browser tells me 1rem at the root element is, possibly a user preference.

I try to avoid using <h4> in my posts, that many levels feel excessive. Therefore the step above the prose is for the <h3>s in the text. The following step for the <h2>s. The next step feels too small for my <h1>, because unlike the other headings I’m not limiting it to the prose’s line length. So I use the step after that for the <h1>. Makes five steps in total.

Look at that guy. Talks about type scales, then generates only five steps and ignores one of them. Well, I don’t know how to respond to that.

Speaking of scales, I like the so called Perfect Fifth, because it is, as Stuart Robson put it recently, Bold. That scale has a factor of 1.5. It’s a number that at least I did not pull out of thin air.

Obviously, a factor of 1.5 does not lend itself to a small viewport. It will be the maximum value. Regarding the minimum, I want a visible scale even on the narrowest viewports. But even a factor of 1.2 (Minor Third) is too big. After some playing around I picked 1.1 as the factor. Here we go, picking random numbers.

Next set of questions: At which viewports should minimum and maximum be enforced? So what is the range over which the factor has to grow from 1.1 to 1.5? For now I only think about the viewport’s inline direction.

The Perfect Fifth is indeed bold, so I landed on 150rem. That is huge. Even on default styles (1rem=16px) that’s 2400px. Reading a blog on an inline viewport with that CSS resolution: what are you doing?

The lower end was even harder to pinpoint, in the end I went with 13rem.

The atan2() hack

Noticed that I picked rem not px, yet let you, or your browser, decide how many px 1rem is? How do you get the number of rem of a viewport.

Well if CSS Typed Arithmetic were baseline widely available, I could do calc(100vi / 1rem). Unfortunately, at the time of writing, CSS Typed Arithmetic isn’t even baseline newly available. So instead I used CSS trigonometric functions. First I pretend to solve a trigonometry problem by asking for the inverse tangent of the viewport’s inline size (100vi) and the size of 1rem. Thereby getting a relation of these two without a <length> type. Then I ask for the inverse of that by asking for the tangent of that value. Easy.

Except that it wasn’t reliable without using explicitly defined custom properties for the first argument of the atan2() function. Except that the results weren’t exactly reliable when the second argument wasn’t in px. So I calculated the number of pixels of 100vi first. Except the results were unreliable, if the two arguments differ too much in size. So instead I asked for the inverse of the tangent of 100vi and 1000px, then the tangent of that, then — since the second argument to atan2() is the divisor — multiplied it by 1000, to get roughly the number of pixels of the viewport in the inline direction.

But I wanted the number of rems of the viewport inline size, right? Right. That’s just a small diversion. I use the same hack to get the number of pixels in 1rem.

@property --vpi {
  syntax: "<length>";
  initial-value: 0px;
  inherits: true;
}
@property --rem {
  syntax: "<length>";
  initial-value: 0px;
  inherits: true;
}
:root {
  --rem: 1rem;
  --px-per-rem: calc(tan(atan2(var(--rem),1px)));
  
  --vpi: 100vi;
  --px-of-vpi: calc(1000 * tan(atan2(var(--vpi),1000px)));

  --vpi-in-rem: calc(var(--px-of-vpi)/var(--px-per-rem));
  ⋮
}

Determining the multiplier

Great. Now I can calculate where the current viewport size falls into my range of 13rem to 150rem.

:root {
  ⋮
  --_scale-range-start-rem: 13;
  --_min-factor: 1.1;
  
  --_scale-range-end-rem: 150;
  --_max-factor: 1.5;

  --_scale-progress: clamp(
    0,
    (
      (var(--vpi-in-rem) - var(--_scale-range-start-rem)) /
      (var(--_scale-range-end-rem) - var(--_scale-range-start-rem))
    ),
    1
  );
  ⋮
}

Based on that I can calculate the factor for the current viewport size. For simplicity's sake I tried a linear mapping first. But due to the wide range the factor increased too slowly across mid‐sized viewports. I tried curves of sine and logistic functions, but couldn’t get the result I wanted.

What worked for me across an interval that feels somewhat intuitive (0x1) is a root function. I was surprised, because graphs of root functions are really steep at the start.

:root {
  ⋮
  --root-curve-exponent: 0.3;
  --_scale-factor: calc(
    var(--_min-factor)
    +
    (var(--_max-factor) - var(--_min-factor))
    *
    pow(var(--_scale-progress),var(--root-curve-exponent))
  );
  --scale-up-4: calc(1rem * pow(var(--_scale-factor),4));
  --scale-up-3: calc(1rem * pow(var(--_scale-factor),3));
  --scale-up-2: calc(1rem * pow(var(--_scale-factor),2));
  --scale-up-1: calc(1rem * pow(var(--_scale-factor),1));
}
main h1 {
  font-size: var(--scale-up-4);
}
main h2 {
  font-size: var(--scale-up-2);
}
main h3 {
  font-size: var(--scale-up-1);
}

That’s it for now. Don’t expect that I’ll change the numbers in this post while I continue tinkering with it.

What about accessibility?

One topic I sometimes notice in talks, videos, and blogposts from people at the intersection of being typography‐curious and accessibility‐minded, is the question, if a certain fluid scale is accessible or not. It’s a good question. It’s an important question.
I think my fluid type scale is accessible. Okay, bye.

Does it pass WCAG 2.2 Success Criterion (SC) 1.4.4 Resize Text? Which says: Except for captions and images of text, text can be resized without assistive technology up to 200 percent without loss of content or functionality.

Maths, again

Let’s do some calculations. In SC 1.4.4 there are no exceptions for large headings. So there has to be a way for text, even for the largest heading, to be displayed twice its default size.

That’s easy on mobile because, as mentioned earlier, at least before support for the text-scale <meta> tag, there is the OS display size setting, which doesn’t even get you 200% when switching from the lowest to the highest option. That’s why there is pinch‐to‐zoom; make sure you allow that. Something, something — when users pinch they expect the magnifier effect, therefore it doesn’t fail SC 1.4.10 Reflow.

I only need to worry about the resizing on capable desktop browsers, meaning user‐agents that let me set preferred font sizes and browser zoom. And that’s a good thing, because that means we as web authors don’t need to design around the limitations of mobile browsers.

Speaking of capable browsers, these allow changing the preferred font size, and using browser zoom. The SC does not state, that each on their own needs to accomplish the resizing. So a user, desperate to further enlarge all text, will use both. The highest of the five options in Chrome gives us 24px=1rem. The maximum zoom is 500%, that’s consistent across browsers, I think.

The shape of my scale’s graphs, shooting up right above 13rem, shows that the effect will be the worst in a scenario where using 24px=1rem and 500% lands on a viewport inline size of 13rem, so 312 CSS pixels at 500% zoom. That’s 1560 CSS pixels at 100% zoom. That combined with the default 16px=1rem, leads to my <h1> receiving a computed font-size: 70px, almost. At 312 CSS pixels and 24px=1rem it computes to font-size: 35px. So over the steepest drop off, it fell to half its size in CSS pixels. However, due to browser zoom of 500%, the physical size of the letters on the display will be roughly 2.5 times its default size. Hurray, passed!

Well, except if my math is wrong, wouldn’t be the first time.

By the way if you want to pass SC 1.4.4 without being accessible, then style all your elements with font-size: 8px. It will double its size as soon as you hit 200% browser zoom. Wait, you need to know the difference between normative and non‐normative WCAG documents before I’ll allow you to shout at me.

Future improvements and thoughts

If you like reading blogposts on your ultra‐wide screen, like an 32:9 aspect ratio, and you arrange your windows in rows because of that’s how you roll, say 32:3, then I assume it will look bad. Maybe I should also consider the viewport’s block size, at least to a certain degree.

I’m using a variable font. Maybe I should use a scale for the font weights as well, instead of magic numbers.

In the end, most of this is about line‐lengths, maybe I could use rch instead.

I like the scale, maybe it would stick to it even if it failed SC 1.4.4. This site doesn’t have to conform (yet), and I’m convinced that it’s accessible, as long as the body text scales up and the headings remain larger than the body text.

Can you imagine how much nicer the CSS would look, if CSS custom functions were baseline widely available?

Once I start using this scale for spacing aside from font sizes, I should probably round them to 1px or 0.25px to avoid sub‐pixel layout issues. I’m not going to do that without CSS custom functions.

Although, for sizing aside from font sizes, that probably should not scale linearly with the text, maybe a combination of px and rem is better.

What do you think?