Why CSS line-height Isn't Leading
Print designers know leading as the literal strips of lead once placed between lines of metal type. Later, the term came to mean the distance from one baseline to the next. In CSS, the line-height property often gets compared to leading, but the two behave very differently. To see why, it helps to name the parts of a typeface that actually matter here.
- Baseline: the imaginary line the type sits on, as in a ruled notebook.
- Descender: the region below the baseline touched by letters such as
g,j,p,q, andy. - X-height: roughly the height of a lowercase
x, usually the perceived height of other lower-case characters. - Cap height: the usual height of capital letters.
- Ascender: the area above the cap height that letters like
bandhmay reach.

Fonts come with these metrics baked in. The person setting the type — you — controls what sits outside them, namely the leading between baselines:

Leading and line-height sound interchangeable, but in a browser they are not. Take a typical CSS reset:
* {
margin: 0;
padding: 0;
}
Now set an h1 in something like Lato with a line-height of, say, 300px. Instead of hanging the text on a baseline, the browser puts a single line of text inside a “line box” that is 300px tall and centers the glyphs within it. Result: a huge line of type with an equal band of empty space above and below the text — effectively padding, not leading.

The half-leading model has consequences. Even when font-size equals line-height, the text box grows because the em square expands beyond the visible glyphs. Put a background color on the element and the extra space on both sides of every line becomes obvious.
Teaching CSS Real Leading
True leading would mean a single line of text with no space above or below its own glyph extents, while multiline text keeps the full spacing between baselines. One known solution is Basekick, originally by Michael Taranto, and now maintained within SEEK's Braid Design System. Its current formula applies a negative top margin to the element's ::before pseudo-element and a compensating translateY to the element itself so no stray space survives.
The underlying values can be encoded in a Sass mixin, or in Less, JavaScript, PostCSS mixins, or whatever has similar math capabilities:
@function calculateTypeOffset($lh, $fontSize, $descenderHeightScale) {
$lineHeightScale: $lh / $fontSize;
@return ($lineHeightScale - 1) / 2 + $descenderHeightScale;
}
@mixin basekick($typeSizeModifier, $baseFontSize, $descenderHeightScale, $typeRowSpan, $gridRowHeight, $capHeight) {
$fontSize: $typeSizeModifier * $baseFontSize;
$lineHeight: $typeRowSpan * $gridRowHeight;
$typeOffset: calculateTypeOffset($lineHeight, $fontSize, $descenderHeightScale);
$topSpace: $lineHeight - $capHeight * $fontSize;
$heightCorrection: 0;
@if $topSpace > $gridRowHeight {
$heightCorrection: $topSpace - ($topSpace % $gridRowHeight);
}
$preventCollapse: 1;
font-size: #{$fontSize}px;
line-height: #{$lineHeight}px;
transform: translateY(#{$typeOffset}em);
padding-top: $preventCollapse;
&::before {
content: "";
margin-top: #{-($heightCorrection + $preventCollapse)}px;
display: block;
height: 0;
}
}
The mixin needs only a handful of inputs:
$baseFontSize: the normal system font size, e.g. 16px, against which other types scale.$typeSizeModifier: a multiplier applied to the base for a given rule — 2 with a 16px base yieldsfont-size: 32px.$descenderHeightScale: the font's descender depth as a ratio; about 0.11 for Lato.$capHeight: the font's cap height ratio; about 0.75 for Lato.$gridRowHeight: your vertical rhythm's unit, e.g. 4, dividing evenly into the base font size.$typeRowSpan: grid units multiplied by the row height to reach a line height, e.g. 8 × 4 = aline-heightof 32px.
With those values in place, block elements without margins sit flush against each other. Margins you do add are then truly pixel-perfect, because they are no longer stacking on top of line box padding.
Making the Mixin Manageable
Rather than repeating all the variables, group them by concern. System-level variables change rarely and stay global. Font-level values belong to their typeface. Only a few rule-level values vary by element:
| Variable Type | Description | Mixin Variables |
|---|---|---|
| System Level | These values are properties of the design system we’re working with. | $baseFontSize$gridRowHeight |
| Font Level | These values are intrinsic to the font we’re using. There might be some guessing and tweaking involved to get the perfect numbers. | $descenderHeightScale$capHeight |
| Rule Level | These values will are specific to the CSS rule we’re creating | $typeSizeMultiplier$typeRowSpan |
$baseFontSize: 16;
$gridRowHeight: 4;
@mixin basekick($typeSizeModifier, $typeRowSpan, $descenderHeightScale, $capHeight) {
/* Same as above */
}
Font metrics can sit inside a higher-order mixin, so calling it is near trivial:
@mixin Lato($typeSizeModifier, $typeRowSpan) {
$latoDescenderHeightScale: 0.11;
$latoCapHeight: 0.75;
@include basekick($typeSizeModifier, $typeRowSpan, $latoDescenderHeightScale, $latoCapHeight);
font-family: Lato;
}
.heading--medium {
@include Lato(2, 10);
}
The output goes from a detailed setup to a clean rule using Lato with font-size: 32px, a 40px line height, and all the required offsets included. That lets a page align to a 4px grid just as a design file in Sketch or Figma would.
Native Support Ahead?
Browsers may not always need this trick. A CSS specification proposal suggests adding a property named something like line-height-trim or leading-trim so the line box collapses that surplus space intrinsically. Comments on the thread are open — if this behavior matters to you, the working group invites feedback there.



