Skip to content

CSS Variable Namespacing#68846

Open
mattrbeck wants to merge 3 commits into
angular:mainfrom
mattrbeck:css_var_namespacing
Open

CSS Variable Namespacing#68846
mattrbeck wants to merge 3 commits into
angular:mainfrom
mattrbeck:css_var_namespacing

Conversation

@mattrbeck
Copy link
Copy Markdown
Member

@mattrbeck mattrbeck commented May 20, 2026

Superseds #67362. Primary differences are:

  • Opt-out syntax is now a --global prefix, e.g. --global--foo: blue
  • Added support for style properties, e.g. [style.--foo]="'blue'"
  • Added errors for prefix missing trailing double-hyphen, e.g. --global-foo

This adds CSS variable namespacing support to Angular.

This allows multiple apps to coexist on the same page with isolated CSS variables, meaning one can use color: var(--primary-color); without worrying about accidentally inheriting the primary color of a different app which happens to set it on an ancestor element.

To enable this feature, call provideCssVarNamespacing in your app.config.ts. Typically you want to configure this with the same value as APP_ID, but with an additional separator at the end (a - or _):

import {ApplicationConfig, APP_ID} from '@angular/core';
import {provideCssVarNamespacing} from '@angular/platform-browser';

export const appConfig: ApplicationConfig = {
  providers: [
    {
      provide: APP_ID,
      useValue: 'my-app',
    },
    provideCssVarNamespacing('my-app_'),
  ],
};

This only namespaces styles in Angular components (the styles or styleUrls properties in @Component). It does not namespace global styles, which are out of scope for this effort.

Namespacing does naturally break any JavaScript references to CSS variables, therefore this PR also introduces CssVarNamespacer which allows you to automatically namespace variables based on what is configured in the application.

import {CssVarNamespacer} from '@angular/platform-browser';

const namespacer = inject(CssVarNamespacer);
const color = namespacer.namespace('--primary-color');
getComputedStyle(someElement).getPropertyValue(color);

Libraries should consider always using the namespacer when referring to CSS variables, as they may be consumed by applications which enable namespacing.

Namespacing works by having the compiler unconditionally prepend %NS% to CSS variables (--foo -> --%NS%foo) and then at runtime replaces %NS% with a namespace specified by provideCssVarNamespacing('my-app_') (--%NS%foo -> --my-app_foo).

Internal bug: b/485672083


Closes #67362 via supersession.

@pullapprove pullapprove Bot requested review from crisbeto and kirjs May 20, 2026 23:47
@angular-robot angular-robot Bot added the detected: feature PR contains a feature commit label May 20, 2026
@mattrbeck mattrbeck force-pushed the css_var_namespacing branch from 6c27311 to 302644d Compare May 21, 2026 00:01
@angular-robot angular-robot Bot added the area: compiler Issues related to `ngc`, Angular's template compiler label May 21, 2026
@ngbot ngbot Bot added this to the Backlog milestone May 21, 2026
@mattrbeck mattrbeck requested review from dgp1130 and removed request for kirjs May 21, 2026 00:02
@pullapprove pullapprove Bot requested a review from atscott May 21, 2026 00:03
@mattrbeck mattrbeck force-pushed the css_var_namespacing branch from 302644d to e95f942 Compare May 21, 2026 00:22
* followed by a separator, such as 'my-app_'.
* @publicApi
*/
export function provideCssVarNamespacing(namespace: string): EnvironmentProviders {
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question for reviewer: I'm inclined to automatically add a _ suffic to the provided namespace if it's non-empty and doesn't terminate with a - or _. Any opinions? Might be nice to automatically add the separator.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't feel too strongly, since this should be mostly unobservable by the application.

I wonder if it might be better to be consistent (either always add the suffix or never do) just to reduce unintuitive effects and also reduce bundle size (drop an otherwise unnecessary if statement).

@Inject(CSS_VAR_NAMESPACE) @Optional() cssVarNamespace: string | null = null,
) {
this.defaultRenderer = new DefaultDomRenderer2(eventManager, doc, ngZone, this.tracingService);
this.cssVarNamespace = cssVarNamespace ?? '';
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question for reviewer: Rather than falling back to an empty string, I'm inclined to default to the APP_ID. If a user wants to disable prefixing app-wide, they could still provideCssVarNamespacing(''). Thoughts?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We still need CSS namespacing to be off-by-default right, otherwise this would be a breaking change? So I would think if CSS_VAR_NAMESPACE is not provided, it needs to default to '' just to not break existing apps.

Now we could have provideCssVarNamespacing() (with no argument) default to APP_ID as the namespace. I don't have any objection to that.

// Validate that the whole `--foo` variable is passed in.
if (typeof ngDevMode === 'undefined' || ngDevMode) {
if (!name.startsWith('--')) {
throw new Error(
Copy link
Copy Markdown
Contributor

@SkyZeroZx SkyZeroZx May 21, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be a RuntimeError ?

Comment thread packages/platform-browser/src/dom/css_var_namespacer.ts Outdated
*
* Typically set via {@link provideCssVarNamespacing}.
*/
export const CSS_VAR_NAMESPACE = new InjectionToken<string>('CSS_VAR_NAMESPACE');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can save a few bytes by doing

Suggested change
export const CSS_VAR_NAMESPACE = new InjectionToken<string>('CSS_VAR_NAMESPACE');
export const CSS_VAR_NAMESPACE = new InjectionToken<string>(typeof ngDevMode !== 'undefined' && ngDevMode ? 'CSS_VAR_NAMESPACE' : '');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Other suggestion, what if that token had a default factory ?
This was we would skip that optional: true part everywhere it is injected

Comment thread packages/platform-browser/src/dom/css_var_namespacer.ts Outdated
mattrbeck added 3 commits May 21, 2026 17:24
Adds logic to inject symbols into CSS variables for runtime namespacing.
The runtime now replaces instances of `%NS%` with a namespacing
variable, limiting reach of CSS variables to the current app. An opt-out
syntax of a `--global` prefix allows users to avoid this behavior.
Using `--global-foo` is now prohibited. We suspect these cases will
likely be typos of `--global--foo` in the future, so we blanket ban them
and direct users to the expected syntax.
Adds support for namespacing css variables in style properties. Behaves
as you'd expect following the implementation for stylesheets generally.

This change also moves the error message into a util function since we
now need to produce the same error in three places.
@mattrbeck mattrbeck force-pushed the css_var_namespacing branch from e95f942 to 5dd7961 Compare May 22, 2026 00:24
@mattrbeck mattrbeck added area: core Issues related to the framework runtime target: minor This PR is targeted for the next minor release labels May 22, 2026
Copy link
Copy Markdown
Contributor

@dgp1130 dgp1130 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks great, no major concerns on my end. Thanks for taking this on @mattrbeck!

Comment on lines +1051 to +1053
if (!leadingVar && !trailingColon) {
return match;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: I assume the need for the leading var or trailing colon is to avoid namespacing situations like .foo--bar {}? Might be worth a comment to call that out more explicitly here.

result = `--%NS%${varName.substring('--'.length)}`;
}

return (leadingVar || '') + result + (trailingColon || '');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: Prefer ??.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on these edge cases, could definitely seem some of them being problematic if we didn't cover them!

* followed by a separator, such as 'my-app_'.
* @publicApi
*/
export function provideCssVarNamespacing(namespace: string): EnvironmentProviders {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't feel too strongly, since this should be mostly unobservable by the application.

I wonder if it might be better to be consistent (either always add the suffix or never do) just to reduce unintuitive effects and also reduce bundle size (drop an otherwise unnecessary if statement).

@Inject(CSS_VAR_NAMESPACE) @Optional() cssVarNamespace: string | null = null,
) {
this.defaultRenderer = new DefaultDomRenderer2(eventManager, doc, ngZone, this.tracingService);
this.cssVarNamespace = cssVarNamespace ?? '';
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We still need CSS namespacing to be off-by-default right, otherwise this would be a breaking change? So I would think if CSS_VAR_NAMESPACE is not provided, it needs to default to '' just to not break existing apps.

Now we could have provideCssVarNamespacing() (with no argument) default to APP_ID as the namespace. I don't have any objection to that.

return match;
}

if (varName.startsWith('--global-') && !varName.startsWith('--global--')) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question: We're assuming there are no/few legitimate use cases of --global-foo as an unrelated variable name right?

Comment on lines +601 to +608
if (boundName.startsWith('--global-') && !boundName.startsWith('--global--')) {
this._reportError(getInvalidCssGlobalError(boundName), boundProp.sourceSpan);
}
if (boundName.startsWith('--global--')) {
boundPropertyName = '--' + boundName.substring('--global--'.length);
} else {
boundPropertyName = '--%NS%' + boundName.substring('--'.length);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider: Is it possible to factor this whole piece of logic into a shared function? I'm a bit worried this could easily diverge if we forget one of them exists.

if (style.startsWith('--')) {
style = style.replace('%NS%', this.cssVarNamespace);
el.style.setProperty(style, value, flags & RendererStyleFlags2.Important ? 'important' : '');
} else if (flags & (RendererStyleFlags2.DashCase | RendererStyleFlags2.Important)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider: We can reduce duplication of setProperty by separating the if statements. Same for removeStyle.

Suggested change
} else if (flags & (RendererStyleFlags2.DashCase | RendererStyleFlags2.Important)) {
if (style.startsWith('--')) {
style = style.replace('%NS%', this.cssVarNamespace);
}
if (flags & (RendererStyleFlags2.DashCase | RendererStyleFlags2.Important)) {

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: compiler Issues related to `ngc`, Angular's template compiler area: core Issues related to the framework runtime compiler: styles core: CSS encapsulation core: stylesheets detected: feature PR contains a feature commit target: minor This PR is targeted for the next minor release

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants