[css-scoping] Proposal for light-dom scoping/namespacing with re-designed @scope rule (original) (raw)

This would likely require additions to both CSS Scoping and CSS Cascade. See my full explainer for more details.

As I've been working on proposals around cascade layers & component queries, there is another aspect of "cascade modernization" that comes up regularly: scope. I'm aware that there is some hesitancy on the issue, since the initial specification was never implemented, and Shadow DOM was seen as a path forward (potentially a replacement). I think that time (and further development of Shadow-DOM) has helped clarify two quite different use-cases:

  1. Total isolation of a DOM subtree/fragment from the host page, so that no selectors get in or out unless explicitly requested.
  2. Lighter-touch component namespacing, and prioritization of "proximity" when resolving the cascade.

Shadow-DOM addresses the first, but it comes with a lot of overhead that is required for "full isolation". Meanwhile authors rely on convoluted naming conventions (like BEM) and JS tooling (such as CSS Modules, Styled Components, & Vue Scoped Styles) for the second use-case… which has been thoroughly discussed in various forms:

[Note: This doesn't attempt to resolve all the use-cases discussed in those threads. The discussion so far has often conflated the two approaches to scope, and I'm trying to divide them out. I think that still leaves a number of "isolation-first" cases that would best be addressed with changes that build on top of shadow-DOM - such as these ideas explored by Yu Han.]

Re-introducing @scope <selector> { ... } with a few adjustments…

1. Provide a "lower boundary" or "slot" syntax

This would make it possible to scope fragments rather than entire DOM sub-trees. @giuseppeg has suggested a syntax that I think is a good starting-point for more bikeshed discussion:

@scope (from: .carousel) and (to: .carousel-slide-content) { p { color: red } }

In my mind, only the first ("from") clause should be required, and may not need explicit labeling. It would likely accept a single (complex) selector:

@scope (.media-block) { img { border-radius: 50%; } }

In terms of selector-matching, this would be the same as .media-block img, but with slightly different cascade implications (see below). The second ("to") clause would be optional, and accept a list of selectors that represent lower-boundary "slots" in the scope. The targeted lower-boundary elements are included in the scope, but their descendants are not:

@scope (.media-block) to (.content) { img { border-radius: 50%; } .content { padding: 1em; } }

Which would only match img and .content inside .media-block -- but not if there are intervening .content between the scope root and selector target. This follows the current selector-scoping behavior of various popular tools.

I'm not convinced that to is necessarily the right keyword (others have proposed until) or if we should even consider using a functional syntax, or calling calling the lower boundary "slots":

@scope root(.media-block) slots(.content) { /* ... */ }

More discussion would be useful.

2. Make the cascade effects of scoping much less intrusive (weighted below specificity)

When scopes do overlap, it's useful to recognize the proximity of a scope (inner scope takes precedence) in the cascade. This is not currently represented in CSS. Descendant selectors rely on source order rather than proximity:

/* link colors for light and dark backgrounds */ .light-theme a { color: purple; } .dark-theme a { color: plum; }

When these color themes are nested, the dark theme will always take precedence:

plum
also plum???

Both shadow DOM and the original spec give scoped context a very powerful impact on the cascade — overriding even specificity. The original spec also inverted scope-layering for !important declarations. This follows the logic of more highly-isolated use-cases, where there is more clear distinction between the inner scope and the outer host. But in the more common lightly-scoped cases, a more nuanced interplay between specificity and scope is helpful. Most existing tools only add minimal cascade weight to scoped selectors, like a single attribute selector.

I propose re-adding "scope proximity" to the cascade specification after/below selector specificity, but above/before source-order. That would help resolve our example above:

@scope (.light-theme) { a { color: purple; } }

@scope (.dark-theme) { a { color: plum; } }

plum
purple

While still allowing more specific selectors to override scope when desired.

If authors desire more layering impact similar to the initial spec, that is now available using Cascade Layers — and the two features can be combined.

A path forward

This still needs a lot of work, but my goal here is to open discussion around a path forward for light-DOM scope/namespacing. I have a much more detailed explainer for my thought-process — but there are a lot of open questions, and I'd like to:

Happy for comments, thanks!