A place to cache linked articles (think custom and personal wayback machine)
Você não pode selecionar mais de 25 tópicos Os tópicos devem começar com uma letra ou um número, podem incluir traços ('-') e podem ter até 35 caracteres.

index.md 25KB

title: Deep dive CSS: font metrics, line-height and vertical-align url: https://iamvdo.me/en/blog/css-font-metrics-line-height-and-vertical-align hash_url: 157e744e80 archive_date: 2024-04-18 og_image: http://iamvdo.me/images/apple-touch-152.png description: An introduction to the inline formatting context. Explores line-height and vertical-align properties, as well as the font metrics. Understand how text is rendered on screen, and how to control it with CSS. favicon: https://iamvdo.me/images/favicon.png language: en_US

Line-height and vertical-align are simple CSS properties. So simple that most of us are convinced to fully understand how they work and how to use them. But it’s not. They really are complex, maybe the hardest ones, as they have a major role in the creation of one of the less-known feature of CSS: inline formatting context.

For example, line-height can be set as a length or a unitless value , but the default is normal. OK, but what normal is? We often read that it is (or should be) 1, or maybe 1.2, even the CSS spec is unclear on that point. We know that unitless line-height is font-size relative, but the problem is that font-size: 100px behaves differently across font-families, so is line-height always the same or different? Is it really between 1 and 1.2? And vertical-align, what are its implications regarding line-height?

Deep dive into a not-so-simple CSS mechanism…

Let’s talk about font-size first

Look at this simple HTML code, a <p> tag containing 3 <span>, each with a different font-family:

<p>
    <span class="a">Ba</span>
    <span class="b">Ba</span>
    <span class="c">Ba</span>
</p>
p  { font-size: 100px }
.a { font-family: Helvetica }
.b { font-family: Gruppo    }
.c { font-family: Catamaran }

Using the same font-size with different font-families produce elements with various heights:

Different font-families, same font-size, give various heights

Even if we’re aware of that behavior, why font-size: 100px does not create elements with 100px height? I’ve measured and found these values: Helvetica: 115px, Gruppo: 97px and Catamaran: 164px

Elements with font-size: 100px have height that varies from 97px to 164px

Although it seems a bit weird at first, it’s totally expected. The reason lays down inside the font itself. Here is how it works:

  • a font defines its em-square (or UPM, units per em), a kind of container where each character will be drawn. This square uses relative units and is generally set at 1000 units. But it can also be 1024, 2048 or anything else.
  • based on its relative units, metrics of the fonts are set (ascender, descender, capital height, x-height, etc.). Note that some values can bleed outside of the em-square.
  • in the browser, relative units are scaled to fit the desired font-size.

Let’s take the Catamaran font and open it in FontForge to get metrics:

  • the em-square is 1000
  • the ascender is 1100 and the descender is 540. After running some tests, it seems that browsers use the HHead Ascent/Descent values on Mac OS, and Win Ascent/Descent values on Windows (these values may differ!). We also note that Capital Height is 680 and X height is 485.
Font metrics values using FontForge

That means the Catamaran font uses 1100 + 540 units in a 1000 units em-square, which gives a height of 164px when setting font-size: 100px. This computed height defines the content-area of an element and I will refer to this terminology for the rest of the article. You can think of the content-area as the area where the background property applies .

We can also predict that capital letters are 68px high (680 units) and lower case letters (x-height) are 49px high (485 units). As a result, 1ex = 49px and 1em = 100px, not 164px (thankfully, em is based on font-size, not computed height)

Catamaran font: UPM —Units Per Em— and pixels equivalent using font-size: 100px

Before going deeper, a short note on what this it involves. When a <p> element is rendered on screen, it can be composed of many lines, according to its width. Each line is made up of one or many inline elements (HTML tags or anonymous inline elements for text content) and is called a line-box. The height of a line-box is based on its children’s height. The browser therefore computes the height for each inline elements, and thus the height of the line-box (from its child’s highest point to its child’s lowest point). As a result, a line-box is always tall enough to contain all its children (by default).

Each HTML element is actually a stack of line-boxes. If you know the height of each line-box, you know the height of an element.

If we update the previous HTML code like this:

<p>
    Good design will be better.
    <span class="a">Ba</span>
    <span class="b">Ba</span>
    <span class="c">Ba</span>
    We get to make a consequence.
</p>

It will generate 3 line-boxes:

  • the first and last one each contain a single anonymous inline element (text content)
  • the second one contains two anonymous inline elements, and the 3 <span>
A <p> (black border) is made of line-boxes (white borders) that contain inline elements (solid borders) and anonymous inline elements (dashed borders)

We clearly see that the second line-box is taller than the others, due to the computed content-area of its children, and more specifically, the one using the Catamaran font.

The difficult part in the line-box creation is that we can’t really see, nor control it with CSS. Even applying a background to ::first-line does not give us any visual clue on the first line-box’s height.

line-height: to the problems and beyond

Until now, I introduced two notions: content-area and line-box. If you’ve read it well, I told that a line-box’s height is computed according to its children’s height, I didn’t say its children content-area’s height. And that makes a big difference.

Even though it may sound strange, an inline element has two different height: the content-area height and the virtual-area height (I invented the term virtual-area as the height is invisible to us, but you won’t find any occurrence in the spec).

  • the content-area height is defined by the font metrics (as seen before)
  • the virtual-area height is the line-height, and it is the height used to compute the line-box’s height
Inline elements have two different height

That being said, it breaks down the popular belief that line-height is the distance between baselines. In CSS, it is not .

In CSS, the line-height is not the distance between baselines

The computed difference of height between the virtual-area and the content-area is called the leading. Half this leading is added on top of the content-area, the other half is added on the bottom. The content-area is therefore always on the middle of the virtual-area.

Based on its computed value, the line-height (virtual-area) can be equal, taller or smaller than the content-area. In case of a smaller virtual-area, leading is negative and a line-box is visually smaller than its children.

There are also other kind of inline elements:

  • replaced inline elements (<img>, <input>, <svg>, etc.)
  • inline-block and all inline-* elements
  • inline elements that participate in a specific formatting context (eg. in a flexbox element, all flex items are blocksified)

For these specific inline elements, height is computed based on their height, margin and border properties. If height is auto, then line-height is used and the content-area is strictly equal to the line-height.

Inline replaced elements, inline-block/inline-* and blocksified inline elements have a content-area equal to their height, or line-height

Anyway, the problem we’re still facing is how much the line-height’s normal value is? And the answer, as for the computation of the content-area’s height, is to be found inside the font metrics.

So let’s go back to FontForge. The Catamaran’s em-square is 1000, but we’re seeing many ascender/descender values:

  • generals Ascent/Descent: ascender is 770 and descender is 230. Used for character drawings. (table “OS/2”)
  • metrics Ascent/Descent: ascender is 1100 and descender is 540. Used for content-area’s height. (table “hhea” and table “OS/2”)
  • metric Line Gap. Used for line-height: normal, by adding this value to Ascent/Descent metrics. (table “hhea”)

In our case, the Catamaran font defines a 0 unit line gap, so line-height: normal will be equal to the content-area, which is 1640 units, or 1.64.

As a comparison, the Arial font describes an em-square of 2048 units, an ascender of 1854, a descender of 434 and a line gap of 67. It means that font-size: 100px gives a content-area of 112px (1117 units) and a line-height: normal of 115px (1150 units or 1.15). All these metrics are font-specific, and set by the font designer.

It becomes obvious that setting line-height: 1 is a bad practice. I remind you that unitless values are font-size relative, not content-area relative, and dealing with a virtual-area smaller than the content-area is the origin of many of our problems.

Using line-height: 1 can create a line-box smaller than the content-area

But not only line-height: 1. For what it’s worth, on the 1117 fonts installed on my computer (yes, I installed all fonts from Google Web Fonts), 1059 fonts, around 95%, have a computed line-height greater than 1. Their computed line-height goes from 0.618 to 3.378. You’ve read it well, 3.378!

Small details on line-box computation:

  • for inline elements, padding and border increases the background area, but not the content-area’s height (nor the line-box’s height). The content-area is therefore not always what you see on screen. margin-top and margin-bottom have no effect.
  • for replaced inline elements, inline-block and blocksified inline elements: padding, margin and border increases the height, so the content-area and line-box’s height

vertical-align: one property to rule them all

I didn’t mention the vertical-align property yet, even though it is an essential factor to compute a line-box’s height. We can even say that vertical-align may have the leading role in inline formatting context.

The default value is baseline. Do you remind font metrics ascender and descender? These values determine where the baseline stands, and so the ratio. As the ratio between ascenders and descenders is rarely 50/50, it may produce unexpected results, for example with siblings elements.

Start with that code:

<p>
    <span>Ba</span>
    <span>Ba</span>
</p>
p {
    font-family: Catamaran;
    font-size: 100px;
    line-height: 200px;
}

A <p> tag with 2 siblings <span> inheriting font-family, font-size and fixed line-height. Baselines will match and the line-box’s height is equal to their line-height.

Same font values, same baselines, everything seems OK

What if the second element has a smaller font-size?

span:last-child {
    font-size: 50px;
}

As strange as it sounds, default baseline alignment may result in a higher (!) line-box, as seen in the image below. I remind you that a line-box’s height is computed from its child’s highest point to its child’s lowest point.

A smaller child element may result in a higher line-box's height

That could be an argument in favor of using line-height unitless values, but sometimes you need fixed ones to create a perfect vertical rhythm. To be honest, no matter what you choose, you’ll always have trouble with inline alignments.

Look at this another example. A <p> tag with line-height: 200px, containing a single <span> inheriting line-height

<p>
    <span>Ba</span>
</p>
p {
    line-height: 200px;
}
span {
    font-family: Catamaran;
    font-size: 100px;
}

How high is the line-box? We should expect 200px, but it’s not what we get. The problem here is that the <p> has its own, different font-family (default to serif). Baselines between the <p> tag and the <span> are likely to be different, the height of the line-box is therefore higher than expected. This happens because browsers do their computation as if each line-box starts with a zero-width character, that the spec called a strut.

An invisible character, but a visible impact.

To resume, we’re facing the same previous problem as for siblings elements.

Each child is aligned as if its line-box starts with an invisible zero-width character

Baseline alignment is screwed, but what about vertical-align: middle to the rescue? As you can read in the spec, middle “aligns the vertical midpoint of the box with the baseline of the parent box plus half the x-height of the parent”. Baselines ratio are different, as well as x-height ratio, so middle alignment isn’t reliable either. Worst, in most scenarios, middle is never really “at the middle”. Too many factors are involved and cannot be set via CSS (x-height, ascender/descender ratio, etc.)

As a side note, there are 4 other values, that may be useful in some cases:

  • vertical-align: top / bottom align to the top or the bottom of the line-box
  • vertical-align: text-top / text-bottom align to the top or the bottom of the content-area
Vertical-align: top, bottom, text-top and text-bottom

Be careful though, in all cases, it aligns the virtual-area, so the invisible height. Look at this simple example using vertical-align: top. Invisible line-height may produce odd, but unsurprising, results.

vertical-align may produce odd result at first, but expected when visualizing line-height

Finally, vertical-align also accepts numerical values which raise or lower the box regarding to the baseline. That last option could come in handy.

CSS is awesome

We’ve talked about how line-height and vertical-align work together, but now the question is: are font metrics controllable with CSS? Short answer: no. Even if I really hope so. Anyway, I think we have to play a bit. Font metrics are constant, so we should be able to do something.

What if, for example, we want a text using the Catamaran font, where the capital height is exactly 100px high? Seems doable: let’s do some maths.

First we set all font metrics as CSS custom properties , then compute font-size to get a capital height of 100px.

p {
    /* font metrics */
    --font: Catamaran;
    --fm-capitalHeight: 0.68;
    --fm-descender: 0.54;
    --fm-ascender: 1.1;
    --fm-linegap: 0;

    /* desired font-size for capital height */
    --capital-height: 100;

    /* apply font-family */
    font-family: var(--font);

    /* compute font-size to get capital height equal desired font-size */
    --computedFontSize: (var(--capital-height) / var(--fm-capitalHeight));
    font-size: calc(var(--computedFontSize) * 1px);
}
The capital height is now 100px high

Pretty straightforward, isn’t it? But what if we want the text to be visually at the middle, so that the remaining space is equally distributed on top and bottom of the “B” letter? To achieve that, we have to compute vertical-align based on ascender/descender ratio.

First, compute line-height: normal and content-area’s height:

p {
    …
    --lineheightNormal: (var(--fm-ascender) + var(--fm-descender) + var(--fm-linegap));
    --contentArea: (var(--lineheightNormal) * var(--computedFontSize));
}

Then, we need:

  • the distance from the bottom of the capital letter to the bottom edge
  • the distance from the top of the capital letter to the top edge

Like so:

p {
    …
    --distanceBottom: (var(--fm-descender));
    --distanceTop: (var(--fm-ascender) - var(--fm-capitalHeight));
}

We can now compute vertical-align, which is the difference between the distances multiplied by the computed font-size. (we must apply this value to an inline child element)

p {
    …
    --valign: ((var(--distanceBottom) - var(--distanceTop)) * var(--computedFontSize));
}
span {
    vertical-align: calc(var(--valign) * -1px);
}

At the end, we set the desired line-height and compute it while maintaining a vertical alignment:

p {
    …
    /* desired line-height */
    --line-height: 3;
    line-height: calc(((var(--line-height) * var(--capital-height)) - var(--valign)) * 1px);
}
Results with different line-height. The text is always on the middle

Adding an icon whose height is matching the letter “B” is now easy:

span::before {
    content: '';
    display: inline-block;
    width: calc(1px * var(--capital-height));
    height: calc(1px * var(--capital-height));
    margin-right: 10px;
    background: url('https://cdn.pbrd.co/images/yBAKn5bbv.png');
    background-size: cover;
}
Icon and B letter are the same height

See result in JSBin

Note that this test is for demonstration purpose only. You can’t rely on this. Many reasons:

  • unless font metrics are constant, computations in browsers are not ¯⁠\⁠(ツ)⁠/⁠¯
  • if font is not loaded, fallback font has probably different font metrics, and dealing with multiple values will quickly become quite unmanageable

Takeaways

What we learned:

  • inline formatting context is really hard to understand
  • all inline elements have 2 height:
    • the content-area (based on font metrics)
    • the virtual-area (line-height)
    • none of these 2 heights can be visualize with no doubt. (if you're a devtools developer and want to work on this, it could be awesome)
  • line-height: normal is based on font metrics
  • line-height: n may create a virtual-area smaller than content-area
  • vertical-align is not very reliable
  • a line-box’s height is computed based on its children’s line-height and vertical-align properties
  • we cannot easily get/set font metrics with CSS

But I still love CSS :)