Create More Flexible CSS Layouts with the “:has()” Pseudo-Class

The “:has()” pseudo-class can solve some unique design problems, resulting in more adaptive, even reactive website designs.

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 #eHdrSolo element is immediately followed by a full-width element, like a featured image.
  • The #eHdrSolo element is immediately followed by a .rvwInfo element, 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 #eHdrSolo element.
  • This entry contains a .rvwInfo element after the #eHdrSolo element.
  • This entry doesn’t contain either of those things after the #eHdrSolo element.

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

So far, I’ve shared examples of the :has() pseudo-class’ utility that are taken directly from Opus’ latest design. This final example, however, presents something from a different context, one that’s often encountered inside web applications like content management systems.

Web applications often display a list of entries inside a <table>, with each row representing a single database entry, be it a blog post, a user, an order, or something else entirely. Oftentimes, these entry tables allow users to check/select multiple rows in order to perform batch operations (e.g., deleting multiple entries). And as a nice bit of user feedback, it’s pretty standard to highlight each row as it’s checked or selected.

In the past, you’d have to use JavaScript for this row highlighting, but :has() makes it possible with just a few lines of CSS.

The above CSS looks to see if a <tr> contains any checked .selectRow elements, which, in this scenario, are checkbox <input>s. If it does, then a background color is applied to its child <td> elements using the > child combinator. While JavaScript is still necessary for the “check all rows” checkbox to function, :has() can theoretically reduce the amount of JavaScript you need for the table’s interactivity.

Now, if one were inclined to be pedantic — which is often the case with us developers — one could argue that this unnecessarily blurs the line between CSS and JavaScript. Traditionally, JavaScript has been about controlling and enhancing how elements behave. Depending on your view, handling checkbox interactivity could fall into the “behavior” category and thus, should be handled by JavaScript.

I’m not sure I agree with that; my preference is to follow the “Rule of Least Power” and do as much as possible with CSS before turning to JavaScript. In any case, this still shows just how expansive and robust CSS has become and, in the process, begun challenging preconceived notions of how it can — and should — be used.


Along with the :is() and :where() pseudo-classes, the :has() pseudo-class might not seem as glamorous or immediately apparent as, say, CSS grid, custom properties, and CSS nesting. But I hope the above examples give you some idea of just how cool it really is, and how it can improve the quality of our websites’ layouts with just a modicum of code.

Enjoy reading Opus? Want to support my writing? Become a subscriber for just $5/month or $50/year.
Subscribe Today
Return to the Opus homepage