zudo-css

Type to search...

to open search from anywhere

Prose Heading Spacing

CreatedApr 4, 2026UpdatedApr 5, 2026Takeshi Takatsudo

The Problem

Markdown-to-HTML converters produce flat sequences of block elements — paragraphs, lists, tables, and headings. A common spacing strategy is to give every block element the same bottom margin for uniform rhythm. Headings then get additional top margin so readers perceive section boundaries.

This works until headings appear consecutively:

<h2>Getting Started</h2>
<h3>Prerequisites</h3>
<h4>Node.js Version</h4>
<p>Install Node.js 20 or later.</p>

Each heading carries its own large top margin. When they stack, the accumulated whitespace creates a visual gap that looks broken — far larger than any other spacing on the page. The problem worsens inside flex or grid containers where margins do not collapse.

MDX compounds this because component wrappers and admonitions can interrupt the expected heading-to-content flow, making rigid margin rules fragile.

Consecutive Heading Problem — Accumulated Top Margins

The Solution

Decouple spacing from individual elements. Instead of each element owning its own margins, make spacing a property of the relationship between siblings. This is the “flow” pattern: a parent container rule controls the gap between adjacent children, and headings override that gap to create section separation.

The consecutive heading problem is then solved with a single override: when a heading follows another heading, tighten the spacing back down.

Three production-ready strategies exist, each with different trade-offs.

Code Examples

Strategy 1: Flow Utility with Heading Override

The flow utility uses the lobotomized owl selector (* + *) scoped to a container. Each heading sets a larger flow space. One rule tightens consecutive headings.

.prose > * + * {
  margin-block-start: var(--flow-space, 1em);
}

/* Headings create section separation */
.prose :where(h2) { --flow-space: 2.5em; }
.prose :where(h3) { --flow-space: 2em; }
.prose :where(h4) { --flow-space: 1.5em; }

/* Consecutive headings: tighten spacing */
.prose :where(h2, h3, h4, h5, h6) + :where(h2, h3, h4, h5, h6) {
  --flow-space: 0.5em;
}

.prose > :first-child {
  margin-block-start: 0;
}

This is the most robust approach. Spacing is owned by one margin direction only (margin-block-start), so it works identically in block flow, flex, and grid — no margin collapse dependency.

Flow Utility — Consecutive Headings Tightened

Strategy 2: Tailwind Typography Style

Tailwind Typography takes a different approach: headings own both top and bottom margins, and a wildcard rule zeroes the top margin of whatever follows a heading.

.prose h2 { margin-block: 2em 1em; }
.prose h3 { margin-block: 1.6em 0.6em; }
.prose h4 { margin-block: 1.5em 0.5em; }

.prose p,
.prose ul,
.prose ol,
.prose table,
.prose pre {
  margin-block-end: 1em;
}

/* Zero out next sibling's top margin after any heading */
.prose :is(h2, h3, h4) + * {
  margin-block-start: 0;
}

When h2 is followed by h3, the h3’s top margin becomes 0. Only the h2’s bottom margin (1em) remains as spacing. This handles consecutive headings without explicit pair rules.

The trade-off: this zeros the top margin of any element after a heading, not just other headings. That means the first paragraph after a heading is always tight to the heading — which is usually desirable, but removes the ability to fine-tune that gap independently.

Tailwind Typography Style — heading + * Resets Top Margin

Strategy 3: :has() — Target the Preceding Heading

The :has() selector enables parent-side control: reduce a heading’s bottom margin when another heading follows it.

:is(h2, h3, h4):has(+ :is(h2, h3, h4, h5, h6)) {
  margin-block-end: 0.25em;
}

This targets the first heading in a consecutive pair and reduces its bottom margin. The second heading keeps its normal top margin. Combined, the total gap shrinks to a reasonable size.

This approach is precise — it only adjusts spacing between heading pairs, leaving heading-to-content spacing untouched. Browser support is universal in modern browsers (Chrome 105+, Firefox 121+, Safari 15.4+).

The trade-off: this strategy still depends on both headings having their own margins (top and bottom). In flex or grid containers where margins do not collapse, the gap between consecutive headings remains larger than in block flow. Use the flow utility (Strategy 1) or the Tailwind style (Strategy 2) when the prose container may be a flex or grid column.

:has() — Reduce Bottom Margin When Heading Follows

The Flex/Grid Margin Collapse Trap

In normal block flow, adjacent vertical margins collapse — the larger margin wins. Many prose spacing strategies rely on this behavior implicitly. But flex and grid containers do not collapse margins. Both margins apply in full, doubling the gap.

This matters because markdown content containers are increasingly rendered inside flex or grid layouts (for sidebars, table of contents panels, or multi-column layouts).

/* This relies on margin collapse — breaks in flex/grid */
.prose-block h2 { margin-block: 2em 1em; }
.prose-block h3 { margin-block: 1.6em 0.6em; }
/* h2 bottom (1em) + h3 top (1.6em) = 1.6em in block flow (collapsed) */
/* h2 bottom (1em) + h3 top (1.6em) = 2.6em in flex/grid (stacked) */

/* This works everywhere — only one margin per gap */
.prose-flow > * + * { margin-block-start: var(--flow-space, 1em); }
Margin Collapse — Block Flow vs Flex Container

Combined Approach for Production

A production prose container can combine the flow utility with :has() for maximum control:

.prose > * + * {
  margin-block-start: var(--flow-space, 1em);
}

/* Section separation before headings */
.prose :where(h2) { --flow-space: 2.5em; }
.prose :where(h3) { --flow-space: 2em; }
.prose :where(h4) { --flow-space: 1.5em; }

/* Tighter gap between heading and its first content */
.prose :where(h2, h3, h4) + :where(p, ul, ol, table, pre) {
  --flow-space: 0.5em;
}

/* Consecutive headings: tight grouping */
.prose :where(h2, h3, h4, h5, h6) + :where(h2, h3, h4, h5, h6) {
  --flow-space: 0.5em;
}

/* Trim edges */
.prose > :first-child { margin-block-start: 0; }
.prose > :last-child { margin-block-end: 0; }

Fine-Tuning Specific Heading Pairs

The combined approach treats all consecutive heading pairs identically. When the design requires different spacing between specific pairs — for example, more room between h2 and h3 (a major section to subsection transition) than between h3 and h4 (a minor nesting step) — add explicit pair overrides on top:

/* Base: all consecutive headings get the same tight spacing */
.prose :where(h2, h3, h4, h5, h6) + :where(h2, h3, h4, h5, h6) {
  --flow-space: 0.5em;
}

/* Fine-tune: h2 → h3 gets slightly more room */
.prose :where(h2) + :where(h3) {
  --flow-space: 0.75em;
}

/* Fine-tune: h3 → h4 stays tighter */
.prose :where(h3) + :where(h4) {
  --flow-space: 0.4em;
}

The :where() wrapper keeps specificity flat, so declaration order controls which rule wins. Place pair-specific overrides after the catch-all consecutive heading rule.

Pair-Specific Overrides — Uniform vs Tuned Consecutive Headings

This is an optional refinement layer. Most prose layouts work well with uniform consecutive heading spacing. Add pair-specific overrides only when the design explicitly calls for visible hierarchy distinction between heading transitions.

Quick Reference

ScenarioStrategyKey Selector
Uniform sibling spacingFlow utility.prose > * + * { margin-block-start: ... }
Heading section separationOverride --flow-space.prose :where(h2) { --flow-space: 2.5em }
Consecutive heading tighteningAdjacent heading selector:where(h2,h3,h4) + :where(h2,h3,h4,h5,h6)
Zero next-sibling after headingTailwind style:is(h2,h3,h4) + * { margin-block-start: 0 }
Reduce preceding heading margin:has() selector:is(h2,h3):has(+ :is(h3,h4,h5)) { margin-block-end: ... }
Pair-specific heading tuningExplicit pair overrides:where(h2) + :where(h3) { --flow-space: 0.75em }
Flex/grid safe spacingSingle-direction marginsUse margin-block-start only, never both directions

Common AI Mistakes

  • Setting both margin-top and margin-bottom on headings without accounting for flex/grid contexts. Margins collapse in block flow but stack in flex/grid. The same CSS produces different spacing depending on the parent container.
  • Adding large margin-top to every heading without a consecutive heading override. This causes h2 + h3 + h4 stacks to produce enormous gaps.
  • Relying on margin collapse as a spacing strategy. Margin collapse is implicit behavior that breaks when the layout context changes. Explicit single-direction margins are more predictable. Never design spacing that depends on margin collapse. It is uncontrollable — margins collapse in block flow but not in flex, grid, or beside floats. When an element has both margin-bottom and the next element has margin-top, the resulting gap changes depending on the parent layout context. Use only margin-block-start via the flow utility and padding for internal spacing within components. If a component needs bottom spacing for visual weight (e.g., decorative borders on headings), use padding-bottom, not margin-bottom, so it does not interact with the flow utility’s margin-block-start on the next element.
  • Using different margin values for the same element type. One paragraph gets margin-bottom: 16px, another gets 20px. Uniform spacing from a flow utility prevents this.
  • Not zeroing margin-block-start on the first child. The first element in a prose container should sit flush against the container edge. Without :first-child { margin-block-start: 0 }, the flow utility adds unwanted top space.
  • Forgetting to tighten heading-to-content spacing. Headings need less space below them than above — the heading should visually “belong” to the content that follows it, not float equidistant between sections.

When to Use

Flow Utility with Heading Override

The default choice for markdown/MDX prose containers. Works in any layout context. Scales to any number of heading levels. Use this when building documentation sites, blog post templates, or any content-heavy layout.

Tailwind Typography Style

Use when adopting Tailwind’s @tailwindcss/typography plugin or building a similar opinionated prose system. The heading + * wildcard is simple but removes fine-grained control over heading-to-content gaps.

:has() Selector Approach

Use as a targeted fix on top of existing heading margins in block flow containers. Particularly useful when retrofitting spacing to an existing stylesheet without restructuring the margin strategy. Does not require adopting the flow utility pattern. Avoid in flex or grid containers where margins do not collapse — the gap will remain larger than expected.

Explicit Pair Rules

Use only when the content structure is strictly controlled (e.g., a CMS that limits heading nesting) and the number of heading combinations is small. Does not scale to arbitrary markdown content.

Tailwind CSS

The complex adjacent sibling selectors (:where(h2,h3,h4,h5,h6) + :where(h2,h3,h4,h5,h6)) cannot be expressed with Tailwind utility classes alone. Three approaches exist:

Use @tailwindcss/typography

The @tailwindcss/typography plugin already handles consecutive headings. Apply the prose class and heading spacing is managed automatically — including the h2 + *, h3 + *, h4 + * reset pattern described in Strategy 2.

<article class="prose">
  <h2>Getting Started</h2>
  <h3>Prerequisites</h3>
  <p>Typography plugin handles the spacing.</p>
</article>

Write Custom CSS in Tailwind v4

Tailwind v4’s CSS-first configuration allows writing selectors directly in your stylesheet. Add heading spacing rules in a @layer:

@layer components {
  .prose > * + * {
    margin-block-start: var(--flow-space, 1em);
  }

  .prose :where(h2) { --flow-space: 2.5em; }
  .prose :where(h3) { --flow-space: 2em; }
  .prose :where(h4) { --flow-space: 1.5em; }

  .prose :where(h2, h3, h4, h5, h6) + :where(h2, h3, h4, h5, h6) {
    --flow-space: 0.5em;
  }

  .prose > :first-child { margin-block-start: 0; }
}

This is the recommended approach when not using @tailwindcss/typography — full selector control with Tailwind’s layer system.

Tailwind Utilities for Simple Cases

For simple prose layouts where only one level of heading override is needed, Tailwind’s arbitrary variant syntax works:

<article class="[&>*+*]:mt-4 [&>h2]:mt-10 [&>h3]:mt-8">
  <h2>Section</h2>
  <h3>Subsection</h3>
  <p>Content</p>
</article>

This does not handle the consecutive heading problem. For that, the arbitrary variant would be:

<article class="[&>:is(h2,h3,h4)+:is(h2,h3,h4,h5,h6)]:mt-2">
  ...
</article>

This works but is difficult to read and maintain. Prefer the CSS @layer approach or @tailwindcss/typography over long arbitrary variants.

Tailwind: @tailwindcss/typography Handles Consecutive Headings

References

Revision History