Create More Flexible CSS Layouts with the “:has()” Pseudo-Class
I’ve said it before and I’ll keep on saying it: CSS is awesome. The language’s capabilities have continued to expand in recent years, granting front-end developers a plethora of techniques for creating flexible web layouts and writing robust, efficient code. There’s flexbox and CSS grid, of course, but also CSS nesting, custom properties (aka, CSS variables), expanded color formats, container queries, and new viewport units.
And then there’s the :has() pseudo-class.
As its name suggests, :has() allows you to apply styles to an element based on whether or not it has certain related selectors. This usually means selectors that are contained within the element as descendants, but as we’ll see, that doesn’t have to be the case.
Consider this really simple example:
By default, all <p> tags are red. But if a <p> tag contains any <a> tags, then it’s blue. You can also do the inverse of this using the :not() pseudo-class:
Here, all <p> tags are red by default, but any <p> tags that don’t contain any <a> tags will be blue.
On the surface, :has() might seem a bit abstract — I still find myself second-guessing the syntax at times — but it can unlock a new level of flexibility for webpage layouts. In fact, I’m using :has() in several key aspects of Opus’ latest design, which I launched this past weekend. (And just in case you’re worried, browser support for :has() is pretty solid.)
Adaptive Banner Layout Sizes
Occasionally, I want to emphasize a particular entry and make it look and feel extra-special. To do so, I give it a “banner” layout which consists of a more prominent featured image. (Some examples include my favorite songs of 2023 and this entry about classic Choose Your Own Adventure books.)
The banner layout fills the entire screen, minus the height of the header (which contains the logo and navigation), creating a more visually immersive experience. This is done with the help of the calc() function:
.postB {
& #eHdrSolo {
min-block-size: calc(100svh - var(--hdr));
}
}When I add the .postB class to the <body> tag, then the size of the #eHdrSolo element — which represents the entry’s header and contains its title, summary, and some meta information — is set to 100svh minus the header’s size, which is defined by the --hdr custom property.
(Note: I’m using the min-block-size logical property instead of min-height to set the header’s height and organizing my code with nested CSS. I’m also sticking with my tendency to use compact and abbreviated CSS selectors. Finally, if you’re unfamiliar with the “svh” viewport unit, it solves the annoying “iOS toolbar overlap” issue. None of these are necessary to use :has() but are just unique aspects of my design’s CSS.)
This works 99% of the time. However, sometimes I also want to display an alert across the top of Opus when it’s time to promote something, like my February membership drive. (Which is coming soon. Hint, hint.) If I want the banner’s header to still fill up all of the available space, then I need to take the alert’s height into consideration when calculating the header’s size. This is where :has() comes into play.
.postB {
&:has(#a) {
& #eHdrSolo {
min-block-size: calc(100svh - calc(var(--hdr) + 4.4rem));
}
}
}If the .postB element has the #a element somewhere inside it — #a is the alert element’s ID — then the #eHdrSolo element’s size should be 100svh minus the sum of the header’s height plus 4.4rem, which is the alert’s height. (I usually try to avoid hard-coding values like that, but in this case, I deemed it acceptable since the alert isn’t a critical aspect of the design that’s always present.)
I could adjust the HTML to add new classes to the <body> and/or #eHdrSolo elements whenever I display the site alert, and use those to modify the entry header’s size. But thanks to :has(), I can just add the #a element and the banner layout automatically adapts to it — which strikes me as a much more elegant solution in keeping with CSS best practices.
Adaptive Entry Header Widths
I also use :has() to affect the entry headers on non-banner (i.e., standard) entries. By default, all of an entry’s content, header included, is contained within a wrapper element that’s set to a specific width. (I do like my centered column layouts.) But certain elements can break out of this wrapper via negative margins, such as floated images and, in certain circumstances, the #eHdrSolo element itself. For this particular iteration of Opus’ design, those circumstances are currently:
- The
#eHdrSoloelement is immediately followed by a full-width element, like a featured image. - The
#eHdrSoloelement is immediately followed by a.rvwInfoelement, indicating that the entry is a review.
You can compare and contrast the different header layouts on these posts:
- This entry contains a full-width featured image after the
#eHdrSoloelement. - This entry contains a
.rvwInfoelement after the#eHdrSoloelement. - This entry doesn’t contain either of those things after the
#eHdrSoloelement.
In the first two examples, the #eHdrSolo element breaks out of the wrapper. As with the banner layout, I could add additional classes to apply the negative margins necessary to adjust the width of the entry header. But :has() makes it a lot easier:
#eHdrSolo:has(+ .full, + .rvwInfo) {
margin-inline: -12rem;
}This shows just how flexible :has() can be. In this case, I’m not looking to see if #eHdrSolo contains certain elements, but rather, if those elements are adjacent to it using the + next-sibling combinator. Basically, if the #eHdrSolo element’s next sibling has the .full or .rvwInfo class, then it has negative inline margins and breaks out of the wrapper.
As with the “adaptive banner” example, I just render the HTML elements that I want and the CSS takes over, adjusting the layout as needed. It’s a beautiful example of what can be done when content and presentation are kept separate.
Highlighting Selected Table Rows
Update (6/19/2024): This has been moved into its own blog post.