Line Height & Vertical Rhythm: Implementation Workflow

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 is part of the Typography Fundamentals & System Architecture blueprint, and it 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 baseline grid diagram shows how rows snap onto a fixed rhythm unit so descenders, ascenders, and inter-paragraph spacing all land on shared gridlines.

Baseline grid snapping text rows to a fixed rhythm unit Horizontal gridlines spaced one rhythm unit apart, with three lines of text whose baselines and margins align to the grid. Heading on baseline Body line one snaps to grid Body line two, rhythm locked 1 unit = line-height x font-size baseline grid
Each text row's baseline lands on a shared rhythm unit, so margins and line boxes stay vertically aligned.

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.

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; }
}

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

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.

Related