CSS units: px, em, rem, % and ch
You set your nested menu to font-size: 0.9em because one level looked a
touch large. Level two is smaller than you wanted. Level three is nearly
illegible. Nothing is broken, no rule is being overridden, and the numbers are
all 0.9.
The unit is the problem. A number in CSS is meaningless on its own — 0.9 of
what is the entire question, and each unit answers it differently.
.price {
font-size: 20px; /* twenty pixels, always */
padding: 1em; /* twenty pixels, because the font is 20px */
}
Two declarations, same rendered value, completely different promises.
px is the fixed one
px does not move. border: 1px is one pixel at every font size, in every
container, on every page.
One clarification, since it confuses people: a CSS pixel is not necessarily a hardware pixel. On a high-density phone screen the browser may paint one CSS pixel across four physical ones, which is why a 16px font is not microscopic on a modern phone. You do not manage this; the browser does.
Use px where a value genuinely should not scale: hairline borders, small
fixed offsets, the odd icon that must land on an exact size.
Avoid it for body text. A reader who has raised their browser's default font size — because they need to — gets no benefit from a page hard-coded to 14px.
% is relative to the parent, but to which measurement?
A percentage is always a share of something in the parent, and the something changes by property. This catches people out:
width: 50%— half the containing block's width.font-size: 120%— 1.2× the parent's font size.line-height: 150%— 1.5× this element's own font size.padding: 10%andmargin: 10%— a share of the containing block's width, on all four sides.
That last one is the surprise. padding-top: 10% measures against width, not
height. It looks like a bug and it is deliberate — it is how people used to
force a fixed aspect ratio before aspect-ratio existed.
em measures from the element's own font size
em is relative to the font size of the element the declaration is on.
.btn {
font-size: 14px;
padding: 1em; /* 14px */
}
.btn-large {
font-size: 20px;
padding: 1em; /* 20px */
}
One padding declaration, two proportional results. The large button's breathing
room grows with its text automatically, so it looks like the same button rather
than a small button with a big label. This is em doing exactly what it is
best at: spacing that belongs to the type.
There is one special case, and it is the one that causes all the trouble. When
the property is font-size, the element's own font size is what you are
busy calculating, so em falls back to the parent's computed font size
instead.
The compounding trap
Beginners treat em and rem as spellings of one idea, and describe em as
"relative to the parent". Half right, and the wrong half hurts.
Here is a menu with three levels of nesting:
<ul class="menu">
<li>Routes
<ul>
<li>Karachi
<ul>
<li>Saddar</li>
</ul>
</li>
</ul>
</li>
</ul>
.menu ul {
font-size: 0.9em;
}
If em really meant "relative to the parent's original size", every level
would be 0.9 of 16px — 14.4px — and it would stop there. That is what most
people expect, and the first level agrees with them, which is what makes the
belief so convincing.
But the parent's font size is not 16px any more. It is whatever the previous
0.9em produced. So the multiplications stack:
| Level | Calculation | Computed |
|---|---|---|
.menu |
inherited root | 16px |
| level 2 | 16 × 0.9 | 14.4px |
| level 3 | 14.4 × 0.9 | 12.96px |
| level 4 | 12.96 × 0.9 | 11.664px |
That is 0.9 × 0.9 × 0.9 = 0.729 of where you started. Add two more levels and the text is under 10px. Every step is obeying your rule perfectly; the rule just compounds.
rem is the fix. It is relative to the root font size — the <html>
element, 16px unless the user changed it — no matter how deep the element sits:
.menu ul {
font-size: 1rem; /* 16px at every level, forever */
}
Nothing compounds, because nothing is measured against the level above.
So the working split is: rem for anything that must not compound — type
scale, section spacing, component sizes — and em for anything that should
deliberately track the local font size, which in practice means padding,
margin-bottom on a heading, and gaps inside a button.
One more warning while you are here. You will see advice to set
html { font-size: 62.5% } so that 1rem becomes a convenient 10px. It works,
but it shrinks the size the reader chose in their browser settings by nearly
40%. Leave the root alone and get comfortable with 16.
ch measures in characters
1ch is the width of the 0 glyph in the element's current font.
Its use is line length. Long lines are hard to read because your eye loses the next line on the return sweep; the comfortable range is roughly 45 to 75 characters.
article p {
max-width: 65ch;
}
That is a much better instruction than max-width: 700px, because it means
what you actually want. Change the font size, and the measure keeps holding
about the same number of characters.
Be honest about the approximation, though. ch measures the digit zero, and
in most fonts letters average narrower, so 65ch typically holds rather more
than 65 characters. It also shifts when you change font family — a condensed
face fits far more into the same ch count. It is a good approximation, not a
guarantee.
The same paragraph, three ways
Put one paragraph inside a 400px card and again inside a 900px card:
.p-a { width: 25%; }
.p-b { width: 250px; }
.p-c { width: 60ch; }
25%is 100px in the small card and 225px in the large one. It promises a quarter of whatever contains me.250pxis 250px in both — which overflows the 400px card once padding is added. It promises this exact size, container be damned.60chis the same reading measure in both, until the container is narrower than that, at which point it overflows too. It promises this many characters per line.
None is right in general. Pick the one whose promise matches the sentence you would say out loud about the design.
Viewport units, briefly
1vw is one per cent of the viewport width, 1vh one per cent of its height.
100vh is a full screen.
On phones there is a wrinkle: the address bar slides away as you scroll, so
"the screen" has two heights. 100vh uses the larger one, which means a
supposedly full-height section can be taller than the visible area at first.
100dvh — dynamic viewport height — tracks the space actually visible right
now, and is usually what you meant.
A default set worth starting from
If you want one line to remember: rem for type and vertical rhythm, em
for padding inside a component, px for borders, % and ch for widths, and
dvh when something must fill the screen.
Next: calc(), which lets you mix these in a single value — a width of 100% minus a fixed sidebar — and comes with a whitespace rule that fails completely silently. The colour lesson covers the other half of the values you will be writing today.
Prefer learning with a teacher?
WasiLearn Academy runs small-group classes covering this material, online and in Karachi.
See the classes