Line Height & Vertical Rhythm: Implementation Workflow

This guide is part of the Typography Fundamentals & System Architecture area. Consistent vertical rhythm is the discipline of making every line box and margin land on a shared spacing unit, so text columns, sidebars, and components line up no matter how they nest. This workflow covers unitless line-height calculation, snapping spacing to a modular grid, and synchronizing the fallback line box so a font swap never breaks the rhythm. The outcome to aim for is pixel-stable rows across viewports and a CLS contribution from typography under the 0.1 Core Web Vitals threshold.

Problem Framing: When the Rhythm Drifts or Jumps

Two distinct failures break vertical rhythm, and they need different fixes. The first is drift: nested elements compound a unit-based line-height so deeply nested text spaces differently from shallow text, and rows gradually fall off the grid. The second is jump: a late-arriving web font has a different line box than the fallback, so when it swaps in, every row below shifts and the browser scores it as Cumulative Layout Shift (CLS).

Diagnose drift first. In Chrome DevTools, inspect a deeply nested text node, open Elements → Computed, and read the computed line-height in pixels; compare it to the same element near the document root. If a line-height set in px or rem higher in the tree is producing different visual leading at different nesting depths, you have inheritance compounding. Diagnose jump next: enable Rendering → Layout Shift Regions, throttle to Slow 3G, reload, and watch whether the text block flashes a shift region at the moment the WOFF2 request in the Network panel resolves. A shift there means the fallback and web font disagree on the line box.

The fix for drift is unitless line-height; the fix for jump is a metric-matched fallback line box. Both are below. The comparison below quantifies how much CLS a mismatched fallback actually costs versus one whose metrics have been overridden to match — the gap is the entire reason this workflow exists.

Typography CLS: unmatched vs matched fallback Bar chart comparing cumulative layout shift contributed by typography for an unmatched fallback versus a fallback with size-adjust and override descriptors applied. Typography CLS: unmatched vs matched fallback Unmatched fallback 0.18 CLS 0.1 CWV threshold 0.10 CLS Matched fallback 0.02 CLS CLS score
A metric-matched fallback line box cuts typography CLS from 0.18 to 0.02.

Baseline Configuration: The Minimum Correct Setup

Before tuning anything, three things must be true: line-height is set as a unitless multiplier on :root and inherited rather than re-declared per element, vertical spacing is expressed as multiples of a single grid unit, and the named fallback font carries override descriptors so its line box matches the web font. Get these three right and most rhythm problems never appear.

Minimum rhythm configuration

:root {
  --line-height-base: 1.5;   /* unitless: multiplies each element's font-size */
  --grid-unit: 0.5rem;       /* one rhythm step = 8px at a 16px root */
}

body {
  line-height: var(--line-height-base);
  font-family: "Inter", "Inter Fallback", Arial, sans-serif;
}

/* Spacing is always a multiple of the grid unit */
p { margin-block: calc(var(--grid-unit) * 2); }
h2 { margin-block: calc(var(--grid-unit) * 3); }

A unitless line-height is the keystone: the browser multiplies the value against each element's own computed font-size, so a heading at 2rem and body text at 1rem both get proportional leading and nesting never compounds. Set line-height: 24px instead and that pixel value inherits literally, so a child with smaller text gets too much leading and a child with larger text gets clipped. For grids expressed in the line box itself, the new CSS lh unit lets you set vertical rhythm directly, sizing margins as multiples of the computed line height instead of hardcoded pixels.

Verify by inspecting a deeply nested paragraph in the Computed panel: its line-height should resolve to font-size × 1.5 for that element, not the root's pixel height. Margins, read off the box model overlay, should be exact multiples of the grid unit.

Step-by-Step Workflow

Step 1 — Define the rhythm unit and base multiplier

Pick one grid unit (4px or 8px are the common choices) and one unitless base line-height, and express both as :root custom properties so every component references the same source. Verify with a project-wide search — grep -rn "line-height" src/ should surface your token and inherited usages, not dozens of ad-hoc pixel values that will drift apart over time.

Step 2 — Apply unitless line-height and let it inherit

Set line-height once on body from the token and avoid re-declaring it on children unless a component genuinely needs a tighter or looser leading. When you must override, override with another unitless value, never a unit. Verify by comparing the computed line-height of a root-level paragraph and a triple-nested one in DevTools: the ratio to font-size must be identical even though the pixel values differ.

Step 3 — Snap spacing to the grid with calc()

Replace every hardcoded vertical margin and padding with calc(var(--grid-unit) * n) so spacing is always a whole number of rhythm steps. Use margin-block rather than margin-top/margin-bottom to stay writing-mode safe and to let adjacent margins collapse predictably.

Snap vertical spacing to the rhythm grid

h1 {
  font-size: clamp(2rem, 5vw, 3rem);
  line-height: 1.15;                       /* tighter, still unitless */
  margin-block: calc(var(--grid-unit) * 4);
}
ul, ol { margin-block: calc(var(--grid-unit) * 2); }

Verify with the DevTools box-model overlay: every vertical margin should read as an exact multiple of the grid unit (8, 16, 24px at an 8px unit), and toggling the Rendering → Layout Shift Regions flag during interaction should show no rhythm break.

Step 4 — Hold the rhythm through responsive scaling

Use clamp() on font-size so type scales fluidly between breakpoints without media-query bloat, while line-height stays unitless and tracks the scaling automatically. Because the multiplier follows the computed font-size, the line box grows and shrinks in proportion and rows stay on a consistent relative rhythm. Verify by dragging the viewport from 360px to 1440px with rulers on: line boxes should scale smoothly and never overlap or clip ascenders.

Step 5 — Match the fallback line box to prevent the swap jump

The rhythm you just built collapses the instant a web font swaps in with a different line box. Declare a named fallback @font-face and give it ascent-override, descent-override, line-gap-override, and size-adjust so its box equals the web font's. The percentages come from extracting the web font's metrics; the full extraction pipeline lives in Font Metrics & Baseline Alignment. Place the tuned fallback between the web font and the generic family in the stack.

Lock the fallback line box

@font-face {
  font-family: "Inter Fallback";
  src: local("Arial");
  size-adjust: 107%;
  ascent-override: 90%;
  descent-override: 22%;
  line-gap-override: 0%;
}

Verify by throttling to Slow 3G, reloading with Layout Shift Regions enabled, and watching the swap: a matched fallback produces no shift region when the web font arrives, and a PerformanceObserver on layout-shift logs no entry at that timestamp.

Step 6 — Audit CLS and lock the rhythm into CI

Capture CLS before and after with Lighthouse or a PerformanceObserver, and assert the typography contribution stays under target. Where axis interpolation is in play, coordinate with Optical Sizing & Variable Axes so a wght or opsz change does not move the line box off-grid. Verify by diffing two Lighthouse JSON reports: the cumulative-layout-shift audit should hold steady or improve, with no layout-shift source attributed to the swapped text node.

Vertical rhythm implementation workflow Numbered process diagram listing the six workflow steps for building and locking vertical rhythm. Vertical rhythm implementation workflow 1 Define rhythm unit grid-unit token 2 Apply unitless line-height inherit, don't repeat 3 Snap spacing to grid calc() margins 4 Hold rhythm on resize clamp() font-size 5 Match fallback line box size-adjust overrides 6 Audit CLS in CI Lighthouse budget
Six ordered steps take a page from ad-hoc spacing to a verified, CI-locked rhythm.

Worked Example: Rhythm Inside a Nested Card Grid

The single-column examples above hide a harder case: a card grid where each card holds a heading, two lines of body copy, and a footer, and the cards must all bottom-align on the page-level rhythm even though their content lengths differ. The naive approach — letting each card's height be auto and hoping flexbox centers things — produces cards whose bottom edges land on different gridlines depending on how many words wrapped.

The fix is to make the card's internal spacing a whole number of rhythm units and let align-items: start on the grid container do the rest, rather than trying to force equal heights:

.card-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
  gap: calc(var(--grid-unit) * 4);
  align-items: start;
}

.card {
  padding: calc(var(--grid-unit) * 3);
  display: grid;
  grid-template-rows: auto 1fr auto; /* heading / body / footer */
  row-gap: calc(var(--grid-unit) * 2);
}

.card h3 {
  line-height: 1.25; /* unitless, still inherits proportionally */
  margin-block: 0;
}

.card p {
  line-height: var(--line-height-base);
  margin-block: 0;
}

.card footer {
  line-height: 1.4;
  margin-block: 0;
}

Because every gap and padding value is a multiple of --grid-unit, and every text element's line-height is unitless, each card's internal rows still line up with the page rhythm even though the cards themselves are different heights. The grid-template-rows: auto 1fr auto pattern pins the footer to the bottom of the tallest card in a row without forcing the heading or body region to stretch, so leading never gets distorted by flex-driven vertical centering — a common way rhythm quietly breaks in dashboard and product-card layouts. Verify by placing cards with one, two, and three lines of body text side by side in the same row: their footers should align on a shared gridline, and the box-model overlay on each footer should report the same top offset from its own card's top edge, in multiples of the grid unit, once padding is subtracted.

Edge Case: Sub-Pixel Rounding Divergence Between Browsers

Unitless line-height is universally supported, but browsers do not all round the resulting sub-pixel value identically. At a font-size of 15px and a line-height of 1.5, the computed line box is 22.5px — a value with no integer pixel representation. Chromium rounds this per line box during layout, Firefox accumulates fractional remainders across a run of stacked elements, and the two can diverge by a device pixel every few dozen rows in a long article. On a standard display this is invisible; on high-density text (dense tables, code listings with many rows) it can accumulate into a one-to-two pixel drift between a Chromium and a Gecko rendering of the same page.

This is not a bug to "fix" — it is sub-pixel rounding working as specified, and no CSS property eliminates it entirely. The practical mitigation is to keep font-size values that produce line heights close to whole pixels (multiples of your grid unit divided by the line-height multiplier tend to land closer to integers) and to avoid asserting pixel-perfect equality in visual regression tests across browsers; instead assert a tolerance of 1–2px when diffing screenshots taken in different engines. Verify by rendering the same 40-row table in Chrome and Firefox at 100% zoom, screenshotting both, and diffing with a tool that accepts a small pixel tolerance rather than exact match — an exact-match diff will falsely fail on rounding alone.

Edge Case: Browser Zoom and User Font-Size Overrides

Vertical rhythm built entirely in rem and unitless line-height scales correctly under both the browser's page zoom and a user's forced minimum font size, because both mechanisms operate on the root font-size that rem and unitless multipliers reference. A rhythm built with fixed px grid units, however, stops scaling: at 200% zoom the text grows but the 8px gap between rows does not, and rows visually crowd together even though nothing broke technically.

Express --grid-unit in rem, not px, for exactly this reason — 0.5rem scales with the root font-size the same way the type does, keeping the ratio between line height and spacing constant under zoom or a forced 20px minimum font size in accessibility settings. Verify by setting the browser's minimum font size to a large value (Chrome: Settings → Appearance → Font size) and confirming that margins and line boxes grow together rather than the text overtaking its own spacing.

Edge Case: Vertical Writing Modes and RTL Text

Rhythm built with margin-block rather than margin-top/margin-bottom already survives a switch to writing-mode: vertical-rl or an RTL locale, because logical properties resolve relative to the current writing direction rather than a fixed physical axis. A rhythm still using physical margin-top/margin-bottom breaks the moment the writing mode changes, because "block start" no longer maps to "visual top."

The one property that still needs attention in vertical writing modes is line-height itself: a unitless value continues to multiply against font-size correctly, but the axis the resulting line box occupies rotates with the writing mode, so a rhythm grid drawn with fixed horizontal gridlines (as in a design mock) needs to be redrawn as vertical gridlines for a vertical script layout — the rhythm unit stays the same, only its visual orientation changes. Verify by toggling writing-mode: vertical-rl on a test page and confirming the box-model overlay still reports margins as exact multiples of the grid unit, now measured along the inline axis instead of the block axis.

Browser Compatibility & Fallback Matrix

Feature Chrome / Edge Firefox Safari Notes
Unitless line-height all all all The portable, inheritance-safe baseline
clamp() for font-size 79+ 75+ 13.1+ Fluid type without media queries
margin-block logical prop 87+ 66+ 14.1+ Writing-mode safe vertical spacing
size-adjust / *-override 87–92+ 89–92+ 16.4–17+ Safari shipped these late; see notes
CSS lh unit 110+ 120+ 16.4+ Margins as multiples of computed line height

The headline edge case is Safari's late support for the metric override descriptors: size-adjust arrived in 16.4 and the ascent-override/descent-override/line-gap-override trio in 17, so older Safari ignores them and shows a small residual swap jump rather than a broken layout. Guard the optimization with @supports (size-adjust: 100%) if you need to branch. The CSS lh unit is also recent across the board (Safari 16.4, Chrome 110, Firefox 120), so when you size margins in lh, provide a rem-based fallback first and upgrade inside an @supports (margin-block: 1lh) block. Unitless line-height itself has been universal for decades and needs no guard — which is exactly why it should carry the bulk of your rhythm logic.

Code Configuration Examples

Unitless rhythm with custom properties

:root {
  --base-line-height: 1.5;
  --grid-unit: 0.25rem;
}

h1 {
  font-size: clamp(2rem, 5vw, 3rem);
  line-height: 1.15;
  margin-block: calc(var(--grid-unit) * 4);
}
p { margin-block: calc(var(--grid-unit) * 2); }

Margins sized with the lh unit, with a fallback

/* Fallback for older browsers */
p { margin-block: 1.5rem; }

/* Upgrade: one and a half line boxes of space */
@supports (margin-block: 1lh) {
  p { margin-block: 1.5lh; }
}

Sizing in lh ties spacing to the actual rendered line height, so a component that locally changes line-height keeps its margins proportional automatically.

Build-time metric extraction for fallback overrides

# Run at build time to emit CSS custom properties for rhythm tuning
from fontTools.ttLib import TTFont

font = TTFont("inter.woff2")
upm = font["head"].unitsPerEm
os2 = font["OS/2"]

print(f"--cap-height-ratio: {os2.sCapHeight / upm:.4f};")
print(f"--x-height-ratio: {os2.sxHeight / upm:.4f};")

Locking axis ranges so the line box stays on-grid

@supports (font-variation-settings: "opsz" 12) {
  body { font-variation-settings: "opsz" 16, "wght" 400; }
}
@media (min-width: 60rem) {
  body { font-variation-settings: "opsz" 20, "wght" 400; }
}

Runtime CLS assertion with PerformanceObserver

let typographyShift = 0;

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    if (!entry.hadRecentInput) typographyShift += entry.value;
  }
}).observe({ type: "layout-shift", buffered: true });

// After the load event settles, assert against a budget in a
// synthetic-monitoring script or a Lighthouse CI custom check.
window.addEventListener("load", () => {
  setTimeout(() => {
    if (typographyShift > 0.1) {
      console.warn(`Typography CLS budget exceeded: ${typographyShift.toFixed(3)}`);
    }
  }, 3000);
});

This is the same observer pattern used to isolate font-driven shift from other CLS sources; it accumulates every unexpected layout-shift entry into a single number you can compare against the 0.1 threshold, and pairs well with the deeper instrumentation covered in Measuring Font Loading Performance and Tracking Font CLS with the web-vitals Library.

Typography CLS budget in CI Meter showing measured typography layout shift against the 0.1 Core Web Vitals budget threshold. Typography CLS budget in CI 0 0.10 CLS 0.02 measured 0.10 budget
A locked rhythm keeps the typography share of CLS well inside the Core Web Vitals budget.

Common Pitfalls

Pitfall Impact Resolution
Unit-based line-height (px/rem) Inheritance compounding breaks nested spacing Switch to unitless multipliers (1.4, 1.6)
Ignoring ascender/descender overflow Visual clipping in tight grid containers Add padding-block equal to the descender delta
No fallback line-box match CLS jump at the font swap Apply size-adjust/*-override to the fallback @font-face
Variable-axis drift at breakpoints Rows fall off-grid during transitions Bind opsz/wght ranges to @media queries
Hardcoded margins overriding the grid Destroys rhythm synchronization Replace margin with margin-block: calc(var(--grid-unit) * n)
Sizing in lh without a rem fallback Collapsed margins in pre-16.4 Safari Provide a rem default, upgrade inside @supports
Flex align-items: center on card content Cards drift off the page-level rhythm Use grid-template-rows: auto 1fr auto and align-items: start
Grid unit expressed in px Rhythm stops scaling under browser zoom or forced minimum font size Express --grid-unit in rem so it scales with the root font-size
Exact-match visual regression across browsers False failures from sub-pixel rounding differences Diff screenshots with a 1–2px tolerance, not byte-exact comparison

Frequently Asked Questions

Why does unitless line-height prevent vertical rhythm drift?

A unitless value multiplies directly against the computed font-size of the current element, so every element gets leading proportional to its own size. A unit value like 24px or 1.5rem inherits as a fixed length, so a nested element with different text size keeps the ancestor's pixel leading — too loose for small text, clipped for large text. Unitless line-height is the single most important rule for drift-free nesting, which is why it should live on :root and rarely be overridden with anything but another unitless value.

How do I match the fallback line box so a font swap does not shift the grid?

Declare a named fallback @font-face and give it ascent-override, descent-override, line-gap-override, and size-adjust derived from the web font's metrics, then chain it between the web font and the generic family. When the web font swaps in, its line box already equals the fallback's, so no rows move. The full extraction-to-CSS pipeline is in Font Metrics & Baseline Alignment; validate the result with Layout Shift Regions on a throttled reload.

How do variable font axes impact vertical rhythm?

Interpolating wght or opsz shifts the glyph bounding boxes, so the line box can grow as the axis moves and push rows off the grid. Bind the axis values to breakpoints with @media and @supports (font-variation-settings: ...), calibrate the fallback overrides at the most-used axis position, and re-check the rhythm at each breakpoint. See Optical Sizing & Variable Axes for stabilizing the baseline through axis transitions.

Should I snap to a 4px or an 8px grid?

Use 8px as the default rhythm unit for body-led layouts — it keeps the token count low and aligns with most icon and spacing systems — and drop to a 4px sub-unit only where dense UI (chips, table rows, form controls) needs finer steps. Express both as a single custom property and its multiples so the choice is centralized; switching the base unit later is then a one-line change rather than a sweep through every component.

Does font-display: swap make the rhythm problem worse?

swap is orthogonal to rhythm, but it makes a mismatched fallback line box visible rather than hidden: with block, the invisible-text period masks the swap jump behind a period where nothing is rendered, so the shift technically still happens but the user never sees text move. With swap, the fallback renders immediately and the jump is seen the instant the web font arrives. This means metric-matched fallback overrides matter more, not less, under swap — pairing font-display: swap with an unmatched fallback is the single most common way real sites accumulate typography CLS in the field. See font-display Values Explained for the full trade-off between the display modes.

Can I compute the grid unit and base line-height automatically from a type scale?

Yes, and it is the more maintainable long-term approach: derive --grid-unit as a fraction of your body font-size (for example, half the body size rounded to the nearest even pixel) so the rhythm step scales if the base type size ever changes, rather than being a magic number picked once and forgotten. The Type Scale & Modular Grids guide covers deriving both the horizontal type scale and the vertical rhythm unit from the same ratio, which keeps horizontal and vertical spacing visually consistent instead of being tuned independently.

Related