Solved With :has(): Vertical Spacing in Long-Form Text | CSS-Tricks

If you’ve ever worked on sites with lots of long-form text—especially CMS sites where people can type strips of text into a WYSIWYG editor—you’ve probably had to write CSS to manage the vertical spacing between different typographical elements, such as headings, paragraphs, lists and so on.

Getting this right is surprisingly difficult. And that’s one of the reasons things like the Tailwind Typography plugin and Stack Overflow’s Prose exist—though these handle much more than just vertical spacing.

Firefox supports :has() behind layout.css.has-selector.enabled flag in about:config in writing.

What makes typographic vertical spacing complicated?

I guess it should just be as simple as saying that each element— p, h2, uletc. — have a certain amount of top and/or bottom margin… right? Unfortunately, that is not the case. Consider this desired behavior:

  • The first and last elements of a block of long text should not 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 content should have a nice big space between them. A “section” is a heading and all the following content belonging to that heading. In practice, this means having a nice big space before a heading… but does not if that heading is immediately preceded by another heading!
Example of a heading 3 after a section and another after a heading 2.
We want more space above heading 3 when it follows a typographic element, like a paragraph, but less space when it immediately follows another heading.

Look no further than right here at CSS-Tricks to see where this can come in handy. Here are a few screenshots of spaces I took from another article.

A heading 2 element directly above a heading 3.
The vertical distance between heading 2 and heading 3
A Heading 3 element directly after a Paragraph element.
The vertical space between heading 3 and a paragraph

The traditional solution

The typical solution I’ve seen involves putting any long-form content in a wrapper div (or a semantic tag if applicable). My go-to class name has been .rich-text, which I think I’m using as a hangover from older versions of Wagtail CMS, which would add this class automatically when rendering WYSIWYG content. Downwind typography uses a .prose class (plus some modifier classes).

Then we add CSS to select all typographic elements in this wrapper and add vertical margins. Note of course the special behavior mentioned above that has to do with stacked headers and the first/last element.

The traditional solution sounds reasonable… what’s the problem? I think there are a couple…

Rigid structure

Having to add a wrapper class like .rich-text all the right places means baking in a certain structure to your HTML code. It’s sometimes necessary, but it feels like it shouldn’t be in this particular case. It can also be easy to forget to do this everywhere you go, especially if you’re going to use it for a mix of CMS and hardcoded content.

The HTML structure becomes even more rigid when you want to be able to trim the top and bottom margins of the first and last elements respectively, because they must be immediate children of the wrapper element, e.g. .rich-text > *:first-child. To > is important – we don’t want to accidentally select the first list item in each ul or ol with this selector.

Blend Margin Properties

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

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

Now, I don’t know about you, but I’ve always felt that it’s better to use a single margin direction when separating things, and generally favor margin-bottom (it requires CSS gap property is not possible, which it is not in this case). Whether this is a big deal, or even true, I’ll let you decide. But personally I would rather put margin-bottom for spacing between long-form content.

Collapsing margins

Due to collapsing margins, this mixture of top and bottom margins is not a major problem in itself. Only the larger of two stacked margins takes effect, not the sum of both margins. But… well… I don’t really like collapsing margins.

Collapsing margins are another thing to watch out for. It can be confusing for junior developers who are not up to speed with that CSS markup. The spacing will change completely (ie stop collapsing) if you were to change the wrapper to one flex layout with flex-direction: column for example, which is something that wouldn’t happen if you set your vertical margins in a single direction.

I more or less know how collapsing margins work and I know they are there by design. I also know that they have sometimes made my life easier. But they have also made it more difficult at other times. I just think they’re a little weird and I’d generally prefer to avoid trusting them.

That :has() solution

And here is my attempt to solve these problems with :has().

To summarize the improvements, this aims to:

  • No packaging class is required.
  • We work with a consistent margin direction.
  • Collapsing margins are avoided (which may or may not be an improvement, depending on your stance).
  • There is no setting styles and then immediately overriding them.

Remarks and reservations regarding :has() solution

  • Always check browser support. At the time of writing, Firefox only supports :has() behind an experimental flag.
  • My solution does not contain all possible typographical elements. For example, there is none <blockquote> in my demo. However, the voter list is easy enough to expand.
  • My solution also doesn’t handle non-typographic elements that may be present in your special long-form text blocks, e.g <img>. That’s because, for the sites I work on, we tend to lock down WYSIWYG as much as possible to core text nodes, such as headers, paragraphs, and lists. Everything else – e.g. quotes, images, tables, etc. – is a separate CMS component block, and the blocks themselves are separated from each other when rendered on a page. But again, the voter list can be expanded.
  • I have only included h1 for the sake of completeness. I wouldn’t normally allow a CMS user to add one h1 via WYSIWYG as the page title would be baked into the page template somewhere instead of being entered into the CMS page editor.
  • I do not take into account a heading immediately followed by heading at the same level (h2 + h2). This would mean that the first header would not “own” any content, which seems like abuse of headers (and correct me if I’m wrong, but that power violates WCAG 1.3.1 Info and Relations). I also don’t account for skipped header levels that are invalid.
  • I am in no way rejecting the existing approaches I mentioned. If and when I build another Tailwind site, I will be using the excellent typography plugin, without a doubt!
  • I’m not a designer. I came up with these distance values ​​by eyeballing it. You could (and should) probably use better values.

Specificity and project structure

I was going to write a whole big thing here about how the traditional method and the new one :has() way of doing it can fit in ITCSS method… But now that we have :where() (the zero specificity selector) you can pretty much choose your preferred level of specificity for any selector now.

That being said, the fact that we are no longer dealing with a wrapper — .prose, .rich-textetc. — to me it feels like this should live in the “elements” layer ie. before you start dealing with class-level specificity. I have used :where() in my examples to keep the specificity consistent. All the voters in both of my examples have a specificity score of 0,0,1 (except for the bare-bones reset).

Concludes

So there you have it, a bleeding edge solution to a very boring problem! This newer approach is still not what I would call “simple” CSS – as I said at the beginning, it’s a more complex subject than it might first appear. But aside from having a few slightly complex selectors, I think the new approach makes more sense overall, and the less rigid HTML structure seems very appealing.

If you end up using this, or something similar, I’d love to know how it works for you. And if you can think of ways to improve it, I’d love to hear them too!

William

Leave a Reply