The other day, CSS-Tricks published Solved With :has(): Vertical Spacing in Long-Form Text, which explains how to maintain consistent vertical spacing across a document using the fairly new and very useful :has selector. The requirements are reasonable and common:

Consider this desired behavior:

  • The first and last elements in a block of long-form text shouldn’t have any extra space above or below (respectively). This is so that other, non-typographic elements are still placed predictably around the long-form content.
  • Sections within the long-form content should have a nice big space between them. A “section” being a heading and all the following content that belongs to that heading. In practice, this means having a nice big space before a heading… but not if that heading is immediately preceded by another heading!

[…]

In the pre-:has() world, we haven’t had a way to select an element based on what follows it. Therefore, the traditional approach to spacing typographic elements involves using a mix of both margin-top and margin-bottom:

  1. We start by setting our default spacing to elements with margin-bottom.
  2. Next, we space out our “sections” using margin-top — i.e. very big space above each heading
  3. Then we override those big margin-tops when a heading is followed immediately by another heading using the adjacent sibling selector (e.g. h2 + h3).

The mention of margin-bottom perplexes me. Here’s a sample of how I maintain uniform spacing on this site as far as possible (in Sass, but I trust the core idea is clear regardless, remembering that & stands in for ‘the current selector’):

h1
  & + *
    margin-top: calc(1.5 * var(--spacing-unit))

h2
  * + &
    margin-top: calc(1 * var(--spacing-unit))

  & + *
    margin-top: calc(var(--spacing-unit) / 8)

  pre + &
    margin-top: calc(1.5 * var(--spacing-unit))

h3
  * + &
    margin-top: calc(var(--spacing-unit) / 2)

p + p, p + ins, p + del, del + p, ins + p, pre + ins, pre + del, ul + p, ol + p, ins + del, del + ins, ins + ins, del + del
  margin-top: 1em

ul, ol
  margin-top: 1em

  h2 + &
    margin-top: calc(var(--spacing-unit) / 4)

There are inconsistencies in how I phrase it and necessary irregularities in the amount of spacing, but the unifying thread is that as long as I exclusively use margin-top, I can control vertical spacing entirely through the sibling selector (e.g. & + *). To quote the article, however:

Now, I don’t know about you, but I’ve always felt it’s better to use a single margin direction when spacing things out, generally favoring margin-bottom (that’s assuming the CSS gap property isn’t feasible, which it is not in this case). Whether this is a big deal, or even true, I’ll let you decide. But personally, I’d rather be setting margin-bottom for spacing long-form content.

Instead, they present their approach with :has:

// Non-heading elements should have a big gap after them if followed by a heading.
// Star selector means that a bunch of stuff should "just work",
// e.g. if you insert images or other elements between text nodes.
// But you could change the `*` to `:is(h1,h2,h3,h4,h5,h6)` if you don't want that.
*:has(+ h2) {
  margin-bottom: var(--space-l2-lg);
}
*:has(+ h3) {
  margin-bottom: var(--space-l3-lg);
}
*:has(+ h4) {
  margin-bottom: var(--space-l4-lg);
}
*:has(+ h5) {
  margin-bottom: var(--space-l5-lg);
}
*:has(+ h6) {
  margin-bottom: var(--space-l6-lg);
}

// Headings followed immediately by a heading of a level down
// should have only a small gap below.
// Using :where() for consistent specificity.
:where(h1):has(+ h2) {
  margin-bottom: var(--space-l1-sm);
}
:where(h2):has(+ h3) {
  margin-bottom: var(--space-l2-sm);
}
:where(h3):has(+ h4) {
  margin-bottom: var(--space-l3-sm);
}
:where(h4):has(+ h5) {
  margin-bottom: var(--space-l4-sm);
}
:where(h5):has(+ h6) {
  margin-bottom: var(--space-l5-sm);
}

// List item spacing.
:where(li):has(+ li) {
  margin-bottom: var(--space-list-items);
}

// Nested list spacing.
// This is the only place we need to use margin-top :'(
:where(li) :is(ul, ol) {
  margin-top: var(--space-list-items);
}

// Remove the bottom margin on the last prose element
// (i.e. if it has no next sibling).
// Could there be an argument to apply this to ALL elements (*) with no next sibling?
:is(p, ul, ol, h1, h2, h3, h4, h5, h6):not(:has(+ *)) {
  margin-bottom: 0;
}

There are many distractions here and in the rest of the article: :is & :where, wrapper classes, collapsing margins, the cascade. None of that is relevant to this discussion, fortunately. What matters is this:

Always check browser support. At time of writing, Firefox only supports :has() behind an experimental flag.

To summarize, the author chooses margin-bottom over margin-top out of personal preference and solves the consequent problem by adopting a more complex selector that wasn’t supported anywhere until last year. I find this disappointing. The styles don’t degrade gracefully:

A browser that doesn’t support the selector won’t apply any of the rules

No matter how few visitors that might affect, why abandon them when a simpler and well-supported solution is already in widespread use?

This is to some extent an elaboration on my brusque comment. :has will be invaluable in many scenarios, but this isn’t one of them. Many thanks to my captive kind friends who agreed to read an early draft, and especially to Thomas of hello, yes for providing the link to the original A List Apart article from ten years ago about the ‘lobotomized owl’ selector.