Spacing Long-Form Text: A New Take on an Old Problem
Managing vertical spacing in long-form text is one of those deceptively difficult problems on content-heavy sites. CMS-driven pages, where editors can drop in headings, paragraphs and lists through a WYSIWYG editor, quickly expose the limits of a naive approach like giving every element a simple top and bottom margin.
That’s why tools like Tailwind’s Typography plugin and Stack Overflow’s Prose exist. They solve not just spacing, but a host of typographic concerns. But as CSS’s :has() selector becomes more widely available — Firefox currently supports it behind the layout.css.has-selector.enabled flag in about:config — we can rethink the whole approach to vertical rhythm in rich text.
Why Simple Margins Aren’t Enough
At first glance, the problem seems trivial: add margins to p, h2, ul, and you’re done. But there are two requirements that complicate things:
- In a block of long-form content, the first and last elements must not have extra space above or below, so surrounding page components remain predictably positioned.
- There should be a substantial gap before each heading — but not when that heading is immediately preceded by another heading. That case flips the rule, demanding tight spacing instead of a big break.
These edge cases are common enough that even CSS-Tricks itself stumbles on them; screenshots of spacing issues from other articles there show exactly the kind of awkward gaps and closings these rules are meant to prevent.
The Traditional Workaround and Its Costs
The standard solution has been to wrap long-form content in a container — typically named something like .rich-text or, in Tailwind sites, .prose — and then apply margins to all typographic children from inside that wrapper. This works, but it comes with structural trade-offs:
- Rigid markup. The required wrapper class must be present in every place long-form content appears, whether that content comes from a CMS or is hard-coded. Forgetting it in one template yields inconsistent spacing.
- Direct-child requirements. Trimming margins from the first and last elements demands selectors like
.rich-text > *:first-child, not just to manage the spacing itself but to avoid accidentally styling the first item inside aulorol. - Mixed margin directions. Without a way to select an element based on what follows it, the traditional approach alternates between
margin-bottomfor default spacing,margin-topfor section breaks before headings, and override rules likeh2 + h3to collapse those large top margins when headings stack. This mixing — and the overriding — is something many developers prefer to avoid.
Collapsing Margins: Convenience or Confusion?
The mix of top and bottom margins works because of CSS’s margin collapsing behavior: stacked vertical margins collapse to the larger of the two rather than summing. But relying on that behavior has its own risks.
Collapsing margins are yet another quirk to keep in mind — and they break in predictable but surprising ways. If the wrapper is changed to a flex layout with flex-direction: column, margin collapsing stops entirely, and spacing shifts instantly. A single-direction margin approach would behave identically regardless of the wrapper’s display type.
Collapsing margins exist by design and have their uses, but they’re subtle. For junior developers, they can feel less like a convenience and more like a logic trap waiting to spring; changing one unrelated layout property can cascade into visibly altered spacing.
The :has() Approach
With :has(), the wrapper class becomes unnecessary. The core insight is that a heading’s spacing depends on whether another heading precedes or follows it — exactly the kind of conditional logic :has() enables. The approach targets typographic elements directly, using :where() to keep specificity at 0,0,1:
- All elements get consistent
margin-bottomspacing, with no reliance on margin collapsing. - The gap before a heading is handled by adjusting that heading’s top margin — but only when it is not preceded by another heading.
- Similarly,
:has()can detect an element that is followed by a heading and trim its bottom margin accordingly, ensuring elements tightly bound to a following heading don’t drift apart.
This eliminates multi-directional margins and the need to override previous rules. The selector list is built for a narrow set of core elements — headings, paragraphs, lists — but is trivially extensible to cover blockquote, images or other elements an authoring system might produce.
Practical Caveats
This technique is not without its boundaries. Before using it, keep these points in mind:
- Browser support: At the time of writing, Firefox only supports
:has()experimentally behind thelayout.css.has-selector.enabledflag. - Element coverage: The example here omits non-typographic elements like
<img>and<blockquote>. That choice suits sites that reserve those for separate CMS component blocks. Extending the selector list is straightforward if your content model includes them. - Heading semantics: The CSS does not handle stacked headings of the same level (e.g.,
h2 + h2) or skipped levels. Both are considered misuse of heading structure, with potential accessibility implications under WCAG 1.3.1 (Info and Relationships). - Visual tuning: The spacing values in the demonstration were chosen by eye. Real design systems likely need more polished values.
Styling the Right Layer
The removal of a wrapper class has architectural implications when fitting this into a CSS methodology like ITCSS. Without a class boundary to scope into, the selectors now apply globally to typographic elements — so it feels most natural to treat these rules as element-level styles, placed early in the cascade before class-based overrides. Keeping specificity low with :where() preserves the ability to override individual styles when needed, without marking this particular block as a source of cascade conflicts.
This newer solution still involves a set of moderately complex selectors; spacing for long-form content remains a tougher problem than it initially looks. But by moving the conditions into the selector itself, the layout logic becomes less fragile and the HTML stays clean — two qualities that will often outweigh a little extra selector complexity.



