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…
font-size
firstLook 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:
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
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:
Let’s take the Catamaran font and open it in FontForge to get metrics:
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)
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:
<span>
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 beyondUntil 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).
line-height
, and it is the height used to compute the line-box’s heightThat being said, it breaks down the popular belief that line-height
is the distance between baselines. In CSS, it is not .
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:
<img>
, <input>
, <svg>
, etc.)inline-block
and all inline-*
elementsFor 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
.
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:
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.
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:
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.inline-block
and blocksified inline elements: padding
, margin
and border
increases the height
, so the content-area and line-box’s heightvertical-align
: one property to rule them allI 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
.
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.
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.
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-boxvertical-align: text-top
/ text-bottom
align to the top or the bottom of the content-areaBe 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.
Finally, vertical-align
also accepts numerical values which raise or lower the box regarding to the baseline. That last option could come in handy.
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);
}
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:
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);
}
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;
}
Note that this test is for demonstration purpose only. You can’t rely on this. Many reasons:
What we learned:
line-height
)line-height: normal
is based on font metricsline-height: n
may create a virtual-area smaller than content-areavertical-align
is not very reliableline-height
and vertical-align
propertiesBut I still love CSS :)