Using maths to balance your heading styles

In HTML, we have six different heading levels, and it can be a real pain to style them all in a way where they’re both identifiable and accessible.

When I say accessible, there are a few considerations. The first is that we cannot rely solely on colour to make headings visually different. This would fail 1.4.1 Use of Color, and would mean people with a colour vision deficiency, or who are “colour blind”, are at a disadvantage.

Also, when using high contrast themes on Windows, or forced-colors modes in the browser, all heading and body text colours are replaced with a single system colour, so any difference between your heading colours disappears entirely. So, we can use colour, but we also need to have some other form of visual styling too.

The second consideration, is that when people try to create a visual difference that doesn’t rely on colour, the two things they often reach for beyond bold text are italics and block capitals. Both of these things are known to cause issues for people who have difficulty with written text, for example, people with dyslexia.

It is recommended to avoid italics and fancy fonts, which are particularly difficult for a reader with dyslexia…

Luz Rello and Ricardo Baeza-Yates, Good Fonts for Dyslexia

For this reason, I always try to avoid italics wherever possible. On this site, the only thing which uses italic styling is the <em> element, which I only ever really apply to emphasise a single word. With this in mind, I’ve been trying to find a method to create a balanced collection of styles for headings without needing to really think about it, and without making things difficult for anybody reading them.

This post outlines the solution I decided on and implemented on this website. I’m not claiming this is a new or original idea, it’s just a mashup of things that people way smarter than me had already figured out! But, what it gave me, is six visually distinct heading levels using a single ratio, and it’s all calculated from the size of the body copy.

Start with a scale

The entire method this hangs off is the use of a modular scale. If you’ve not come across this idea before, it’s one which is definitely worth reading about!

A modular scale is rooted in music theory, and uses a base note, or “fundamental”. A mathematical ratio is then used to find the notes which are harmonious. Because, harmonious notes aren’t random, they’re notes which will alwats work together because of the structure of their soundwaves. It’s a system rooted in physics. And, what’s cool about it, is the same theory works to find harmonious sizing in visual layouts.

To create a scale, each step multiplies or divides the value prior to it by a set ratio. The same scale is then used to size the headings and the spacing to create a visual balance which you never need to eyeball. This is what makes the whole thing feel like a single system, instead of a bunch of seemingly random pixel values.

You can read more about modular scale and how you can use it, in the awesome book Every Layout, by Andy Bell and Heydon Pickering.

The following code is an example of how we’d set up a seven point scale:

body {
  /* --ratio is obviously our ratio */
  --ratio: 1.5;

  /* --s0 is our base, or fundamental */
  --s0: 1.2rem;

  /* Positive numbers are larger */
  --s1: calc(var(--s0) * var(--ratio));
  --s2: calc(var(--s1) * var(--ratio));
  --s3: calc(var(--s2) * var(--ratio));

  /* Negative numbers are smaller */
  --s-1: calc(var(--s0) / var(--ratio));
  --s-2: calc(var(--s-1) / var(--ratio));
  --s-3: calc(var(--s-2) / var(--ratio));

  /* Our body copy is set to the base size */
  font-size: var(--s0);
}

The ratio is the multiplier used to calculate everything else. 1.5 is a good ratio, as it’s the recommended line-height for accessibility. You could also use 1.618 if the Golden Ratio is something you’re into. But really, it doesn’t matter, as long as everything is calculated off the same ratio, it will be balanced visually.

s0 is our base size, or fundamental. This is the default size of our body copy. 1.2rem would compute to around 19px in the browser by default, but importantly, unlike px values, rem will scale with the user’s browser settings. You can read more about the problem with static pixel values in my post accessibility and font sizes.

All of the other points on our scale are calculated using the ratio and s0. Each increase multiplies the step below by the ratio, and each decrease divides the step above it by the ratio.

The hierarchy of visual cues

Ok, how does all of this apply to headings?

A heading level can be perceived a few ways. For example, its:

  • font size
  • font weight
  • font style
  • surrounding whitespace
  • colour

These cues are not all equal though. Some are definitely more obvious than others, so we have to decide how we’re going to use each one.

As I mentioned earlier, I wanted to do this without relying on colour, italics or block capitals, so it didn’t leave a lot to play with. But, I settled on three which turned out to have a pretty natural hierarchy:

  1. Font size: Easily the strongest visual cue, and usually the thing which you notice when scanning the content

  2. Spacing: A heading with a large amount of whitespace around it also creates clear scannable breaks in the content sections

  3. Font weight: It works, but only to a point, and only in large increments, and it’s less obvious in dark-mode due to an optical effect called the Irradiation Illusion

The method I used is pretty simple. I used the strongest cue until I ran out of options. Then, I moved onto the next cue in the list, and overlapped them where needed.

Font size first

The highest ranking cue should be the easiest, because our scale already hands us the sizes. But, it’s not long before we run out of options. For example:

h1 { font-size: var(--s3); }
h2 { font-size: var(--s2); }
h3 { font-size: var(--s1); }
h4 { font-size: var(--s0); }
h5 { font-size: var(--s0); }
h6 { font-size: var(--s0); }

When we get to h4, h5 and h6, we just have to bottom out at s0, because our heading is now the same size as our body copy. And, one rule I’d argue is non-negotiable is that a heading should never be smaller than the copy it introduces. Because, text smaller than a paragraph can easily be perceived as a caption or supporting text, not as a heading.

We’ll revisit the h4 later on, because we will change the size of it. But, I think it will be clearer if the post follows my whole thought process sequentially, rather than trying to explain the modification I needed to make the first time we’re looking at it.

Spacing second

For spacing, again we’ll lean on the work from Andy Bell and Heydon Pickering. The prose flow technique from my favourite 3 lines of CSS and the lobotomised owl selector. Using these means each element’s gap is owned by a single variable, which our headings then override.

Adding spacing above headings

First, we remove all the margins, using .prose > * { margin: 0 }. Then, we apply new spacing, but we only apply it above elements to create a gap, which avoids margins overlapping and collapsing, which would throw the visual balance off. For example:

/* Set the default space to the scale's base size */
.prose {
  --flow-space: var(--s0);
}

/* Remove all margins on all prose content */
.prose > * {
  margin: 0
}

/* Set the default space on all prose content */
.prose > * + * {
  margin-block-start: var(--flow-space);
}

/* Set h1 and h2 to have the biggest gap */
.prose > :is(h1, h2) {
  --flow-space: var(--s3);
}

/* Set h3 and h4 to have a slightly smaller gap */
.prose > :is(h3, h4) {
  --flow-space: var(--s2);
}

/* Set h5 and h6 to have a smaller gap again */
.prose > :is(h5, h6) {
  --flow-space: var(--s1);
}

The gaps between everything are set by flow-space, which is, by default, our base size at s0. So, without ever overriding it, all the elements, including paragraphs and headings, would have the same gap.

We override the variable and increase the space above the headings by targeting them explicitly. We set the space above the h1 and h2 to the biggest gap. The h3 and h4 share the next gap down. And, the h5 and h6 share the next gap down.

It doesn’t matter that there are two pairs of headings sharing a gap, because we will use other cues to differentiate them before we’re finished. What’s important is that all headings now have a larger gap above them than a paragraph.

On this site, the h1 always opens the prose block, so the owl selector never matches it, and it doesn’t ever get a gap above it anyway. But, it’s included in the rule so the hierarchy still holds if something does end up sitting above it.

Adding spacing below headings

Next, we set the gap below the heading, by targeting the element which immediately follows it and applying a top margin. This is to maintain our rule that spacing is only applied above an element, so margins do not overlap and collapse. For example:

/* h2, h3 and h4 slightly reduce the gap below */
.prose > :is(h2, h3, h4) + * {
  --flow-space: var(--s-1);
}

/* h5 reduces the gap below further */
.prose > h5 + * {
  --flow-space: var(--s-2);
}

/* h6 has the smallest gap below */
.prose > h6 + * {
  --flow-space: var(--s-3);
}

We reduce the gaps below the headings, to visually pull the related content closer to them and make it more obvious that there is a relationship there. The h2, h3, and h4 all have the gap below them reduced by the smallest increment. The h5 reduces the gap one step further. And, the h6 has the smallest gap. The h1 is left out on purpose, as it keeps the original gap below it, which is the largest.

Font weight third

As I mentioned earlier, font weight isn’t the best visual indicator. So, the only time we change it is for the h6, which we drop to a medium weight. For example, from 700 to 500:

h6 {
  font-weight: 500;
}

By dropping the weight to 500, we get two things, it:

  • drops the weight enough so that it’s visually different from the h5, even in dark mode
  • keeps the h6 from being visually identical to text in a <strong> element.

There is a caveat to this. You need to use a decent font! Because, if it doesn’t ship with the weights we set in our CSS, the browser will quietly swap it for the nearest available weight it has. Or worse, it will create a fake weight by smearing the strokes, which is likely to look terrible. So, choose a good variable weight font, a Google font, or make sure you manually serve a custom font with all of the required weights covered.

The font weight only has to carry this visual cue on the h6. This is the point of ranking the methods in the first place. The weakest cue does the least work, and is applied to the heading level you’re likely to see least often.

The level 4 sizing issue

I mentioned earlier we’re going to revisit the h4. Because, with the current setup, visually, we’re going from a h3 at around 28px down to a h4 at around 19px, which is a pretty big visual jump. And, for me, there was an obvious gap around the 24px mark which was a wasted opportunity.

This was a sticking point for me, for the longest time, because it needed to work as a system, and I couldn’t get the maths to math! Just changing the scale or the ratio ended up with the h1 being huge, or the scale increments were so small it didn’t increase the size enough to make it obvious.

The question I struggled with was, what number goes between 19px and 28px without throwing everything else out of balance? Because, on a modular scale we can’t just pick a number that looks “sort of ok”. The whole point is that it’s a system, and it needs to work consistently if it’s going to scale.

I’m going to be honest, I’m terrible at maths! So, my C grade at GCSE had to do some serious heavy lifting! My initial thought was that I just needed to find the midpoint. I assumed I could add the two sizes and divide them by two. But, the whole point of a modular scale is that it isn’t additive. Each step is multiplied, not added. So, this didn’t work.

After some trial and error and a bit of research, it turns out that on a scale which is multiplied, the most honest midpoint is the geometric mean. This would be the number which sits the same multiple above s0 and below s1.

Trying to figure this out as an equation melted my brain. I needed to find the m (mean) where m ÷ s0 is equal to s1 ÷ m. Solving that would then give me the m which was equal to s0 multiplied by the square root of r (the ratio).

I know… WTF… right?

The square root calculation is the important bit. Two half steps would just multiply together to become one full step again, because √r × √r = r. The half step needed isn’t the size that happens to fall exactly between two steps, it’s the point halfway between the two steps which still adheres to the scale’s own rules. A correct half step calculation will work using any sequential values in the scale, and still appear perfectly balanced.

Luckily for me, CSS can do square roots directly, using the sqrt() function. So, with all of that complex maths theory out of the way, the actual calculation in the CSS turned out to be pretty simple. We calculate the base, s0, multiplied by the square root of the ratio.

This gives us a true half step for our h4, and fixes the missed sizing opportunity.

h4 {
  font-size: calc(var(--s0) * sqrt(var(--ratio)));
}

So, with our 1.2rem base and our 1.5 ratio, this h4 now computes in the browser to 1.47rem, which is roughly 23.5px. Which is right around the 24px size gap I was aiming for. And now, because the maths is correct, it’s still anchored to the ratio, instead of being hardcoded. So, it always stays correct, even if I change the ratio in future.

The following demo shows the difference with the half step. The first section shows the original setup, where the h4 had bottomed out at the size of the body copy. The second section shows the h4 sitting on the half step.

A before and after comparison of the level 4 heading. Both sections use the same scale, and the only rule which changes is the h4 font size.

As for where the calculation lives, my choice was to just keep this half step as an outlier used only for the h4, rather than make it available as part of the main scale used across the entire site.

Putting it all together

The following CSS is the final output:

/* The scale */
body {
  --ratio: 1.5;
  --s0: 1.2rem;
  --s1: calc(var(--s0) * var(--ratio));
  --s2: calc(var(--s1) * var(--ratio));
  --s3: calc(var(--s2) * var(--ratio));
  --s-1: calc(var(--s0) / var(--ratio));
  --s-2: calc(var(--s-1) / var(--ratio));
  --s-3: calc(var(--s-2) / var(--ratio));

  /* The body copy font size */
  font-size: var(--s0);
}

/* Use the font size first */
h1 { font-size: var(--s3); }
h2 { font-size: var(--s2); }
h3 { font-size: var(--s1); }

/* Calculate the half step for the h4 */
h4 { font-size: calc(var(--s0) * sqrt(var(--ratio))); }

/* Set h5 and h6 to the size of the body copy */
h5, h6 { font-size: var(--s0); }

/* Use spacing second */
.prose > * {
  margin: 0
}

/* Set the default prose spacing above all elements */
.prose {
  --flow-space: var(--s0);
}
.prose > * + * {
  margin-block-start: var(--flow-space);
}

/* Set the spacing above the headings */
.prose > :is(h1, h2) {
  --flow-space: var(--s3);
}
.prose > :is(h3, h4) {
  --flow-space: var(--s2);
}
.prose > :is(h5, h6) {
  --flow-space: var(--s1);
}

/* Set the spacing below the headings */
.prose > :is(h2, h3, h4) + * {
  --flow-space: var(--s-1);
}
.prose > h5 + * {
  --flow-space: var(--s-2);
}
.prose > h6 + * {
  --flow-space: var(--s-3);
}

/* Use the font weight third, only on the h6 */
h6 { font-weight: 500; }

/* Use colour as a secondary indicator (optional) */
h5 { color: color-mix(in oklch, currentColor 75%, transparent); }
h6 { color: color-mix(in oklch, currentColor 65%, transparent); }

With this code, no visual indicator is reliant on colour. The whole hierarchy survives, even in forced-colors mode. Visual cues overlap, so that even when we’ve run out of options, the next cue picks up where the last one left off.

Because nothing relies on colour alone, we’re safe to add some back in as an enhancement. I opted to subdue the h5 slightly, and the h6 slightly more. This is a subtle but hopefully useful addition, for people who can perceive it.

See it in action

The following demo uses the exact CSS from above. The only additional styles included are a few scaffolding styles to make it fit in with my themes. Because, if you’re like me, and you’re doom scrolling this at 2am in dark-mode when you can’t sleep, then I don’t want to be responsible for blowing out your retinas!

A visual example of h1 to h6, styled using the methods outlined in this post.

Final thoughts

I’m not a designer who believes in “pixel perfect design”. I don’t squint at Figma and nudge something left and right using the arrow keys until it looks perfect. I’m a designer that loves systems, components and patterns. I’d far rather focus on the nuanced problems of a particular product or business area, than worry about whether the gap between two blocks of text looks better at 10px or 12px.

By using a scale, this headache goes away. Sure, my designs are technically more restricted, but I can rest easy knowing whatever option I choose, they will always complement one another and remain balanced. If one gap looks too big, I just use the next one down. I don’t need to stress about having total control, because I can just trust the system to do its thing. Well, that is, as long as my GCSE maths holds out!

Anyways. As always, I hope this was useful.

Thanks,
Craig