Vertical Rhythm with the CSS lh Unit: Baseline Grids in Pure CSS

This guide is part of the Line-Height & Vertical Rhythm section within the Typography Fundamentals & System Architecture area. It resolves one specific technique: using the lh and rlh length units to express spacing as multiples of the line-height, so every margin and gap snaps to a single baseline grid without magic numbers.

Problem Statement

Vertical rhythm means every block of text sits on a consistent baseline grid: the distance between lines, and the spacing between paragraphs and headings, are all multiples of one rhythm unit. Traditionally you approximate this with rem or em margins hand-tuned to roughly equal your line-height — but the moment line-height changes, or a heading uses a different one, the arithmetic breaks and the grid drifts. The lh unit makes the relationship exact: 1lh is the computed line-height of the current element, and 1rlh is the line-height of the root element. Expressing margins in lh/rlh means spacing is defined in terms of the rhythm itself, so it stays locked to the grid automatically even when font sizes or line-heights change. This matters most in systems that also lean on a fluid type scale built with clamp(), where font-size — and therefore line-height in px — changes continuously across the viewport; a rhythm expressed in fixed px or unrelated rem values cannot track that continuous change, but one expressed in lh/rlh does, because both units resolve against the live computed line-height at every viewport width.

Prerequisites

  • A defined root line-height, e.g. :root { line-height: 1.5 }, because rlh resolves against it. A unitless line-height is recommended so it scales with font-size.
  • Awareness of browser support. The lh and rlh units shipped in Chrome 109 (Jan 2023), Safari 16.4 (Mar 2023), and Firefox 120 (Nov 2023). They are now Baseline, but if you support older releases you need the rem fallback shown below.
  • A consistent base font-size so the rhythm unit is predictable across the document.
  • Familiarity with how line-height: normal resolves, since 1lh against a normal value is defined but font-dependent (see the edge case below) rather than a clean multiple of font-size.
Unit What it resolves to Chrome Safari Firefox
lh line-height of the current element 109 16.4 120
rlh line-height of the root element 109 16.4 120
lh / rlh Browser Support Comparison table showing Chrome, Safari, and Firefox support versions for the lh and rlh CSS length units. lh / rlh Browser Support Chrome Safari Firefox lh 109 16.4 120 rlh 109 16.4 120
Both units shipped together and are now Baseline across evergreen browsers.

Implementation

The core technique: set spacing properties in lh (for rhythm relative to the element's own line-height) or rlh (for rhythm locked to the document-wide grid). Use rlh for inter-block spacing so headings and paragraphs share one grid even though their own line-heights differ.

Baseline-grid spacing with lh and rlh units

:root {
  font-size: 1.125rem;   /* ~18px base */
  line-height: 1.5;      /* the rhythm unit: 1rlh = 1.5 x 18px = 27px */
}

body {
  /* paragraphs and headings flow on a 1rlh grid */
  margin: 0;
}

p {
  margin-block: 0 1rlh;  /* trailing gap = exactly one root rhythm unit */
}

h2 {
  font-size: 1.5rem;     /* bigger text, different OWN line-height... */
  line-height: 1.2;
  /* ...but spacing uses rlh, so it stays on the SHARED baseline grid */
  margin-block: 2rlh 1rlh;
}

blockquote {
  /* indent and pad in lh so the quote's internal rhythm matches itself */
  padding-block: 1lh;
  border-inline-start: 0.25rem solid currentColor;
  margin-block: 1rlh;
}

.stack > * + * {
  /* a utility: every stacked sibling separated by one rhythm unit */
  margin-block-start: 1rlh;
}

The annotated reasoning: 1rlh equals the root line-height (1.5 × 18px = 27px here), so any margin-block expressed in rlh is a whole number of grid rows. The h2 is the load-bearing example — it has a different line-height (1.2) for its own larger text, yet its surrounding margins use rlh, not lh. That distinction is the whole trick: if the heading's margins used 1lh, they would be 1.2 × 24px = 28.8px, off-grid relative to the body's 27px rows. By using rlh for inter-block spacing and reserving lh for intra-element padding (like the blockquote, whose internal padding should track its own line-height), every gap between blocks remains a clean multiple of the single document rhythm. The .stack utility generalises this: any vertical stack separates children by exactly one rhythm unit, the modern lobotomized-owl pattern expressed in baseline units.

Worked Example: lh in Flex and Grid Gaps

lh and rlh are not limited to margin — they resolve anywhere a <length> is accepted, including gap, padding, border-width, and even translate. This is useful for card layouts that need to sit on the same rhythm as the surrounding prose:

.card-list {
  display: grid;
  gap: 1rlh;              /* row/column gaps stay on-grid */
  padding: 1rlh;
}

.card {
  padding-block: 0.5lh;   /* internal padding tracks the card's OWN text size */
}

.icon-label {
  display: flex;
  align-items: center;
  gap: 0.5lh;             /* icon-to-label gap scales with the label's line-height */
}

Here gap: 1rlh on the grid container keeps card spacing aligned with the page's baseline grid even though cards may contain headings, lists, or images with varied internal line-heights. The .icon-label gap deliberately uses lh (not rlh) because it should track the local text size of the label next to the icon — if the label's font-size changes independently of the root (for example inside a small badge), the icon gap should shrink with it, which lh does automatically and rlh would not.

Worked Example: lh with Container Queries

Component-level line-heights are common when a component's font-size is controlled by a container query rather than the viewport. In that case lh (not rlh) is the correct rhythm unit, because the component's own line-height — not the document's — defines its internal rhythm:

.article-card {
  container-type: inline-size;
  line-height: 1.4;
}

@container (min-width: 480px) {
  .article-card { font-size: 1.25rem; }
}

.article-card .meta {
  /* tracks the card's own, container-driven line-height,
     not the page-level rlh grid */
  margin-block-start: 0.75lh;
}

As the container query changes font-size, the card's computed line-height changes too, and 0.75lh recomputes automatically to stay proportional — no JavaScript ResizeObserver needed to keep the internal spacing in sync with the responsive type size.

Edge Case: line-height: normal

When an element's line-height is the keyword normal rather than a number, 1lh still resolves to a used value, but that value is derived from the font's own metrics (roughly ascent + descent + line-gap from the font file) rather than a clean multiple of font-size. Two different typefaces with line-height: normal at the same font-size can therefore produce different lh values, which breaks a shared rhythm across a fallback stack. For any element you want to participate in the rhythm, always set an explicit unitless line-height (e.g. line-height: 1.5) rather than relying on normal, so 1lh is a predictable multiple of font-size regardless of which font in the fallback stack actually renders.

Defensive Variant: rem Fallback via @supports

For browsers predating Chrome 109 / Safari 16.4 / Firefox 120, lh/rlh are invalid and the declaration is dropped, collapsing your spacing. The defensive pattern declares a rem approximation first (always applied), then overrides with the rlh version inside an @supports query so only capable browsers use the precise unit.

Progressive enhancement: rem baseline first, lh/rlh when supported

:root {
  font-size: 1.125rem;
  line-height: 1.5;
  /* precompute the rhythm unit as a rem value for the fallback:
     1.5 (line-height) x 1.125rem = 1.6875rem */
  --rhythm: 1.6875rem;
}

/* 1. Fallback applied to every browser. */
p  { margin-block: 0 var(--rhythm); }
h2 { margin-block: calc(var(--rhythm) * 2) var(--rhythm); }

/* 2. Capable browsers replace the approximation with exact rlh,
      which stays correct even if line-height changes at runtime. */
@supports (margin: 1rlh) {
  p  { margin-block: 0 1rlh; }
  h2 { margin-block: 2rlh 1rlh; }
}

The fallback --rhythm custom property hard-codes the rhythm as a rem value (1.5 × 1.125rem), which is correct as long as the root line-height does not change. The @supports (margin: 1rlh) query then upgrades capable browsers to the live rlh unit, which has the advantage of recomputing automatically if line-height changes (for example via a user stylesheet or a density toggle). This way old browsers still get an on-grid layout from the static rem math, and modern browsers get the self-correcting rlh version, with no JavaScript and no broken spacing anywhere.

Progressive Enhancement Path Numbered steps showing how a page moves from a precomputed rem rhythm to an exact rlh-based rhythm using an at-supports feature query. Progressive Enhancement Path 1 Set --rhythm in rem always applied 2 Apply rem margins baseline for all browsers 3 @supports (margin:1rlh) feature query 4 Override with rlh exact, self-correcting
Rem fallback applies first, then @supports upgrades capable browsers to rlh.

Worked Example: Feature-Detecting in JavaScript

If you need to branch behaviour in JavaScript rather than CSS — for example to decide whether to run a rhythm-debugging overlay — CSS.supports() mirrors the @supports check:

const hasRlh = CSS.supports('margin', '1rlh');

if (!hasRlh) {
  console.warn('rlh unsupported: falling back to precomputed rem rhythm');
  document.documentElement.classList.add('no-rlh');
}

This is rarely necessary for pure spacing (the CSS fallback above already handles it), but it is useful when a rhythm-aware layout tool needs to read the actual used line-height in pixels via getComputedStyle(el).lineHeight and compare it against an expected rlh-derived value, surfacing drift in development builds before it reaches production.

Verification

  • Baseline overlay. Add a temporary debug background of repeating grid lines one rhythm unit apart: body { background-image: repeating-linear-gradient(to bottom, transparent 0, transparent calc(1rlh - 1px), rgba(255,0,0,.3) 1rlh); }. Every line of text and every block edge should land on or between the red lines consistently. Drift means a margin is off-grid (often a stray lh where rlh was needed).
  • Computed values. Inspect a paragraph in DevTools and read the computed margin-bottom in pixels; it should equal root font-size × root line-height (27px in the example). Compare against an h2 margin — both should be whole multiples of that number.
  • Support fallback test. Toggle the @supports block off in DevTools and confirm the rem fallback keeps spacing visually identical, proving the degradation path works for older engines.
  • Cross-viewport check. If your type scale is fluid (driven by clamp()), resize the viewport across its full range and re-run the baseline overlay check at both extremes; because 1rlh recomputes with the live root line-height, the grid should still line up without needing a resize listener or a recalculated --rhythm value.

Common Pitfalls

  • Using lh for inter-block margins. An element's own lh differs from the root grid when its line-height differs; use rlh for spacing between blocks and reserve lh for spacing within an element.
  • Forgetting the fallback. Without an @supports guard, browsers before Chrome 109 / Safari 16.4 / Firefox 120 silently drop lh/rlh declarations, collapsing margins.
  • Mixing rhythm units with arbitrary px gaps. A single margin: 20px in the flow throws everything below it off the baseline grid; keep all vertical spacing in rhythm units.
  • Changing root line-height without updating the --rhythm fallback. The rem fallback is precomputed, so a new line-height must be reflected in --rhythm or the old browsers drift.
  • Relying on line-height: normal for rhythm-bearing elements. Because normal derives its value from font metrics rather than font-size, 1lh becomes font-dependent; set an explicit numeric line-height on any element that must stay on-grid.
  • Ignoring the interaction with your type scale. Large heading sizes from a modular scale need their margins quantised to whole rlh multiples or they punch holes in the rhythm.
  • Using lh inside a component driven by container queries when the page-level grid is intended. If the goal is to keep a widget aligned to the document's rhythm rather than its own local text size, rlh is still correct even inside a container-query context — swapping in lh there silently detaches the component from the shared grid.
Margin Drift: lh vs rlh on h2 Bar chart comparing the computed margin in pixels when an h2 heading's spacing is set with lh versus rlh, against the 27px rhythm unit. Margin Drift: lh vs rlh on h2 rlh margin 27px on-… lh margin 28.8px d… px margin
Using lh on a heading with its own line-height drifts off the shared 27px grid; rlh stays exact.

Frequently Asked Questions

What is the difference between lh and rlh?

1lh equals the computed line-height of the current element, so it changes per element when line-heights differ. 1rlh equals the line-height of the root element and is therefore constant across the whole document — making it the right unit for a single shared baseline grid. Use rlh for inter-block rhythm and lh for spacing that should track an element's own line-height.

Can I rely on lh and rlh in production today?

Yes for evergreen browsers — they shipped in Chrome 109, Safari 16.4, and Firefox 120 and are now Baseline. For audiences on older releases, pair them with a rem-based fallback inside an @supports (margin: 1rlh) query as shown, so unsupported browsers still get an on-grid layout.

Does lh work inside a <table> or on inline elements?

Yes — lh and rlh are valid anywhere a <length> is accepted, including on table cells, inline-level boxes (via padding, since margin has no effect on non-replaced inline elements), and CSS Grid track sizes (grid-template-rows: repeat(3, 1lh)). The subtlety with inline elements is that line-height on an inline box affects the height of its line box, not a visible box border, so using lh for padding on an inline element can look correct in isolation but interact unexpectedly with surrounding text — test with the baseline overlay before shipping it on inline content.

Does changing font-size with clamp() break a rhythm expressed in rlh?

No — that is exactly the case rlh is designed for. Because rlh re-resolves against the computed root line-height at every reflow, a fluid clamp()-based scale that changes font-size continuously across the viewport keeps its rlh-based margins in whole-unit lockstep automatically. A rem-only rhythm would need a corresponding clamp() on every margin to track the same curve; rlh gets that behaviour for free as long as line-height stays unitless.

Related