Front-end education for the real world. Since 2018.





A revisit of the Every Layout sidebar with :has() and selector performance

Heydon Pickering

Topic: CSS

Every Layout’s Sidebar exemplifies a quantum CSS layout, perhaps better than any other layout we offer.

It is neither a two column layout, comprising elements with fixed and fluid widths, nor a single column layout comprising two vertically stacked elements. It’s both, and neither. It adapts, automatically, to context — with no @media or @container queries required.

The Sidebar is versatile too. It can set out the vertical navigation and main content of your page, and it can form intrinsically responsive media blocks, wherein the sidebar is an image.

A page layout with a sidebar on the right. In the main content area, there are two media components on top of each other. Each has an image to its left.

But, even as I’m writing about it now, the name Sidebar bugs me. The layout, as specified, is not actually a sidebar. A sidebar is just a (relatively) narrow element. The Sidebar is a layout that includes a sidebar. Necessarily, there are three elements in total: a container, a designated sidebar element, and an accompanying element to take up the remaining space.

Consequently, the code uses the term with-sidebar to define the container. Grammatically awkward.

Code language
css

.with-sidebar {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sidebar-gap, 1rem);
}

What if this class wasn’t needed? Ideally, I’d like to place a class="sidebar" element anywhere on the page and the supporting meta-layout would assemble itself from the elements around it. This is now possible using the :has() pseudo-class:

Code language
css

:has(> .sidebar) {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem;
}

Targeting the non-sidebar element is also eminently possible, using a combination of :has() and :not(). The complete code for the reusable layout would look something like this:

Code language
css

:has(> .sidebar) {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sidebar-gap, 1rem);
}

.sidebar {
  /* ↓ The width when the sidebar *is* a sidebar */
  flex-basis: var(--sidebar-size, 20rem);
  flex-grow: 1;
}

:has(> .sidebar) > :not(.sidebar) {
  /* ↓ Grow from nothing */
  flex-basis: 0;
  flex-grow: 999;
  /* ↓ Wrap when the elements are of equal width */
  min-inline-size: var(--sidebar-wrap-at, 50%);
}

Demo

Note the custom properties with default values. You can still override these defaults (as I described in Dynamic CSS Components Without JavaScript) at the container level:

Code language
html

<div style="--sidebar-gap: 3rem">
	<nav class="sidebar">...</nav>
	<main>...</main>
</div>

Using style with custom properties can get a little verbose, but it acts like a component prop, enabling parametric layout. By instantiating generic layouts, like Sidebar, with configuration, you can significantly reduce your CSS.

Layout testingpermalink

Despite doing away with some attribution, the Sidebar layout still requires a strict markup structure. We can test and report on incorrect structure directly in CSS, as I wrote in Testing HTML With Modern CSS. In development, you could include a stylesheet containing the following test:

Code language
css

:root {
	--error-outline: 0.25rem solid red;
}

:has(> .sidebar) > :only-child,
:has(> .sidebar) > :nth-child(3) {
  outline: var(--error-outline);
	--error: 'Sidebar layouts must include exactly two child elements.';
}

The error outline style, saved on the :root for reuse across tests, provides a visual regression.

The third of three elements shows a thick red outline.

When inspecting the outlined element in dev tools, the error message is available in a custom property.

View of Firefox developer tools’ inspector. The 3rd child element is matched and the error custom property reads Sidebar layouts must include exactly two child elements.

If you prefer, you can use :not() and turn this into a one-liner:

Code language
css

:has(> .sidebar) > :not(:nth-child(1), :nth-child(2)) {
	outline: var(--error-outline);
	--error: 'Sidebar layouts must include exactly two child elements.';
}

This is not to say you’d necessarily want to preclude multiple sidebars. Both of the following work insomuch as wrapping occurs without unintended white space appearing, but it depends what kind of layout configurations you’re happy with, at different wrapping points.

Code language
html

<div>
	<nav class="sidebar">...</nav>
	<main>...</main>
	<aside class="sidebar">...</aside>
</div>

<div>
	<nav class="sidebar">...</nav>
	<aside class="sidebar">...</aside>
	<main></main>
</div>

With two sidebars of equal size on the left, here’s how wrapping occurs with a 50% threshold (--sidebar-wrap-at):

Four versions of the same layout. The first is inline with two narrow sidebars on the left. The second has a row of two elements followed by a row of just one element. The third starts with one element on one row, followed by a row of two elements, with the second element slightly narrower than the first. The fourth is three elements in a column (one element per row).

It’s the third configuration that is perhaps least desirable, since the larger (main) content has become something of a sidebar itself. It’s in this case that you might want to intervene with a @container query.

Now that @container queries are available, the object is not to use them everywhere but to apply them only where needed. Normal Flexbox wrapping behavior should still do most of the work.

Hypothetically, let’s say the critical point is 400px.

Code language
css

:has(> .sidebar) {
	container-type: inline-size;
}

@container (max-inline-size: 400px) {
	:has(> .sidebar) > .sidebar {
		inline-size: 100cqw;
	}
}

Ideally, we’d be able to store the breakpoint value in a custom property and maintain parametric reusability. The ability to use custom properties in dimensional @container queries has been agreed in principle, but it is not yet available. In the future, you should be able to support calibration like so:

Code language
css

:has(> .sidebar) {
	container-type: inline-size;
	--single-column-break: 400px;
}

@container (max-inline-size: var(--single-column-break) {
	:has(> .sidebar) > .sidebar {
		inline-size: 100cqw; /* full width, using container query units */
	}
}

You can, of course, nest Sidebars. Primitives like Sidebar and its companions are designed to be composed into more complex layouts.

Code language
css

<div style="--sidebar-size: 8rem">
  <div class="sidebar">
    <div style="--sidebar-size: 3.5rem">
      <div class="sidebar"></div>
      <div></div>
    </div>
  </div>
  <div></div>
</div>

This is more likely to result in expected and predictable wrapping behavior.

A layout with two sidebars to the left transforms into a single-sidebar layout followed by a full-width element below it. The third layout is a single column of three elements (one per row).

Selector performancepermalink

Selector performance is one of those things you only need to worry about very infrequently, like terrestrial shark attacks. If you are repeatedly and frequently causing style recalculations, over a massively overpopulated DOM — made up of thousands of nodes — it is something you may want to look into, after you’ve optimised literally everything else. To put it another way: it won’t be your CSS selectors, it will probably be a JavaScript memory leak and/or a bloated DOM.

However, an unconstrained use of the :has() pseudo-class, as in :has(> .sidebar), is assigned a little turtle icon in the Firefox dev tools inspector.

The turtle icon, indicating slow, against the selector in Firefox dev tools

The turtle icon, indicating slow, against the selector in Firefox dev tools

Given that :has() is the manifestation of the fabled “parent selector” — deemed a technical impossibility for years, citing anticipated performance issues — this felt like something I needed to test.

In Chrome’s performance profiler, I enabled CSS selector stats, and recorded myself triggering a style recalculation. I inspected the recalculation event and went to the Selector stats tab. Indeed, :has(> .sidebar) appears as the slowest selector at… 0.005ms in elapsed time.

Code language
css

:has(> .sidebar) {} /* 0.005ms */

:has(> .sidebar) > :not(.sidebar) {} /* 0.002ms */

If I’m understanding correctly, this means it would take 1 / 0.005 or 200 concurrent sidebar layouts using this selector to risk a 1ms bottleneck on style recalculation. That or, I suppose, re-rendering the same sidebar layout every 0.005ms, 200 times over.

These metrics are deceptive anyway. As Trys Mudford wrote, in I wasted a day on CSS selector performance to make a website load 2ms faster, just enabling CSS selector performance stats slows style recalculation down considerably (in their case, from 11.95ms to 270ms total).

This too is something of a quantum effect, albeit not a welcome one. In any case, don’t let the spectre of selector performance deter you from harnessing modern and powerful CSS selector patterns. There’s much more important optimizations you could be focusing on, such as reducing client-side JavaScript, keeping your DOM light, and reducing the number of render-blocking elements.

Enjoyed this article? You can support us by leaving a tip via Open Collective


Newsletter