Scope in the Cascade

There are two primary ways to think about “scope” in CSS, which represent different goals.

  1. Keep scoped styles from escaping scope – by expressing a scope of ownership through root & lower-boundary selectors.
  2. Keep global styles from overriding scoped styles – by giving proximity the power to override specificity in the cascade.

Popular CSS tools (CSS modules, Vue scoped styles, etc) and conventions (BEM, etc) have put their entire focus on the first goal, while previous CSS proposals have been designed to link both goals under a single name – making scope much more “heavy-handed” in the Cascade.

Both “specificity” and “proximity” are heuristics that represent different aspects of the same assumption: details are likely to be more targeted than defaults. We know that’s not universally true in either case, which is why we’re now providing a more explicit @layer functionality.

Still, the heuristics are useful, and one of them needs to take precedence.

Context

Proximity is defined by the DOM, and is largely invisible to a CSS author writing modular styles. Selectors that were previously designed to have higher or lower specificity, will suddenly cascade in unexpected & unreliable ways based on DOM structures.

If proximity takes priority, then the specificity of a selector only matters in relation to other selectors at the same proximity. Global selectors would need to rely on explicit @layer rules if they are intended to have global impact.

Meanwhile, Selector specificity is established in the CSS, and applied consistently no matter how the DOM is shaped. It provides authors with more control over the way a system is applied.

Many projects keep specificity intentionally flat & low-weight when possible, meaning source-order currently takes precedence in most conflicts. That works because authors prefer to avoid conflicts in the first place – something scope will help with.

If specificity takes priority, it can continue to be used in much the same way as before (with @layers to add more customization) – and proximity will begin to apply only in those situations where flat specificity and overlapping scopes allow a conflict. It provides a better fallback heuristic than source-order, without fundamentally changing the way specificity applies.

I see the latter option as a much smoother path forward, and a better match with existing tools & conventions.

Lexical scope comparisons

Many programming languages (including Sass & JS) have a concept of lexical scope, both at the document/module level, and within code structures.

In those cases, scope primarily helps resolve naming conflicts. If a function, mixin, or variable name is allowed to “bleed” across scopes, it might interfere with another feature of the same name.

(This would be similar to CSS scope only resolving conflicts between selectors that have the same exact name)

Lexical scopes tend to be clearly defined and nested. The relationship between scopes is visible in the document, and there may even be tools to explicitly allow cross-scope references when needed (see JS & Sass module imports/exports, and the !global flag for Sass variables).

The same is generally true of Shadow-DOM “isolation context” where the scope is defined by a discrete DOM fragment. The shadow scope is always nested inside a host document scope, and the relationship is clear. Styles on either side have limited but clear ways to penetrate that boundary.

Inline styles are also applied directly to a single element, and given priority over more “global” stylesheet declarations. That can be seen as a form of lexical scope as well, based on where the style is defined.

In all these cases, the scopes have clearly defined parent/child relationships that are visible in the code. It makes sense for the clearly defined parent or child to consistently take precedence.

But those structures & relationships are not at all clear in the many-to-many situation described by selectors, which will be used to establish CSS scope. The relationship between two selectors can take any number of shapes – alternating parent/child relationships in unexpected ways, or sometimes describing the exact same element in unison.

None of that is clear by looking at the CSS alone, and it’s one reason we have specificity – which we can control & define without reference to the DOM.

It will not necessarily be clear how all the scopes in a stylesheet are expected to overlay and interact. The parent/child/unison relationships are likely to change in unpredictable ways – and authors will need to rely on other tools that better express the weight of a scoped rule.

We can either limit that to the new (high-level) @layer blocks, or allow them to use selector specificity as well.

Scope in existing tools

The primary solution I’ve seen was invented (if I understand correctly) by [CSS Modules][modules], and adapted by other tools. Using JavaScript as a preprocessor for both the CSS & HTML output, classes are transformed to make selectors unique within a scope:

<!-- input html -->
<h1 css-module="title">postcss-modules</h1>

<!-- output html -->
<h1 class="_title_xkpkl_5">postcss-modules</h1>
/* input CSS */
:global(.title) { /* specificity [0,1,0] */
color: red;
}

:local(.title) { /* specificity [0,1,0] */
color: green;
}

/* output CSS */
.title { /* specificity [0,1,0] */
color: red;
}
._title_xkpkl_5 { /* specificity [0,1,0] */
color: green;
}

When complex selectors are used, each selector part is transformed individually.

The result is:

Vue scoped styles adapt this approach, but use an additional unique attribute rather than transforming the original attributes:

<!-- input html -->
<h1 class="title">postcss-modules</h1>

<!-- output html -->
<h1 class="title" data-xkpkl>postcss-modules</h1>
/* global CSS */
.title { /* specificity [0,1,0] */
color: red;
}

/* scoped CSS */
.title { /* specificity [0,1,0] */
color: green;
}

/* output CSS */
.title { /* specificity [0,1,0] */
color: red;
}
.title[data-xkpkl] { /* specificity [0,2,0] */
color: green;
}

These tools also tend to…

The result is that scoped styles generally override global styles of the same specificity, but it is easy to increase global specificity when desired to override scoped styles.

My existing proposal

My proposal would be able to replicate either/both outcomes – or provide their own desired specificity behavior – by allowing the author to establish a “scope specificity” in addition to the “selector specificity” of each selector block:

.title { color: red; } /* specificity [0,1,0] */
.title.special { color: purple; } /* specificity [0,2,0] */

@scope (:where(.article)) to (.lower-bounds) {
.title { /* specificity [0,1,0] */
color: green;
}
}
@scope (.article) to (.lower-bounds) {
.title { /* specificity [0,2,0] */
color: green;
}
}
@scope (#article) to (.lower-bounds) {
.title { /* specificity [1,1,0] */
color: green;
}
}

Additionally, I would give priority to scope proximity when specificity is equal. In this example, the global .title.special would be able to override the first (zero-specificity-root) scope.

Migration path

All existing tools would be able to auto-generate @scope rules that match their current behavior in terms of both ownership (lower-boundaries replace unique attributes) & specificity (by generating an appropriate root selector).

The only difference would be that scoped styles are given a slight bump in priority when specificity is equal.

That would take the existing “most common” behavior, and give it more reliable nesting, without dramatically changing the interaction with global styles.

It’s also common in the current tools to generate non-overlapping scopes. That can be quickly reproduced with a naming-convention, or custom attribute, that is used to establish both scope roots, and lower-boundaries:

@scope ([data-scope=media]) to ([data-scope]) {
/* never flow into another scope */
}

Automated tools would be able to simplify their output, only generating attributes on the root of each scope. That simpler output is also more easy to reproduce by hand, and extend as needed.

For most authors, classes and attributes make up the vast majority of selectors, and the most common results of a scope will be similar to Vue: a root specificity of [0,1,0] added to the specificity of individual scoped selectors.

Since that also matches the specificity behavior of nesting, I expect it to be an easy concept to learn & teach.

Use cases

I was asked to show different use-cases for “high vs low powered proximity” in the cascade – placing scope proximity “above” or “below” specificity in the cascade.

I don’t think use-cases fall cleanly into those categories. Instead I’ve found:

All of those use-cases would be solvable with either high-or-low powered proximity. So my argument is not that one or the other has “more” use-cases, but that:

By allowing specificity to take precedence, scope can be integrated more smoothly in existing projects, and the relative weight of each selector remains clearly visible in the CSS.

Fantasai’s example

Fantasai raised this use-case on the CSSWG telecon:

fantasai: Example: I have a sidebar in my page and want it to have a different color. Inverse contrast color. I have rules setting link color heading color etc. Need to override them all in my sidebar. I override the link and say it’s light. Overall outer page has slightly different colors for links in a list. B/c that’s higher specificity it overrides the sidebar.

I implemented this in codepen, and it is possible to re-create the scenario:

See the Pen 1235d3af7fd584d4f9471b90735a38ec by Miriam Suzanne (@mirisuzanne) on CodePen.

(It took some work to make the specificity of a global link-in-a-list style override the specificity of a “scoped” link-in-a-sidebar. Either my scoping selector has to be surprisingly weak, or my global style has to be fairly specific. But we can set that aside for now.)

While this case demonstrates the potential for specificity to give a bad result, neither heuristic actually provides a satisfactory resolution. If we gave preference to proximity, we would lose the link-in-list pattern entirely. I’m not convinced that’s an obvious improvement.

Really what we want is for the author to clarify their intentions:

All of these solutions help to clarify what we meant, and how we intend these patterns to interact. None of them are hacks. Authors can use various combinations of scope, specificity, and layers to convey their intent.

On the other hand, if we give proximity total priority over specificity, @layer becomes the only tool available for managing different intentions.

Fantasai also says:

If you switch class to ID it can completely destroy relationship between selectors.

I’d argue that’s the expected behavior for authors – which they are very familiar with – and exactly the purpose of specificity as a heuristic. I don’t see a good reason for @scope to change that, when we already have both @layer and :where() to help authors manage their specificity more explicitly.

We can dig into some of those cases with a bit more detail.

Global & scoped themes

In the previous example, light mode is the global default theme, and dark-mode is a scoped “inversion” of the theme.

If we base the inversion on a class (like .invert), proximity and specificity would give us the same priority result here. While that seems reasonable to me, I’ll give the inversion a lower specificity, so we can see the potential conflict between them:

html {
background: white;
color: black;
}

a:any-link { color: darkmagenta; }

@scope (aside) {
:scope {
background: white;
color: black;
}
}

This creates a broken state either way:

And either way, the issue is resolved by clarifying our intent for links in the inverted scope:

html {
background: white;
color: black;
}

a:any-link { color: darkmagenta; }

@scope (aside) {
:scope {
background: white;
color: black;
}

/* both higher specificity & closer proximity */
a:any-link { color: violet; }
}

This is very similar to the way authors currently handle the overlap of styles, without scope:

html {
background: white;
color: black;
}

a:any-link { color: darkmagenta; }

aside {
background: white;
color: black;

/* higher specificity */
& a:any-link { color: violet; }
}

Specificity is already designed to give contextual styles more weight over global defaults. Adding proximity as an override to specificity would not solve the problem more easily.

Light & dark theme scopes

Another option would be to define multiple scoped “themes” – such as light- and dark-mode – which can be nested indefinitely. This is a case where we clearly want the inner (more proximate) scope to win, but also a case where I would expect the theme selectors to provide equal specificity:

@scope (.light) {
:scope {
background: white;
color: black;
}

a:any-link { color: purple }
}

@scope (.dark) {
:scope {
background: white;
color: black;
}

a:any-link { color: plum; }
}

Since the specificities match, proximity becomes the deciding factor. For extra caution, we can also ensure these scopes never overlap, avoiding all conflicts between them:

@scope (.light) to (.dark) {}
@scope (.dark) to (.light) {}

Themes and components

Unlike DOM-isolation approaches (eg shadow-DOM), we can also end-up combining broad patterns (like themes) with more contained components (like a specialized link-list style).

There’s not necessarily a clean delineation here, but in some sense these scopes belong in different “layers” of a design system (as per the ITCSS convention or the @layer spec).

For a smaller component like a list, it will generally be “inside” one theme or another. If both the theme and component are scoped, and both scopes apply, the more narrowly defined component is likely to be “more proximate” for items in the list.

But there are use-cases where they overlap in unpredictable ways:

Authors will need tools to manage the priority of these relationships in a more consistent way.

Conclusion

In my mind, the purpose of a global scope is explicitly to apply everywhere, and the purpose of a narrower scope is to constrain where some styles apply.

The argument for strong scope assumes that the goal of scope (constraint) always aligns with a secondary desire to write “higher priority” styles, which override any global patterns.

I find that assumption entirely unreliable, and unfounded in CSS. There is no reason to assume that global styles are low-priority compared to scoped styles.

Meanwhile low-powered scope allows authors to avoid those conflicts in the first place (through containment, where desired) – while balancing proximity, specificity, and layers, in ways that are obvious in the CSS, and integrate smoothly with current tools & conventions.