Scope & Encapsulation

Table of contents

Summary

Authors often complain that CSS is “globally scoped” – so that every selector is compared against every DOM element.

There are several overlapping concerns here, based on a wide range of use-cases – and they can quickly become confused. That has lead to a wide array of proposals that are sometimes working towards different goals.

In the meantime, the CSSWG conversation has been stalled – in part to see how Shadow-DOM changes things.

Looking at the state of things now, my sense is that both shadow-DOM and the abandoned “scope” specification were focused around strong isolation use-cases – which Shadow-DOM has now partly addressed:

Meanwhile, there are many use-cases for “scope” that would require a much lighter touch. I’ve been mainly interested in those low-isolation problems, but this document contains notes on both.

Existing specs that mention scope

CSS Selectors - Level 4

In CSS, this is the same as :root, since there is no way to scope elements. However, it is used by JS APIs to refer to the base element of e.g. element.querySelector()

“Specifications intending for this pseudo-class to match specific elements rather than the document’s root element must define either a scoping root (if using scoped selectors) or an explicit set of :scope elements.”

CSS Scoping - Level 1

The latest draft is primarily concerned with Custom Elements & Shadow DOM.

The First Public Working Draft had more scoping features that have since been removed:

A <style scoped> attribute, which would apply to sibling elements and their descendants. This had a few limitations:

It also included CSS @scope blocks, which would help alleviate both issues. Scoping has two primary effects:

CSS Cascade - Level 4

Prior art

Naming conventions (BEM)

To show that .element is not just inside .block, but belongs to itBEM requires authors to manually namespace one class using both names:

/* .element scoped to .block */
.block-element { /* ... */ }

JS tools & frameworks

CSS Modules, Vue, Styled-JSX, and other tools often use a similar pattern (with slight changes to syntax):

/* a component & it's children get a unique attribute to select against */
.component[scope=component] { /* ... */ }
.element[scope=component] { /* ... */ }

/* nested component containers are part of both outer & inner scope */
.sub-component[scope=component],
.sub-component[scope=sub-component]
{ /* ... */ }

/* but elements inside a nested component only have inner scope */
.sub-element[scope=sub-component] { /* ... */ }

CSSWG draft issues

Yu Han’s notes & proposal

This proposal has two parts, designed to build on top of existing shadow DOM logic.

  1. Allow shadow-DOM elements to opt-in to global styles
  2. Allow light-DOM elements to opt-in to style isolation

Questions

Does this really help with name conflicts?

The scope itself requires some form of naming/selecting, which can’t be forced-unique – so in many ways, the name-conflicts should be similar to simple nested selectors:

@scope (.block) {
.element { /* ... */ }
}

/* .element is already scoped to .block in a sense… */
.block .element { /* ... */ }

The difference becomes more clear when you consider lower boundaries:

@scope (.block) to (.nested) {
/* this will NOT select any .element inside .nested */
.element { /* ... */ }
}

/* this will select any .element, EVEN inside .nested */
.block .element { /* ... */ }

I assume that’s the distinction we care about? I do think that would be enough to limit naming conflicts to the component root (scoping) selectors.

How does scope relate to nesting?

https://drafts.csswg.org/css-nesting/

The goal of nesting is primarily to clean up document structure, and make it more readable – primarily a syntax issue.

Scope has a much more complicated set of goals, around limited selector-matching and namespacing.

But both help to describe the relationship between parent & child selectors. While it might be a mistake to make one rely on the other – they clearly have some overlap.

Where does scope fit in the cascade?

The original scope specification had scope override specificity in the cascade. Un-scoped styles are treated as-though scoped to the document-root:

For normal declarations the inner scope’s declarations override, but for ‘’!important’’ rules outer scope’s override.

But I think we should un-couple scope from specificity/importance. That behavior can be more easily controlled using cascade layers.

There would be several non-exclusive options:

Option 1: No impact on cascade

The goal of scope is to narrow down the list of selectable elements, not necessarily to represent their importance relative to global styles. We could define scope akin to media-queries, with no impact on cascading – only on filtering.

Both specificity & layers can be used in-conjunction with scope to control weighting when desired.

Option 2: The scoping selector

The specificity of the scope-selector itself could be applied to the specificity of each nested selector, so that:

@scope [data-component=tabs] {
/* `[data-component=tabs] .tab-item`: 0,2,0 */
.tab-item { /* ... */ }
}

@scope #tabs {
/* `#tabs .tab-item`: 1,1,0 */
.tab-item { /* ... */ }
}

Option 3: Make Proximity Meaningful

There is a CSS-issue that current scope solutions solve around proximity – concepts of “inner” and “outer” – which might belong in the cascade:

https://twitter.com/keithjgrant/status/1123676335484952576

My instinct would be to add proximity below specificity in the cascade, but above source-order. That would allow scopes to be re-arranged safely, without any impact on existing specificity rules.

(This could be combined with either of the above options)

Option 4: Importance-relative layering

Both the initial scope specification, and the current Shadow-DOM encapsulation-context approach put the scope-proximity above/before specificity in the cascade, and relative to importance. Specificity is not considered unless there is a tie at the scope/importance level…

Shadow encapsulation is designed to treat normal styles as the “default” which an outer page can easily override. This matches the behavior we would expect from browser-defined components:

Scope was designed in the reverse, so that inner context would override outer context unless marked as important. This matches the expectation that proximity should take priority when describing components:

❌ I don’t like this interplay of importance with scope, or the priority of scope over and above specificity. This approach is trying to infer too much about the styles based on their scope. I would hand this power off to Cascade Layers, and allow scope to have much lower weight in the cascade – untangled from importance.