Design Systems Hot Takes

Relearning line height

9 min read

My wife an I were having an argument about line-height. It’s what happens when you live in a design systems house. She was working on updating some font properties and making a case for a larger line-height reflecting on the WCAG guidelines 1.4.12 (Text spacing). The guidelines were confusing so she also found another reference within the United States Web Design System (USWDS) with the following quote:

Longer texts require more line height. Headings and other content elements no longer than a line or two can have a line height between 1 (line-height 1) and 1.35 (line-height 3). Longer texts should have a line height of at least 1.5 (line-height 4).

I was shooketh. I’ve never seen any mention of having the length of the line affect the size of the line-height in the decades I’ve been working on the web. I’ve always seen it based on the font-size. It’s the reason why the popular recommendation has been to use a unitless value.

.some-text {
    /** 150% the font-size */
    line-height: 1.5;
}

So, I immediately set out to disambiguate this and I was shocked at what I found. Read on in the comments. Just kidding.

I found four sources that all mention that the line-height should be affected by the length of a line; also known as the “measure”.

In fact, that last article published by Smashing Magazine has some data about the typography from popular blogs. Here’s a copy of the data:

This got my wheels turning. While I know one of the articles said there’s no perfect formula, could we make something that has a general approximation to have a variable line-height based on both font-size and measure? In the year 2025, let’s see what we can do.

Minimums and maximums

We’ll want to use a clamp() function for a rate of change and we’ll want to know what the minimum and maximum line-height values will be. If we consider that paragraphs are typically the longest lines and the recommendation is supposed to be 1.5, we’ll make that the maximum. Headlines are typically shorter in terms of line length and often have a line-height of between 1.1 and 1.2 so I’ll pick 1.15 for the minimum. We’ll set these as variables because we’ll need them later.

.text {
    --lh-min: 1.15;
    --lh-max: 1.5;
    line-height: clamp(var(--lh-min), var(--lh), var(--lh-max));
}

Where --lh computes our line-height depending on the width of the text container. Based on what we have so far, we’re looking to generate a value between 1.15 and 1.5.

Before we get into measuring the width, I also want to touch upon another rule of thumb. It’s usually a best practice for you to limit the number of characters for any line. This makes larger amounts of text easier to read because your eye doesn’t need to travel as far when scanning for the next line. The range of acceptable characters for this length is anywhere between 40 and 80, so I typically pick 60 characters as an average.

We’ll also want to pick a minimum number of characters. This is meant to denote the least amount of characters where the line-height stops decreasing if the lines are too short. For this, I’d like to determine what the longest common words are and count their characters. I don’t feel like accounting for “antidisestablishmentarianism” is realistic but accounting for “acknowledgements” is certainly reasonable. I couldn’t find a readily available list of these words since what common means is subjective. However, I did find a resource that lists long words of a certain number of syllables. I reviewed the lists and chose that 4 syllable words feel more common than 5 syllable words. The average number of characters from the words listed is roughly 18, so we’ll use that as our minimum.

Measuring measure

Admittedly, it won’t be feasible with CSS alone to determine the length of each line of text, but we can get the largest width of an entire containing element of text. I’d argue that we actually don’t want each individual line to affect the line-height for each line differently. That would make the vertical rhythm of a single paragraph very erratic. Getting the width of a container element is more reasonable and uniform.

At first, I thought a container query would be the right direction. After all, we are trying to query the width of the text. However, there’s two problems with this. First, we’d need to query for every change that happens to the container, that would be like making a container query for every pixel and that isn’t realistic. Second, you can’t affect the container within the container query. This means we’d need some wrapping element as the container first, and then we could affect the text component in some way. However, that isn’t helpful since we really want to know the text component size. So how might we do this?

Time for a hack

I found a post at Frontend Masters called “How to Get the Width/Height of Any Element in Only CSS” which is precisely what we need to do. It uses a uninituitive trick with scroll-driven animations to achieve the effect. I highly recommend reading through the post if you want to learn some serious CSS magic. The tl;dr as I understand it is that as you resize an element, the amount allowed to scroll changes. That change will affect the scroll timeline and we use the amount if affects the timeline to determine the width. I could be wrong on that, I was just excited to have a working concept.

For now, we want to grab the baseline of the effect with a few modifications. Let’s start with the @-rules

@property --_x {
  syntax: "<number>";
  inherits: true;
  initial-value: 0; 
}

@property --ch {
  syntax: "<integer>";
  inherits: true;
  initial-value: 0; 
}

@keyframes x { to { --_x: 1} }

This is mostly identical to what is in the post, however I’ve changed the --w variable to --ch. This will represent the number of characters or the width of the containing element in terms of ch. Now for the declaration block:

.text {
  --lh-min: 1.15;
  --lh-max: 1.5;
  --ch: calc(1/(1 - var(--_x)));
  display: inline-block;
  overflow: auto;
  timeline-scope: --cx;
  animation: x linear;
  animation-timeline: --cx;
  animation-range: entry 100% exit 100%;
  
  line-height: clamp(
    var(--lh-min),
    var(--lh),
    var(--lh-max)
  );
  
  &::before {
    content: '';
    width: 1ch;
    display: block;
    view-timeline: --cx inline;
  }
}

A few things are changed here, other than removing references to the y-axis. First, I’ve removed the position properties because that can create new stacking contexts and it doesn’t seem to change the final outcome. I’ve also set the .text to be inline-block. This is because block elements will just take up the width of their container. We want the amount of text to determine the size of the container. I’ve also updated ::before to have width: 1ch which allows us to measure in terms of characters instead of pixels.

Now we’re ready for the final part, math!

Equation of a line

Like my previous post that explored some advanced typographic tricks, we’re going to need to determine the rate of change here. We’re working linearly so we’ll need the same equations as before. First, the slope of the line.

m=(y2y1)/(x2x1)m=(y_2-y_1)/(x_2-x_1)

We can represent this in CSS like this:

.text {
    --lh-min: 1.15;
    --lh-max: 1.5;
    --ch-min: 18;
    --ch-max: 60;
    --lh-delta: calc(var(--lh-max) - var(--lh-min));
    --ch-delta: calc(var(--ch-max) - var(--ch-min));
    --slope: calc(var(--ch-delta) / var(--lh-delta));
}

Where --slope is the result. Next, we need to solve for the “b” part of equation. This is what I’ll call the --offset.

b=y1(m×x1)b=y_1 - (m \times x_1)

That is represented in CSS like this:

.text {
    --lh-min: 1.15;
    --lh-max: 1.5;
    --ch-min: 18;
    --ch-max: 60;
    --lh-delta: calc(var(--lh-max) - var(--lh-min));
    --ch-delta: calc(var(--ch-max) - var(--ch-min));
    --slope: calc(var(--ch-delta) / var(--lh-delta));
    --offset: calc(var(--ch-min) - (var(--slope) * var(--lh-min)));
}

And finally, we’ll use our restructured line equation to determine what the line-height value should be based on the --ch value.

x=(yb)/mx=(y-b)/m

.text {
    --lh-min: 1.15;
    --lh-max: 1.5;
    --ch-min: 18;
    --ch-max: 60;
    --lh-delta: calc(var(--lh-max) - var(--lh-min));
    --ch-delta: calc(var(--ch-max) - var(--ch-min));
    --slope: calc(var(--ch-delta) / var(--lh-delta));
    --offset: calc(var(--ch-min) - (var(--slope) * var(--lh-min)));
    --lh: calc((var(--ch) - var(--offset)) / var(--slope));
}

Putting these variables together with the earlier CSS gives you the demo below.

I’ve included a character counter at the end of the element so you can see the result of --ch. I’ve also made the element contenteditable so you can change the text right in the browser window. You should see the line height change with a ch value clamped between 18 and 60.

Avoiding tokens Mixinimation