How to set line height that survives a font swap.
Set a two-line headline in Inter at a line height of 1.0, then switch the font to Poppins and change nothing else. In Flutter the capitals jump 5.3 pixels per 100 pixels of type: up toward whatever sits above them. In a browser they barely move. Same number, same fonts, two different layouts, and neither toolkit warns you. The number you typed never described the font in the first place.
The multiplier is of the font size, not the font
Line height is a multiple of the font size: 1.2 at 100 pixels means a 120-pixel line. The glyphs don’t know that. Every font file carries its own vertical metrics, an ascent above the baseline and a descent below it, and together they make the font’s natural line, the height you get when you set nothing at all. Read straight out of the font files bundled in the ShotCanvas app, as a multiple of the font size:
font natural line
Inter 1.210
Montserrat 1.219
Fraunces 1.233
Outfit 1.260
Space Grotesk 1.276
DM Sans 1.302
Playfair Display 1.333
Poppins 1.500
Poppins is the outlier twice over: a 1.4 box plus a built-in line gap of 0.1, which the
browser adds when you leave line height on normal and Flutter adds when you leave
height unset. So “1.2” is not one decision. On Inter it trims a hair off the natural line. On
Poppins it removes three-tenths of the font size, a fifth of the font’s own spacing. A
design system that says “headings use 1.2” is applying different amounts of
squeeze to every family it allows, and when a user or a theme swaps the face, the spacing
silently changes with it.
CSS splits the leftover evenly
When the line is taller or shorter than the font’s natural box, CSS takes the difference, halves it, and adds one half above the glyphs and one below. The spec calls it half-leading. The effect is that the font’s box stays centred in the line whatever number you choose: loosen it and the space grows equally on both sides, tighten it and both sides shrink.
Centring the box is not quite centring the letters. Where the capitals land depends on how the designer balanced ascent against descent, and you can work it out from the metrics. Inter and Poppins are balanced almost perfectly: their capitals sit within a thousandth of an em of the line’s centre. DM Sans and Space Grotesk are within one percent. Outfit sits 3.2% of the font size low, and Playfair Display 6.2% low, because its tall ascent puts the middle of its box above the middle of its capitals. That last one is 3 pixels on a 48-pixel headline: small, but it’s the difference between a label that looks centred in its pill and one that looks like it’s sinking.
Flutter stretches around the baseline
Flutter’s default does something else. When you set height on a
TextStyle, the font’s ascent and descent are each scaled by the same factor,
so the extra space is shared in proportion to them. Inter’s ascent is four times its
descent, so four-fifths of any extra space lands above the text. The SDK calls this
TextLeadingDistribution.proportional, and it is the default.
To see what that does in practice, I set the same line of text at 100 pixels in Flutter 3.38.4’s renderer and in Chromium, with the same font files, and measured the distance from the top of the line to the baseline (Chromium snaps to whole pixels):
baseline from top of line, 100px type
height Flutter CSS
Inter 1.0 80.1 86
1.2 96.1 96
1.5 120.1 111
Poppins 1.0 73.3 85
1.2 88.0 95
1.5 110.0 110
Playfair 1.0 81.2 91
1.2 97.4 101
1.5 121.8 116
Near each font’s natural line the two agree, because there’s little leftover to distribute. Move away from it and they diverge, in opposite directions depending on which way you moved. At 1.5, Flutter pushes Inter 9 pixels lower than the browser does; at 1.0, it pulls Poppins nearly 12 pixels higher. Measured against the centre of the line, Inter’s capitals at 1.0 ride 6.3 pixels high in Flutter and Poppins’s ride 11.6 pixels high; at 1.5, Inter sinks 8.7 pixels low and Playfair 11.4. In the browser, the same capitals sit within half a pixel of centre for Inter and Poppins at every height we tried.
That is the font swap from the opening. Tight line heights are where headlines live, so this
bites hardest on exactly the text people look at first. It also explains the Flutter button
whose label looks fine at the default line height and drifts upward the moment someone tightens
the theme’s height to 1.0 for a denser look.
Getting even leading in Flutter
Flutter has a setting for the CSS behaviour:
TextLeadingDistribution.even. It can be set in three places: on a
TextStyle, on a TextHeightBehavior, or on a
StrutStyle. In our measurements only the last one moved anything. Setting it on
the TextStyle or through the painter’s TextHeightBehavior left
Inter’s baseline at exactly 120.10 at height 1.5. The same setting on a strut put it at
111.38, which is what the half-leading arithmetic predicts and what Chromium drew. We measured
in Flutter’s test renderer, not on a phone, so treat the first two as unconfirmed and
check on a device before you rely on them. The strut works:
Text(
headline,
style: TextStyle(fontFamily: family, fontSize: 48, height: 1.0),
strutStyle: StrutStyle(
fontFamily: family,
fontSize: 48,
height: 1.0,
leadingDistribution: TextLeadingDistribution.even,
forceStrutHeight: true,
),
)
A strut is a minimum line box that every line in the paragraph must use;
forceStrutHeight makes it the exact box. Give it the same family, size and height
as the text. The cost is that a larger inline span, an emoji, or a fallback glyph from another
font can no longer push its own line taller. For a headline or a button label that’s
usually what you want anyway, since lines that change height mid-paragraph look like a bug.
Trim the first and last line
Even leading fixes the middle of a paragraph. The edges still carry half a leading of empty space above the first line and below the last. Put 24 pixels of padding above an Inter headline at 48 pixels and a line height of 1.5, and the font’s box starts 31 pixels down, before you even count the room inside the box above the capitals. Both toolkits can now remove the leading.
In Flutter, TextHeightBehavior(applyHeightToFirstAscent: false,
applyHeightToLastDescent: false) drops the added space above the first line and below
the last. On a two-line Inter paragraph at height 1.5, it took the box from 300 pixels to 271
and put the first baseline at the font’s natural 96.9.
In CSS, text-box: trim-both cap alphabetic goes further and trims the box to the
top of the capitals and the baseline, so the padding you write is the padding you see. Chrome
and Edge have supported it since version 133 and Safari since 18.2; Firefox doesn’t
yet, where it falls back to the untrimmed box. That makes it a safe progressive enhancement.
A short checklist
Write line height per family, not per role. If your app lets people pick a font, store a line height that targets the same visual tightness for each one, starting from the natural lines above. Poppins at 1.25 and Inter at 1.0 are closer to each other than both at 1.1.
Make Flutter match the browser. Use an even-leading strut for any text you align against something else: headlines, buttons, badges, chips. The default proportional split puts most of any extra space above the text, which is harmless in a paragraph and wrong for anything centred.
Correct the fonts that sit low. For a face like Playfair Display, nudge the text up by the offset you calculate from its metrics: half of (ascent − descent − cap height), as a fraction of the em. For Playfair that’s 0.06 of the font size. It’s a per-font constant, so compute it once.
Trim the edges so your spacing tokens mean what they say.
This matters most in App Store screenshots, where the headline is set large, tight, and in whatever display face fits the app, and where trying a different font is a one-tap change. If you want to try headline fonts against your own screenshots and see the spacing before you export, that’s what the Studio is for.