While watching this Kevin Powell video (starting at 4:15), I found out about a really cool CSS pattern for styling prose (as in article text, just like what you're reading right now). More specifically, it's about defining the space between items. It's based on Tailwind's "prose" styling and uses the lobotomized owl selector (* + *).
The Lobotomized Owl 🦉
The name was introduced by Heydon Pickering back in 2014, and it's an awesome name for a very straightforward selector.
The lobotomized owl (* + *) will select any element that is not the first, which makes it basically the same as *:not(:first-child).
Let's assume we have a blog post that has its content rendered like this:
Lorem ipsum dolor
Ad officia consequat pariatur.
Mollit eu anim qui qui et labore sit tempor sit.
Fugiat laboris esse mollit
Ad ipsum dolore laboris nisi
How would you control the spacing between each piece of the text? You could either set a flat spacing (by setting .prose a flexbox and giving it a gap, among other ways), or target any child and give it a predefined margin-top depending on what type it is (so it's larger on headings, for example).
An extremely elegant way of doing that, though, is by using em, which will make the margin vary by the font-size of an element!
/ The default value for 1rem is 16px / h2 { font-size: 2rem; / 32px / } p { font-size: 1rem; / 16px / } .prose > * + * { / Since we're using em, it will scale with the font size of the current element. For the h2, our margin would be 32px * 1.4 = 44.8px For the paragraph, 16px * 1.4 = 22.4px / margin-top: 1.4em; }
The CSS above sets a margin-top to any element (except the first) based on the text size of that element (because of em). So on a <p> with 16px font size, margin-top would be 16px * 1.4 = 22.4px. On an h2 with 32px font size, it'd be 44.8px.
A refresher on em vs rem
It's not uncommon to get these two units confused — but it's important to understand their differences or you might end up with some pretty nasty debugging challenges.
Both scale off of font-size, what changes is what font-size they use. rem will always refer to the font-size of the body element, which defaults to 16px if not set. This means that it is a very predictable unit. 1rem will always be the same value no matter which element uses it.
em, on the other hand, scales off of the font-size of the current element. This means that 1em inside a heading tag can be very different from 1em in a footnotes section.
Both have their uses and can coexist, as we can see right now.
This sets a nice default spacing for all possible elements inside of your .prose div. But you can also use the same idea to handle special cases. Let's say you always want your images to have bigger margin around them. We can target them specifically:
.prose > * + img { / I am not setting a font-size for imgs, which means 1em will default to 1rem, which defaults to 16px / margin-top: 2.8em; margin-bottom: 2.8em; }
I am adding a margin-bottom for that one as well so that the spacing remains consistent. Since margins don't stack in CSS, this means that even if the element below the image has a margin-top, it will merge with the image's margin-bottom. In practice, only the biggest margin counts.
A really good practice when doing stuff like this is to use CSS variables with a fallback value, so you can easily override the default value whenever you need.
Consider your .prose styling has a default spacing of 1.4em, as we've established above. But now you want to use that same styling in a card component. Since on that card you have less space to work with, it'd be ideal if the spacing was a bit tighter than normal. You could do something like:
.prose > * + * { / Use the --prose-spacing variable value if it exists, otherwise use 1.4em / margin-top: var(--prose-spacing, 1.4em); } .prose > * + img { / If we make the image spacing also based on --prose-spacing, it will scale on the card too. / margin-top: calc(var(--prose-spacing, 1.4em) * 2); margin-bottom: calc(var(--prose-spacing, 1.4em) * 2); } .card .prose { / When inside the card, prose spacing is a bit tighter / --prose-spacing: 1.2em; }
If the decimal values (like margin being 22.4px) bother you, you can use the CSS round function to make sure it maps to a predictable, integer value.
If you're interested, I highly recommend reading this amazing article by Ahmad Shadeed about the round function, but the gist of it is that you can do something like:
/ The default value for 1rem is 16px / h2 { font-size: 2rem; / 32px / } p { font-size: 1rem; / 16px / } .prose > * + * { / h2's margin would be 44.8, becomes 44px p's margin would be 22.4, becomes 22px The "2px" second parameter tells it to round to the nearest multiple of 2px. If I used "1px" instead, it 44.8 would round up to 45px. / margin-top: round(var(--prose-spacing, 1.4em), 2px); }
This kind of elegant, simple approach is one of my favorite things about CSS and web development in general. We can easily over-engineer things, but nothing beats something like this.