Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[css-scoping] Scoping of functions, other name-defining at-rules and custom idents #11798

Open
kizu opened this issue Feb 27, 2025 · 16 comments
Open
Labels
css-scoping-1 Current Work

Comments

@kizu
Copy link
Member

kizu commented Feb 27, 2025

Context

There are many name-defining at-rules in CSS.

Examples: @keyframes, @property, @counter-style, @position-try, and, soon, @function, etc.

There are also many custom idents, regular or dashed.

Examples: custom properties, counters, anchors, timelines, etc.

A common problem for them all: currently, most of them (all? I did not check) are tree-scoped.

Problem

Current “CSS Scoping Module Level 1” defines how things are scoped in Shadow DOM vs Light DOM.

However, when we're purely in a Light DOM context (or inside a single shadow root), all the custom idents exist in a single global scope. This means that it is very easy to have a name clash between libraries, components, and other CSS things, where it is very easy to accidentally reuse the same name.

The definition that wins in the cascade will be used, and it is relatively easy to break something with it. Custom property can be registered with an incorrect type, an animation could use an incorrect set of keyframes, anchored element will be attached to something unexpected, and custom functions could result in a lot of IACVT.

Authors have to use naming conventions that help reduce clashes, or external libraries like CSS Modules that guarantee the uniqueness of all these idents.

A convention is brittle and results in lengthy idents, and anything that fails to follow it could lead to an issue. External tools are, well, external.

Native CSS, by itself (without Shadow DOM), does not provide a mechanism like this.

Proposal

When I think about what we need: we need proper and explicit scoping in the light DOM for all these idents and definitions.

What if we reused @scope?

Even though it did not yet ship in Firefox, I don't think it is a good idea to change its behavior today, as it was for a long time in Chrome and Safari. We don't want to break backwards compatibility. Add to this — not always you want to scope all the idents, so it might be inconvenient for simple projects to have @scope with this strong scoping as the default behavior.

What I suggest: adding some kind of a keyword when we define an @scope. Name TBD and requires bikeshedding, but something like @scope namespace (.foo) {} (this is similar to adding an isolated keyword in github.com//issues/11002 — but I don't see what I propose in this issue as isolation, but more of an extension of a cascade for the custom idents).

When you then define any idents inside — they will be scoped to this particular scope.

Meaning: when we use some ident on an element, we will find the closest scoping rule to this element, and use the named ident that is defined in that scope. If not found, we will go up the scope, and so forth until we will reach the global scope (what we have now without scopes at all).

@scope namespace (.foo) {
	@function --foo() { result: lightgreen }
}
@scope namespace (.bar) {
	@function --foo() { result: pink }
}

.foo {
	background: --foo();
}

.bar {
	background: --foo();
}
<div class="foo">
	should be lightgreen
</div>

<div class="bar">
	should be pink
</div>

<div class="bar">
	<div class="foo">
		should be lightgreen
	</div>
</div>


<div class="foo">
	<div class="bar">
		should be pink
	</div>
</div>

<div class="foo bar">
	should be pink (when multiple match, the one that wins in the cascade wins; in this case — one that defined later)
</div>

This can be seen a bit similar to how container queries, or container query length units work: by default we will look at the closest container. But we also have named containers that allow us to choose which one to use if multiple of them match.

Scopes cannot be named just yet, but there is a proposal to do so: #9742

With an ability to name scopes, we could expand this feature, and allow to somehow override from which scope we want to use an ident. Maybe something like a from-scope(<scope-name>, <ident>)? One problem could be custom properties — not for their usage (var(from-scope(--outer, --foo)) could potentially work), but for definition: it might be tricky to find a good syntax for defining a custom property for a certain scope.

This can be a bit similar to the #10808 — an ability to break encapsulation from inside Shadow DOM for idents.

That's it for now. This is not a very concrete proposal, but something I was thinking about for a very long time, and something I saw others have issues as well.

Possibly, there were already issues about something like this, and this is a duplicate: if so, point me to them, and it would be great to pick this up again.

In general, I think @scope is the best place for something like this, with its proximity rules, and, literally, with its name.

@bramus
Copy link
Contributor

bramus commented Feb 27, 2025

IIRC there were talks before about introducing something like contain: names to scope names to only a subtree. I believe @flackr and/or @noamr were part of those conversations (which were about view-transition-name if I'm not mistaken).

@kizu
Copy link
Member Author

kizu commented Feb 27, 2025

Interesting. I think an at-rule fits this better, given with contain you couldn't scope any of the name-defining at-rules, and with the way I see it (looking up the closest scope that defines a thing), it might be quite different from containment, where you might not want things to leak.

@mirisuzanne
Copy link
Contributor

I believe contain: name is for containing idents that originate in CSS properties: anchor names, container names, etc. Those are already attached to elements by default, so it's clear where the containment originates from, and we can contain how far they spread. With name-defining at-rules, we're starting from a detached global, and we need a way to attach it to specific elements.

@bramus
Copy link
Contributor

bramus commented Feb 28, 2025

I believe contain: name is for containing idents that originate in CSS properties: anchor names, container names, etc. Those are already attached to elements by default, so it's clear where the containment originates from, and we can contain how far they spread. With name-defining at-rules, we're starting from a detached global, and we need a way to attach it to specific elements.

Ideally, the mechanism for both would be the same, no? If name-defining at-rules could be allowed within selectors, contain: names would be able to contain the names of both name-defining at-rules and idents.

The example @kizu gave could then be rewritten to the following, which I believe is easier to understand as you don’t have to connect a few things.

.foo {
	contain: names;
	@function --bg() { result: lightgreen }
}

.bar {
	contain: names;
	@function --bg() { result: pink }
}

.foo, .bar, .baz {
	background: --bg();
}
<div class="foo">lightgreen</div>
<div class="bar">pink</div>
<div class="baz">transparent (because there is no --bg available)</div>

<div class="foo">
  <div class="baz">lightgreen (because the one from .foo is used)</div>
</div>

Using @scope is still possible with this, but it’s not @scope that forces the name containment:

@scope (.foo) {
	contain: names;
	@function --bg() { result: lightgreen }
}

@scope (.bar) {
	contain: names;
	@function --bg() { result: pink }
}

@kizu
Copy link
Member Author

kizu commented Mar 1, 2025

Some thoughts about the contain option.

1. @scope allows having donut scoping, which can be very useful: we can have an outer scope return when the inner scope ends.

@scope namespace (.foo) {
    @function --bg() { result: lightgreen }
}

@scope namespace (.bar) to (.bar-end) {
    @function --bg() { result: pink }
}

.test { background: --bg(); }
<div class="foo">
    <div class="test">I am lightgreen</div>
    <div class="bar">
        <div class="test">I am pink</div>
        <div class="bar-end">
            <div class="test">I am lightgreen again</div>
        </div>
    </div>
</div>

Would it be possible to do this with contain, and if so, will it be as expressive?

2. With contain it could be easier to “break” the containment by overriding something with a different value of contain. To me, it feels weaker than an at-rule. The effect of the scoping is strong: all the ident names will be contained, and it feels better to control this effect on an at-rule level.

3. My initial idea for this was to not even use contain, but allow all the name-defining at-rules to be present on elements. I imagine this can be a pretty significant change, where at-rules will have to live inside a rule alongside declarations. There are benefits from this and could open some more doors, but this also makes things maybe too flexible? And then, it might be confusing to understand how the at-rules inside selectors work without contain: do they work by the current rules — only taking the order and layers into consideration? If so, it could be an easy mistake to make where you forget the contain and think that an at-rule inside your rule is scoped.

4. Semantically, I prefer @scope to contain, after all, we already call this mechanism scoping.

But even if I prefer @scope, it is nice to have an alternative option to consider, as this allows us to weight both of them, and think deeper about the use cases.

Now we have two options:

  1. @scope namespace — additional @scope keyword, name TBD.
  2. contain: names — additional contain value.

I wonder if there are more options we don't yet see.

@mayank99
Copy link

mayank99 commented Mar 2, 2025

I wonder if there are more options we don't yet see.

One other option is a Sass-like module system. #10518

But module scoping is very different from DOM scoping, and I suspect there will be good use-cases for both.

@romainmenke
Copy link
Member

romainmenke commented Mar 2, 2025

A common problem for them all: currently, most of them (all? I did not check) are tree-scoped.

This means that it is very easy to have a name clash between libraries, components, and other CSS things, where it is very easy to accidentally reuse the same name.

Is the problem you are describing mostly name clashes?
I didn't see any other use case mentioned.
If so, I don't think @scope is the right approach.

Authors will want to mix and match things defined in different sources on the same element.

They will not want to use only idents from library A on element B and only idents from library V on element W. There will never be a neat line.


Some ideas I've been considering to improve how authors can work around name collisions:

Add a block syntax to @import to declare aliases.

@import "lib.css" {
  @keyframe BOUNCE as LIB_BOUNCE;
}

Lot's of questions with this one:

  • does it still import everything, including BOUNCE as BOUNCE?
  • what happens if BOUNCE uses variables that also have a naming conflict?

Have multiple isolated lists/registries of user defined idents.

@import "lib.css" namespace(foo);

Every name defining thing (at rules, custom props, grid template area's, ...) from lib.css (including more imports) would be isolated in their own registry.

From the perspective of lib.css everything appears to be normal and unaltered.
But no user defined ident can conflict with anything on the outside.

Referencing things would require some method of constructing the namespaced ident.
Maybe something like ident(namespace(foo) --bar)?

.foo {
  width: var(ident(namespace(foo) --bar));
}

@kizu
Copy link
Member Author

kizu commented Mar 2, 2025

Authors will want to mix and match things defined in different sources on the same element.

I mention that named scopes could cover this use case, although it could be not ideal.


Handling this with imports is an option, but I think it should be only as an additional option, like with an ability to add a layer to an import. There was a proposal for being able to use @scope when importing something: #7348 which could allow doing this in some way as well (but also cumbersome).

Why I don't think imports-only way is good: there are use cases for doing this inside a single file. Even aside from bundled stylesheets, you could want to have something like this for two components in a single file. There is no reason to restrict this to an import-level definition.


That said, maybe @scope is not the best option as well. I like how the import option is close to how this is handled in things like CSS Modules, where the idents are not tied to some element tree, but are scoped for a whole file. I think, this is what @mayank99 means by a module-like system?

What if we could do this with block-level scoping, somehow? Any idents that share their name inside one block are treated as the same, but idents in different blocks are not.


I wonder if we could somehow adopt the @namespace for this? I don't remember when was the last time I saw them used in its current form, but maybe there is a way we could override it with a compatible syntax in a way that will just work?

The current syntax of @namespace is @namespace <namespace-prefix>? [ <string> | <url> ] ; — without a block, with a required string/url, and can only be at the beginning of the file.

What if we override it with a @namespace <dashed-ident> { <rule-list> }, and allow everywhere? It does not intersect with the current @namespace, so could work out? If not, then we could bikeshed the name for such at-rule, I don't have a srtrong preference to @namespace, just, again, something that seems obvious from semantic standpoing. And — we could reuse the | for recalling all the idents as well?

@namespace --a {
	@function --foo() { result: lightgreen }
	.a {
		background: --foo(); /* lightgreen */
	}
}

@namespace --b {
	@function --foo() { result: pink }

	.b {
		background: --foo(); /* pink */
	}
}

.foo {
	background: --foo(); /* transparent — not defined outside a namespace */
}

.bar {
	background: --a|--foo(); /* lightgreen: uses --foo() from --a namespace */
	outline: 2px solid --b|--foo(); /* pink: uses --foo() from --b namespace */
}

Maybe the | syntax is not the best, but I think it would be great to have shorted than ident(namespace(--a) --foo), and something that could be used for things like functions, and not just idents.

I also considered reusing the @sheet, but I don't like that sheets are not applied by default, so this won't work for use cases where you just want to have isolated idents per component in a single file.

So far, the round up of options:

  1. @scope namespace --foo
  2. contain: names
  3. @import namespace(--foo) for a file-only namespacing?
  4. @namespace --foo {} (or other name, with an idea of namespacing/scoping only what is inside a block). Could have an option 3. as an alternative way of applying it alongside.

@Loirooriol Loirooriol added the css-scoping-1 Current Work label Mar 3, 2025
@DarkWiiPlayer
Copy link

I've been hoping for some sort of name scoping for a while but I'd rather have lexical scoping where a name is local to a file or block of CSS code, rather than certain parts of the document.

In its most basic form, this could be implemented by just prefixing any names found within a scope:

@layer has-local-name {
   @local --color; /* scoped to the layer */

   /* gets turned into --internal-prefix-color */
   :root { color: var(--color) }
}
:root {
   --color: red; /* does nothing because plain `--color` isn't used anywhere */
}

A neat side effect here is that this could be done entirely by a preprocessor so it could be used in production a lot sooner.

@kizu
Copy link
Member Author

kizu commented Mar 3, 2025

Hmm, this is an interesting idea. It can be expanded to something like: adding @local --foo inside any block could make this ident local.

I see two issues with this:

  1. How do we recall these from outside? Do we make them completely private? This might be useful, but maybe a separate feature? Or, it would be an extension of the @namespace proposal, where you couldn't recall from outside a @namespace without a name, similar to how you can't extend an anonymous @layer.

  2. If you want to have multiple idents, it can be a bit cumbersome to define all of them. Like, imagine you want a local set of design-tokens. Having a wrapping at-rule might be more convenient.

@DarkWiiPlayer
Copy link

  1. I honestly can't judge whether this would be a complete alternative to your proposal or just something with some overlap. Even with completely private scoping this would address most of my needs, specially if @layers were treated a specially to make names scoped to the layer rather than the individual @layer { } blocks.
  2. I'd say it's reasonable to declare a list of names used in a scope: @local --hue --saturation --value;. If I'm understanding you correctly, you're talking about a way to wrap a section of code and have every name inside be scoped to this section? So something like @everything-inside-local { :root { --color: red; color: var(--color) } }?

@kizu
Copy link
Member Author

kizu commented Mar 3, 2025

Yes, my @namespace proposal is basically about lexical scoping, with every ident inside becoming local to that namespace. Using an @layer with this could be an option, but I am not sure if we want to override them rather than introducing a separate single-purpose concept.

One could think that with many different at-rules, you could want to sometimes combine them, like for every component use @layer, @scope, and @namespace — and that's a valid use case, but I think this is something that could be handled either by custom at-rules, or by mixins, where you could combine as many concepts in one go, creating your own architectural wrapper, and not merging different concepts at the language level.

@bramus
Copy link
Contributor

bramus commented Mar 4, 2025

IIRC there were talks before about introducing something like contain: names to scope names to only a subtree.

For reference: I found back the issue/discussion: #8915 (comment)

@andruud
Copy link
Member

andruud commented Mar 5, 2025

I am very skeptical of introducing any more complexity to name-based lookups than the substantial amount of complexity we already get from ShadowDOM.

@scope namespace (.foo) {
	@function --foo() { result: lightgreen }
}

This is not really implementable. @scope (despite being an at-rule) is essentially a selector feature. Doughnut-less "implicit" scopes (@scope {}) could maybe be used.

Examples: custom properties

I'm fairly certain (tree-)scoped custom properties is off the table forever. It will likely cause unacceptable performance regressions.

@kizu
Copy link
Member Author

kizu commented Mar 6, 2025

@andruud Do you think the @namespace option is better in that regard? It does not have the complexity of tree-scoping with all the cascading implications, as it can be polyfilled one-to-one with conventions/CSS Modules.

I'm fairly certain (tree-)scoped custom properties is off the table forever. It will likely cause unacceptable performance regressions.

Is this true for the more simple namespacing approaches as well? If so, could you briefly point to what would be the performance bottleneck?

@DarkWiiPlayer
Copy link

I'm fairly certain (tree-)scoped custom properties is off the table forever. It will likely cause unacceptable performance regressions.

Isn't tree-scoping names for things that are inherited through cascade practically pointless anyway?

The way I understand it, the options are lexical scoping to help with both functions+mixins and variables, as the scoping is independent of the cascade, or tree-scoping which mostly affects functions+mixins which are global.

Tree-scoping functions and mixins would, in practice, achieve about the same as just letting them cascade the same way custom properties do, but with a separate syntax and global-by-default semantics.

This sounds like it could be very cool in more ways than just avoiding name clashes, but wouldn't fully address clashes at all, and still require some other mechanism like lexical scoping to achieve that.

Maybe splitting the discussions into lexical vs. tree scoping would help keep those two threads more on-topic and avoid confusion?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
css-scoping-1 Current Work
Projects
None yet
Development

No branches or pull requests

8 participants