Lazy combinators

7 min read
This is an unpublished draft. It may be incomplete, contain errors or be completely wrong. Please check back later for the finished version, or subscribe to my feed to be notified.

Prior art:

There are many use cases in CSS where the child/adjacent sibling combinators are too strict, yet the descendant/general sibling combinators are too greedy and essentially, one needs a middle ground: something that extends beyond immediate children/siblings, but only until it finds a match in each direction.

Instead of “lazy” we can also call them “proximity combinators” or “closest combinators”.

This includes:

  1. Lazy descendant: Find a descendant that matches a given selector, but no descendants of that (lazy descendant)
  2. Lazy ancestor: Find the closest ancestor that matches a given selector, but no ancestors of that (lazy :has(* &), backwards lazy descendant)
  3. Lazy subsequent sibling: Find a next sibling that matches a given selector, but no siblings after that (lazy ~)
  4. Lazy preceding sibling: Find a previous sibling that matches a given selector but no siblings before that (lazy backwards ~)

Note that for each of these, the matched result is a strict superset of the corresponding strict combinator and a strict subset of the corresponding lazy combinator. E.g. for lazy descendant, if child (>) matches, that’s what you get. For lazy next sibling, if + matches, that’s what you get.

We should also disentangle this proposal from #12453. If we decide we don’t want to have backwards combinators, we could only do lazy descendant and lazy subsequent sibling, and people can always use :has() to invert them. Though the biggest use case is getting the closest ancestor, which would be a backwards combinator.

Per #12451, we could give them readable names, E.g. lazy ancestor could be /closest/, though things like /preceding-sibling/ or /subsequent-sibling/ seem excessively long (and reminiscent of XPath — yikes). So it’s plausible we may want to give these ASCII art names to connect them with their greedy or strict counterparts:

So, I’ll use >>, <<, ++, -- for the rest of this, but the syntax is TBD.

[!NOTE] TBD: if the first operand in E >> F also matches F, is it included in the matched set? XPath had a “descendant or self” combinator which is sorely missing from CSS. But perhaps it’s best not to conflate the two concepts. OTOH, that’s how JS’s element.closest() works. We could also make << behave this way but not >>, though breaking their inverse relationship could cause confusion.

Everything below applies to siblings as well.

How does it work the lazy descendant work and how does it differ from the child and descendant combinators?

Think of it that way: Selectors are a string representation of a set of elements, and combinators are just infix operators on sets of elements. Just like 1 + 2 + 3 adds 1 + 2, produces 3 and then does 3 + 3 to get 6, conceptually, a combinator E /foo/ F computes the set of elements matched by E, applies a transformation to each (defined by /foo/), produces a union of the results, then filters it by F.

In JS parlance, E /foo/ F would look like (assume foo(element) applies the transformation):

document.querySelectorAll(E).flatMap(foo).filter(e => e.matches(F)

Given no operator precedence, combinators are applied left to right (right to left produces the same result, just requires a different conceptual model, as explained by @tabatkins).

[!NOTE] This is a useful mental model, not how UAs actually implement selector matching (which is right to left). However, as @tabatkins explained here that simply changes the transformation, but the core logic is still the same.

With that mental model:

Here is a visual example:

Blue nodes match E and green nodes match F.

Selector Matched elements
E > F 2
E F 2, 2.1, 3.2, 4.2.1, 4.3
E >> F 2, 3.2, 4.2.1, 4.3
F << E or E:has(F >> E) 1, 4.2
E:has(F) 1, 4, 4.2
E:has(> F) 4.2

Another reason to add them is that even for cases where the greedy versions suffice, lazy combinators could be implemented to be more performant.

Use cases

Populate this with use cases as I find them.

Alternatives

Embed filtering selector in the combinator

Lazy combinators do introduce something unprecedented in how CSS combinators work: The transformation and the filtering step are integrated. Until now, E /foo/ F was three steps:

  1. Match E
  2. Apply the transformation foo to each matched element
  3. Filter the results by F

With lazy combinators, 2 and 3 need to be done together, which has no precedent in CSS and may turn out to be not implementable. We have seen how they could be implemented as syntactic sugar, so perhaps that could be a way forwards, but then we lose any performance benefits. Another option could be to make the filtering selector part of combinator, e.g. E /closest-descendant(F)/ *. I’m hoping we don’t have to resort to this. Beyond its excessive verbosity, we’ve seen how authors find it very confusing when they have to stuff selectors in places where they don’t belong (see :nth-child(1 of E); authors are puzzled why they can’t just have E:nth-match(1)). Worst case, perhaps we could have both, and define E >> F as sugar over E /closest-descendant(F)/ *.

FAQ

Isn’t this basically what @scope does?

There are some similarities between @scope and lazy descendants, but they have different purposes and do different things.

Lazy combinators have several benefits over @scope:

More details below.

Flexibility

@scope only covers ancestor/descendant relationships. There is no such thing as sibling scope.

Additionally, @scope is more limited: you define the root of the matching, an optional descendant selector to exclude, and that’s about it. However, selectors can have any number of combinators, anywhere in the selector.

Do not affect the matching scope

@scope defines two things: the anchor for relative selectors, and the scope for matched elements. There are many use cases where you want these to be separate, such as the HTML attribute use cases, where you want to restrict matched elements to be within a certain element, but also match relative selectors against the element itself, not its scoping root.

Portability

@scope is a CSS syntactic construct, not a selector. There are many contexts that accept CSS selectors, but not general CSS syntax:

One way to think about it is that @scope is a meta-selector, that affects how other selectors match. Let’s draw a parallel with regular expressions. Regular expressions have flags, which change how regexp matching works (e.g. i for case-insensitive matching). There is still value in regexp syntax to do the same thing for a part of the regular expression, which is why we have regexp modifiers.

Even within CSS, for many use cases, a selector syntax is a more lightweight solution than @scope. @scope is still useful due to its effects on the cascade and how it’s the only way to write CSS selectors that are DOM-aware. But many use cases don’t need that.

Predictability

Because @scope is a syntactic construct, authors expect more out of it than it can provide. For example, something that often trips up authors is that inherited properties still inherit outside the donut scope. There is no such confusion with selectors. Authors know what to expect from them.

Isn’t this already possible via .e .f:not(.f .f)?

(This was brought up by @SelenIT in https://github.com/w3c/csswg-drafts/issues/4940#issuecomment-611903372)

Not in the general case. Consider this:

<div id=e1 class=f>
	<div id=e2 class=e>
		<div id=e3 class=f></div>
		<div id=e4 class=f>
			<div id=e5 class=f></div>
		</div>
	</div>
</div>

#e2 >> .f would give us #e3, which is what we expected. However, #e2 .f:not(.f .f) would give us no elements because #e3 does match .f .f.

#e2 .f:not(#e2 .f .f) would work, but not in the general case: .e .f:not(.e .f .f) could still fail if there was another ancestor .e around the whole thing.

This highlights exactly why we need this. These kinds of subtle matching bugs can creep in very easily, and are very hard to fix.

I don’t like combinators, can we use a pseudo-class?

Pseudo-classes are filters. They cannot redirect the matched target like combinators can. We cannot do E << F via E:closest(F). If we do this with a pseudo-class it would need to be on F, like F:closest-to(E), which will be quite awkward in practice.

I don’t like combinators, can we use a pseudo-element?

While element-backed pseudo-elements do exist (::part, ::slotted(), ::details-content etc), they are typically pseudo-elements instead of combinators because they are limited in some way. Additionally, even for those, the fact that they are not regular selectors causes no end of author pain (e.g. see #7922) so that is not a pattern to emulate any more than is absolutely necessary.

But pseudos have nice readable names, and <</>>/etc are confusing

It’s true that the fact that existing combinators use symbols (>, +, ~) instead of the nice readable names of pseudo-classes and pseudo-elements can make them confusing. There have been discussions over the years about using /name/ for new combinators as opposed to ASCII art (see #12451 for a request to formalize this).

That said, I do think that for these particular combinators, symbols can aid learnability by connecting them to the existing ones:

Seeing syntax as a UI, symbols are essentially its icons. And just like icons, symbols are problematic when used in a way that is both nonobvious and disconnected from any existing conventions in the rest of the UI. As an extreme example, consider this: which expression is more readable, 1 + 2 * 3 or 1 plus 2 times 3?

Additionally, icons that need to be learned represent a tradeoff of learnability for efficiency, so they can be a good choice for frequently seen things (the reason why we went with & for Nesting instead of something more descriptive like @nest). As another example, we can look at XPath, which favors named combinators and only has two symbolic ones (/ and //). What is more readable, a ~ b or //a/following-sibling::b?

Combinators that can go in both directions are hard to read without parentheses

What is A << B >> C? Isn’t it more clear as B:has(>> A) >> C? As with most things in life, it depends. Yes, when viewed in the abstract like this it can seem that :has() is superior, because in the absence of other context, we hang on to the one actual word in there. As mentioned above, pseudo-classes are filters, whereas combinators are transformations. Some selectors are more readable expressed as filtering operations, whereas others are more readable expressed as transformations.

When specifying e.g. the Suppose you have a .callout with modifier classes like .note or .tip and a .title and an .icon descendants, and these callouts can be nested. `

.callout {
	>> .icon {}
	>> .title {}

	&.note {
		>> .icon {}
		>> .title {}
	}
}