Vertical Spacing in CSS Doesn’t Require :has
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 bothmargin-top
andmargin-bottom
:
- We start by setting our default spacing to elements with
margin-bottom
.- Next, we space out our “sections” using
margin-top
— i.e. very big space above each heading- Then we override those big
margin-top
s 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’):
Sassh1
& + *
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 CSSgap
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 settingmargin-bottom
for spacing long-form content.
Instead, they present their approach with :has
:
CSS// 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:
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.