Skip to content

Declarative CSS Modules#11687

Open
KurtCattiSchmidt wants to merge 25 commits into
whatwg:mainfrom
KurtCattiSchmidt:css-modules-firstpr
Open

Declarative CSS Modules#11687
KurtCattiSchmidt wants to merge 25 commits into
whatwg:mainfrom
KurtCattiSchmidt:css-modules-firstpr

Conversation

@KurtCattiSchmidt
Copy link
Copy Markdown
Contributor

@KurtCattiSchmidt KurtCattiSchmidt commented Sep 24, 2025

Adds support for Declarative CSS Modules via <style type="module" specifier="specifiername">. shadowrootadoptedstylesheets is handled in this PR: #12339

(See WHATWG Working Mode: Changes for more details.)

Addresses #10673


/acknowledgements.html ( diff )
/indices.html ( diff )
/infrastructure.html ( diff )
/obsolete.html ( diff )
/scripting.html ( diff )
/semantics.html ( diff )

Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

This PR introduces declarative CSS modules support to HTML, allowing CSS to be imported as modules through <style> elements with a specifier attribute and <template> elements with a shadowrootadoptedstylesheets attribute.

  • Adds a specifier attribute to <style> elements that creates module import maps for CSS content
  • Introduces a shadowrootadoptedstylesheets attribute for <template> elements to declaratively adopt CSS modules
  • Implements algorithms for creating declarative CSS module scripts and stylesheet adoption
Comments suppressed due to low confidence (5)

source:1

  • Missing attribute name in IDL definition. Should be [SameObject, PutForwards=value, Reflect] readonly attribute DOMString <dfn attribute for="HTMLStyleElement" data-x="dom-style-specifier">specifier</dfn>; to match the pattern used for other attributes in this interface.
<!-- -*- mode: Text; fill-column: 100 -*- vim: set textwidth=100 :

source:1

  • Grammar error: 'is defines' should be 'defines' - remove the word 'is'.
<!-- -*- mode: Text; fill-column: 100 -*- vim: set textwidth=100 :

source:1

  • Grammar error: 'appended with the of the' should be 'appended with the' - remove the duplicate 'the of'.
<!-- -*- mode: Text; fill-column: 100 -*- vim: set textwidth=100 :

source:1

  • Logic error: The algorithm references <var>specifier</var> but this variable is not defined in the algorithm. It should reference the value of the shadowrootadoptedstylesheets attribute instead.
<!-- -*- mode: Text; fill-column: 100 -*- vim: set textwidth=100 :

source:1

  • Incorrect data-x reference: Should be data-x=\"attr-style-specifier\" not data-x=\"attr-style-blocking\" for the specifier attribute row.
<!-- -*- mode: Text; fill-column: 100 -*- vim: set textwidth=100 :

Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.

Comment thread source Outdated
Comment thread source
Comment thread source Outdated
Comment thread source Outdated
Comment thread source Outdated
Comment thread source Outdated
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull Request Overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (9)

source:1

  • The IDL attribute should be named to match the content attribute. The content attribute is specifier but the IDL should use camelCase convention: [SameObject, PutForwards=value, Reflect] readonly attribute DOMString specifier; should have a data-x attribute defining the DOM property name.
<!-- -*- mode: Text; fill-column: 100 -*- vim: set textwidth=100 :

source:1

  • The shadowrootadoptedstylesheets attribute is listed twice in the content attributes section for the template element. This duplication should be removed.
<!-- -*- mode: Text; fill-column: 100 -*- vim: set textwidth=100 :

source:1

  • The shadowrootadoptedstylesheets attribute is listed twice in the content attributes section for the template element. This duplication should be removed.
<!-- -*- mode: Text; fill-column: 100 -*- vim: set textwidth=100 :

source:1

  • The algorithm step is missing proper HTML structure. It should end with </li> and the nested <ol> should be properly closed with </ol>.
<!-- -*- mode: Text; fill-column: 100 -*- vim: set textwidth=100 :

source:1

  • The variable moduleScript is referenced but never defined in this algorithm. This should likely be the current module script or settings object context.
<!-- -*- mode: Text; fill-column: 100 -*- vim: set textwidth=100 :

source:1

  • These variable assignments are missing closing </p> tags. Each should end with </p></li>.
<!-- -*- mode: Text; fill-column: 100 -*- vim: set textwidth=100 :

source:1

  • Missing spaces after commas in the parameter list. Should be <var>fetchClient</var>, <var>destination</var>, <var>options</var>, <var>settingsObject</var>, <var>referrer</var> for consistency.
<!-- -*- mode: Text; fill-column: 100 -*- vim: set textwidth=100 :

source:1

  • Incorrect data-x reference in the table. Line 148289 should reference data-x=\"element-template\" or similar, not data-x=\"attr-template-shadowrootclonable\".
<!-- -*- mode: Text; fill-column: 100 -*- vim: set textwidth=100 :

source:1

  • Incorrect data-x reference in the table. Line 148363 should reference data-x=\"element-style\" instead of data-x=\"attr-style-blocking\".
<!-- -*- mode: Text; fill-column: 100 -*- vim: set textwidth=100 :

Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.

Copy link
Copy Markdown
Contributor

@dandclark dandclark left a comment

Choose a reason for hiding this comment

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

Review in progress, sharing feedback so far.

Comment thread source Outdated
Comment thread source Outdated
Comment thread source Outdated
Comment thread source Outdated
Comment thread source Outdated
Comment thread source Outdated
Comment thread source Outdated
Comment thread source Outdated
Comment thread source Outdated
Copy link
Copy Markdown

@mhochk mhochk left a comment

Choose a reason for hiding this comment

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

LGTM (No permissions to actually Approve)

Copy link
Copy Markdown
Contributor

@dandclark dandclark left a comment

Choose a reason for hiding this comment

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

This is coming together nicely. I think the biggest thing still to figure out is to prevent a given <style type=module> from being processed twice. I'm about to head out on leave so I'm going to Approve this since I'm supportive of the direction and I trust that the remaining open issues will be handled appropriately.

Comment thread source
<ol>
<li><p>Let <var>element</var> be the <code>style</code> element.</p></li>

<li><p>If <var>element</var> is not <span>connected</span>, then return.</p></li>
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 guess this is also the point where we'd also check a new equivalent of the already started flag to ensure a given <style type=module> only ever gets processed once?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeah I'll have to think about this a little more. I will likely bring this up soon with the WHATWG.

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.

It doesn't seem like this got addressed one way or another?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I'll bring this part up at the next meeting

Comment thread source Outdated
Comment thread source Outdated
of the value of the <span data-x="attr-style-specifier">specifier attribute</span> and a value of
<var>styleDataURI</var>.</p></li>

<li><p><span>Create an import map parse result</span> with <var>input</var> as <var>jsonString</var>
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.

Create an import map parse result can throw -- do we need to handle any of those cases? The one I particularly had in mind to watch for is does it throw when a given specifier is invalid? If not, do we need to handle an invalid specifier some other way?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good question. For now I added a step to look at the importMapParseResult's error to rethrow and to continue if that happens. In practice, we probably want to log something in the console, but this might be enough for the spec.

Comment thread source Outdated
Comment thread source Outdated
@KurtCattiSchmidt KurtCattiSchmidt added the agenda+ To be discussed at a triage meeting label Mar 11, 2026
@noamr
Copy link
Copy Markdown
Collaborator

noamr commented Mar 12, 2026

I still think that we can have declarative CSS modules with URLs before we go down the rabbit hole of supporting them in inline styles with something like specifier.

The main issue with re-importing CSS URLs in shadow DOM is that they are duplicate.
We can support de-duping them in module-style regardless of supporting them in inline styles, e.g.:

<script type=importmap>
  { "mytheme": { type: "css", href: "theme.css" } }
</script>
<my-element>
  <template shadowrootadoptedstylesheets="mytheme">...</theme>
</my-element>

Or something like:

<script type=importmap>
  { "mytheme": { type: "css", href: "theme.css" } }
</script>
<my-element>
  <link rel=stylesheetmodule href="theme.css">
  <!-- or -->
  <link rel=stylesheetmodule href="mytheme">
</my-element>

This decouples the issue of deduping stylesheet references from the issue of importing inline styles.
The former is a stylesheet-specific problem and the latter, while valid, relates to script modules as well, while this proposal is stylesheet specific.

@KurtCattiSchmidt
Copy link
Copy Markdown
Contributor Author

I still think that we can have declarative CSS modules with URLs before we go down the rabbit hole of supporting them in inline styles with something like specifier.

Thanks @noamr. I've recently split the URL version into its own explainer, see https://github.com/MicrosoftEdge/MSEdgeExplainers/blob/main/ShadowDOMAdoptedStyleSheets/explainer.md.

The main issue with re-importing CSS URLs in shadow DOM is that they are duplicate. We can support de-duping them in module-style regardless of supporting them in inline styles, e.g.:
...
This decouples the issue of deduping stylesheet references from the issue of importing inline styles. The former is a stylesheet-specific problem and the latter, while valid, relates to script modules as well, while this proposal is stylesheet specific.

This proposal creates an import map entry under-the-hood for the specifier attribute on <style>, mapping to a generated Blob URL so there's no duplication involved for a given specifier. This approach could easily be expanded to <script> for script and JSON modules. There's nothing in this proposal that needs to be limited to stylesheets. Happy to chat about this at the sync tomorrow of you're available.

Copy link
Copy Markdown
Member

@annevk annevk left a comment

Choose a reason for hiding this comment

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

This still looks far from ready to me. I'm also wondering if we have a good story as to how we eventually want to make this consistent with the script element. Will that get a specifier attribute too?

Comment thread source Outdated
attribute boolean <span data-x="dom-style-disabled">disabled</span>;
[<span>CEReactions</span>, <span data-x="xattr-Reflect">Reflect</span>] attribute DOMString <dfn attribute for="HTMLStyleElement" data-x="dom-style-media">media</dfn>;
[SameObject, PutForwards=<span data-x="dom-DOMTokenList-value">value</span>, <span data-x="xattr-Reflect">Reflect</span>] readonly attribute <span>DOMTokenList</span> <dfn attribute for="HTMLStyleElement" data-x="dom-style-blocking">blocking</dfn>;
[SameObject, PutForwards=value, Reflect] readonly attribute DOMString specifier;
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.

This doesn't make sense.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Can you elaborate on this? Are you saying that specifier should be parser-only?

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.

The IDL is incorrect.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I believe it's correct now

Comment thread source

<ul>
<li><p>The element is popped off the <span>stack of open elements</span> of an <span>HTML
parser</span> or <span>XML parser</span>.</p></li>
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.

Is this just a subset of the next condition?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The next condition is inverted - "The element is not on the..."

This language already exists in https://html.spec.whatwg.org/#the-style-element:update-a-style-block

Comment thread source Outdated
<code data-x="dom-Blob-type">type</code> of "<code>text/css</code>".</p></li>

<li><p>Let <var>styleBlobURL</var> be the <span data-x="concept-url-blob-entry">blob URL entry</span>
associated with <var>styleBlob</var>.</p></li>
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.

This is not how this works. A Blob isn't automatically in a store and concept-url-blob-entry doesn't belong to Blob objects either. (Also, a blob URL entry is a struct, not a URL.)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This is super helpful, thank you! I updated it to account for this, but it could use another look.

Comment thread source
<var>styleBlobURL</var>.</p></li>

<li><p>Let <var>jsonString</var> be the result of calling <span>JSON.stringify</span> on
<var>jsonObject</var>.</p></li>
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.

This is wrong. We should be using Infra primitives for JSON.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

But "create an import map parse result" takes a string, should we change that to allow raw JSON?

Comment thread source Outdated
Comment thread source
<ol>
<li><p>Let <var>element</var> be the <code>style</code> element.</p></li>

<li><p>If <var>element</var> is not <span>connected</span>, then return.</p></li>
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.

It doesn't seem like this got addressed one way or another?

Comment thread source Outdated
@noamr
Copy link
Copy Markdown
Collaborator

noamr commented May 11, 2026

I think that the main issue with this solution, beyond it being tailor-made for styles and ignored for scripts, is that "shadow roots are able to export styles directly to the import map" using an attribute is exotic, as in inconsistent with both how shadow roots work and with how the import map work.

Usually shadow roots should not be able to implicitly affect what's outside of them, and this changes that: any shadow root can import something from a specifier and any shadow root can populate the style that corresponds to that specifier.

I understand the performance-driven use case for this (sharing styles between custom elements that are nested deep in the DOM without duplicating them) but I think we need to find a design that's less radical in terms of breaking shadow DOM encapsulation.

A solution I've brought up before for this was being able to somehow share a style based on an integrity digest, which guarantees that the key and values have some relationship between them, or alternatively having some sort of namespace for CSS modules that is overridable from inside shadow-roots (kind of like data- attributes).

@KurtCattiSchmidt
Copy link
Copy Markdown
Contributor Author

I'm also wondering if we have a good story as to how we eventually want to make this consistent with the script element. Will that get a specifier attribute too?

@annevk - yeah, that is addressed in the explainer - see the table under https://github.com/MicrosoftEdge/MSEdgeExplainers/blob/main/ShadowDOM/explainer.md#other-declarative-modules

@KurtCattiSchmidt
Copy link
Copy Markdown
Contributor Author

I think that the main issue with this solution, beyond it being tailor-made for styles and ignored for scripts, is that "shadow roots are able to export styles directly to the import map" using an attribute is exotic, as in inconsistent with both how shadow roots work and with how the import map work.

Usually shadow roots should not be able to implicitly affect what's outside of them, and this changes that: any shadow root can import something from a specifier and any shadow root can populate the style that corresponds to that specifier.

I understand the performance-driven use case for this (sharing styles between custom elements that are nested deep in the DOM without duplicating them) but I think we need to find a design that's less radical in terms of breaking shadow DOM encapsulation.

A solution I've brought up before for this was being able to somehow share a style based on an integrity digest, which guarantees that the key and values have some relationship between them, or alternatively having some sort of namespace for CSS modules that is overridable from inside shadow-roots (kind of like data- attributes).

I don't consider this as breaking encapsulation - the module map is already global and any element in a nested tree scope can already access the global map. Encapsulation is still preserved when styles are applied. This feature just adds ergonomics for style modules without needing a fetch or executing script, which is super useful to developers who want to stream declarative content.

@noamr
Copy link
Copy Markdown
Collaborator

noamr commented May 16, 2026

I think that the main issue with this solution, beyond it being tailor-made for styles and ignored for scripts, is that "shadow roots are able to export styles directly to the import map" using an attribute is exotic, as in inconsistent with both how shadow roots work and with how the import map work.

Usually shadow roots should not be able to implicitly affect what's outside of them, and this changes that: any shadow root can import something from a specifier and any shadow root can populate the style that corresponds to that specifier.

I understand the performance-driven use case for this (sharing styles between custom elements that are nested deep in the DOM without duplicating them) but I think we need to find a design that's less radical in terms of breaking shadow DOM encapsulation.

A solution I've brought up before for this was being able to somehow share a style based on an integrity digest, which guarantees that the key and values have some relationship between them, or alternatively having some sort of namespace for CSS modules that is overridable from inside shadow-roots (kind of like data- attributes).

I don't consider this as breaking encapsulation - the module map is already global and any element in a nested tree scope can already access the global map. Encapsulation is still preserved when styles are applied. This feature just adds ergonomics for style modules without needing a fetch or executing script, which is super useful to developers who want to stream declarative content.

This is different though. The import map is shared between styles and scripts and here you allow an in-shadow non-script markup to register entries into a map that is generally defined in the head. Eg <style specifier="app.js"> can allow a style to prevent a script from loading in certain situations. Not sure if it's a security risk but at the very least it seems messy for something that is mostly a performance optimization.

@KurtCattiSchmidt
Copy link
Copy Markdown
Contributor Author

I think that the main issue with this solution, beyond it being tailor-made for styles and ignored for scripts, is that "shadow roots are able to export styles directly to the import map" using an attribute is exotic, as in inconsistent with both how shadow roots work and with how the import map work.
Usually shadow roots should not be able to implicitly affect what's outside of them, and this changes that: any shadow root can import something from a specifier and any shadow root can populate the style that corresponds to that specifier.
I understand the performance-driven use case for this (sharing styles between custom elements that are nested deep in the DOM without duplicating them) but I think we need to find a design that's less radical in terms of breaking shadow DOM encapsulation.
A solution I've brought up before for this was being able to somehow share a style based on an integrity digest, which guarantees that the key and values have some relationship between them, or alternatively having some sort of namespace for CSS modules that is overridable from inside shadow-roots (kind of like data- attributes).

I don't consider this as breaking encapsulation - the module map is already global and any element in a nested tree scope can already access the global map. Encapsulation is still preserved when styles are applied. This feature just adds ergonomics for style modules without needing a fetch or executing script, which is super useful to developers who want to stream declarative content.

This is different though. The import map is shared between styles and scripts and here you allow an in-shadow non-script markup to register entries into a map that is generally defined in the head. Eg <style specifier="app.js"> can allow a style to prevent a script from loading in certain situations. Not sure if it's a security risk but at the very least it seems messy for something that is mostly a performance optimization.

Agreed, that's the biggest issue with this proposal. I wouldn't consider blocking a script a security problem though. And this can be avoided via CSP. The shadow-piercing aspect is super important for the scenarios that are streaming out SSR content.

@noamr
Copy link
Copy Markdown
Collaborator

noamr commented May 19, 2026

This is different though. The import map is shared between styles and scripts and here you allow an in-shadow non-script markup to register entries into a map that is generally defined in the head. Eg <style specifier="app.js"> can allow a style to prevent a script from loading in certain situations. Not sure if it's a security risk but at the very least it seems messy for something that is mostly a performance optimization.

Agreed, that's the biggest issue with this proposal. I wouldn't consider blocking a script a security problem though.

I consider this a blocker for this proposal.

And this can be avoided via CSP.

How? I don't get it.

The shadow-piercing aspect is super important for the scenarios that are streaming out SSR content.

I think we need an alternate design where those in-shadow styles are scoped in a way that doesn't interfere with light DOM imports. One way to do this is to scope it to a particular custom element registry.

e.g. you can use the specifier attribute but it doesn't register in the main importmap.
Instead, it registered in a style-specific import map of sorts that is specific to the custom element registry this element belongs to.
when you import a style inside a custom element's shadow root, it would look up in that registry's style import map and only then refer to the document's global import map.

This would need some development but I think we need something like that and that giving each in-shadow style element a free for all write permission on the head's importmap is a no-go.

@KurtCattiSchmidt
Copy link
Copy Markdown
Contributor Author

I consider this a blocker for this proposal.

It isn't actually a security issue though. In your scenario of <style specifier="app.js">, a subsequent import "app.js" will fail due to mismatched Import Attributes ("css" vs "javascript-or-wasm"). The only possible outcome is a failed fetch due to specifier overloading, which is already possible with Import Maps (including from in shadow roots).

And this can be avoided via CSP.

How? I don't get it.

The <style> tag already supports nonce - if you want to block certain style tags from adding specifiers, you can add a CSP and only set the nonce for known/supported <style> tags that you want to allow permission to update the Import Map.

As currently spec'd, this falls under the style-src CSP, but we could take the strictest subset between style-src and script-src to also block Import Map entries as script-src currently does. I brought this up at a prior WHATNOT meeting and it wasn't considered necessary, but that might have been incorrect. Would that behavior address this concern?

I think we need an alternate design where those in-shadow styles are scoped in a way that doesn't interfere with light DOM imports. One way to do this is to scope it to a particular custom element registry.

e.g. you can use the specifier attribute but it doesn't register in the main importmap. Instead, it registered in a style-specific import map of sorts that is specific to the custom element registry this element belongs to. when you import a style inside a custom element's shadow root, it would look up in that registry's style import map and only then refer to the document's global import map.

This would need some development but I think we need something like that and that giving each in-shadow style element a free for all write permission on the head's importmap is a no-go.

This is an interesting idea, I'll ask if it meets the needs of the parties interested in this feature. I am also unsure if it's necessary though - Import Maps aren't currently scoped by shadow root or custom element registries.

@noamr
Copy link
Copy Markdown
Collaborator

noamr commented May 20, 2026

I consider this a blocker for this proposal.

It isn't actually a security issue though. In your scenario of <style specifier="app.js">, a subsequent import "app.js" will fail due to mismatched Import Attributes ("css" vs "javascript-or-wasm"). The only possible outcome is a failed fetch due to specifier overloading, which is already possible with Import Maps (including from in shadow roots).
The <style> tag already supports nonce - if you want to block certain style tags from adding specifiers, you can add a CSP and only set the nonce for known/supported <style> tags that you want to allow permission to update the Import Map.

This proposal allows a <style> tag to prevent certain scripts from loading by corrupting an import map. It circumvents CSP script-src in that sense. Sure developers can add additional style-src protections for this but I think it makes the whole concept of import maps messier and more difficult to govern. I think this does make it a security issue.

btw would the work:

<template shadowrootmode=open>
  <script type=importmap>{ "theme": "data:text/css,* { color: red}"}</script>
</template>

If so, what does putting this in a style element attribute add here?

If we really want inline styles (and scripts) we can do something with IDs, like:

<template shadowrootmode=open>
  <script type=importmap>{ "theme": "#theme"}</script>
  <style id=theme>* { color: red; } </style>
</template>

... with some semantics about when the ID lookup is resolved. This can work for scripts as well and doesn't break the CSP protection of import maps.

btw a lot of these concerns were raised in #10673 and I'm surprised that this PR keeps being raised on WHATNOT when these weren't addressed in any satisfactory way.

@KurtCattiSchmidt
Copy link
Copy Markdown
Contributor Author

This proposal allows a <style> tag to prevent certain scripts from loading by corrupting an import map. It circumvents CSP script-src in that sense. Sure developers can add additional style-src protections for this but I think it makes the whole concept of import maps messier and more difficult to govern. I think this does make it a security issue.

This issue (and a few others) would be resolved by using a <script> tag instead of a <style> tag, but this was discussed at WHATWG early on with this proposal (and more recently with the CSS Working Group here) and <style> was overwhelmingly preferred due to 1) semantics of CSS in a <script> tag were unfavorable and 2) syntax highlighting and other tooling support wouldn't work.

So now we have this CSP mismatch. I agree that it's an issue, but I think taking the strictest CSP between script and style is a reasonable compromise here. Do you agree?

btw would the work:

<template shadowrootmode=open>
  <script type=importmap>{ "theme": "data:text/css,* { color: red}"}</script>
</template>

If so, what does putting this in a style element attribute add here?

Yes, that does work today (see https://codepen.io/Kurt-Catti-Schmidt/pen/OPbmzOq). The downsides to this approach are:

  1. URL parsing - URL's have different parsing than CSS, so things like quotes, spaces, tabs, and newlines will need to be escaped. This is bad ergonomics for developers, since CSS uses these extensively. With <style type="module">, we can do that behind the scenes and not need to introduce different parsing rules to CSS content.

  2. An earlier proposal of this actually used dataURI's under the hood and was discussed at WHATWG. The current Blob-based approach came out of WHATWG feedback based on memory and identity concerns - see Declarative CSS Modules and Declarative Shadow DOM adoptedstylesheets attribute #10673 (comment)

If we really want inline styles (and scripts) we can do something with IDs, like:

<template shadowrootmode=open>
  <script type=importmap>{ "theme": "#theme"}</script>
  <style id=theme>* { color: red; } </style>
</template>

... with some semantics about when the ID lookup is resolved. This can work for scripts as well and doesn't break the CSP protection of import maps.

I had also discussed using ID's in prior proposals. However, ID's are scoped to the light DOM, and this was a major blocking issue. Declarative Shadow DOM is often used in streaming HTML content, and it is a deal-breaker to need to stream back to the light DOM to define style modules. @justinfagnani provided this feedback in a different proposal that I had to address this problem, but it applies to this suggestion as well: #11019 (comment)

btw a lot of these concerns were raised in #10673 and I'm surprised that this PR keeps being raised on WHATNOT when these weren't addressed in any satisfactory way.

One of the challenges with this proposal is that it bridges several different existing concepts in HTML, and often the resolutions in some areas end up raising concerns in other areas, so we keep having circular conversations. It's been really difficult to get these issues to settle, but I do believe what I have currently is a reasonable compromise based on WHATWG feedback. I really appreciate the time you've spent giving feedback both here and in the working groups and really value the time the WHATNOT has spent discussing this.

@KurtCattiSchmidt KurtCattiSchmidt added the agenda+ To be discussed at a triage meeting label May 20, 2026
@justinfagnani
Copy link
Copy Markdown

Yes, IDs don't work because they are tree-scoped. We need identifiers that are not scoped, which is what makes specifiers useful.

Specifiers are also useful because they can point to external CSS files and because they share the same cache as import / with {type: 'css'}`. If they didn't share the same cache then the same URL used in HTML and an import would load the file twice and result in two separate stylesheets. We want to be able to load JS after the initial DOM and have it use the styles that were already delivered inline to the HTML without additional fetches and using the same stylesheet object.

I'm personally very ok with using <script> to contain CSS module scripts if that solves other problems. I'm not at all worried about supporting syntax highlighting for the output of SSR.

@noamr
Copy link
Copy Markdown
Collaborator

noamr commented May 21, 2026

This proposal allows a <style> tag to prevent certain scripts from loading by corrupting an import map. It circumvents CSP script-src in that sense. Sure developers can add additional style-src protections for this but I think it makes the whole concept of import maps messier and more difficult to govern. I think this does make it a security issue.

This issue (and a few others) would be resolved by using a <script> tag instead of a <style> tag, but this was discussed at WHATWG early on with this proposal (and more recently with the CSS Working Group here) and <style> was overwhelmingly preferred due to 1) semantics of CSS in a <script> tag were unfavorable and 2) syntax highlighting and other tooling support wouldn't work.

So now we have this CSP mismatch. I agree that it's an issue, but I think taking the strictest CSP between script and style is a reasonable compromise here. Do you agree?

What like protect the specifier attribute as if it was a script? I find that confusing.

I had also discussed using ID's in prior proposals. However, ID's are scoped to the light DOM, and this was a major blocking issue. Declarative Shadow DOM is often used in streaming HTML content, and it is a deal-breaker to need to stream back to the light DOM to define style modules. @justinfagnani provided this feedback in a different proposal that I had to address this problem, but it applies to this suggestion as well: #11019 (comment)

I get that IDs are tree scoped but as we said before importmap specifiers aren't, right?
So you could locate the importmap and the inline style that it references in the same shadow tree, and it would act like a global "export" of sorts. An importmap is a script tag so it would require script privileges anyway.

e.g.

<elem-a>
  <template shadowrootmode=open>
    <style id=local-theme> elem-a { color: blue } </style>
    <script type=importmap>{ "theme-a": "#local-theme" }</script>
  </template>
</elem-a>

<elem-b>
  <template shadowrootmode=open>
    <style id=local-theme> elem-b { color: green } </style>
    <script type=importmap>{ "theme-b": "#local-theme" }</script>
  </template>
</elem-b>

Comment thread source
<p>The <dfn element-attr for="style"><code data-x="attr-style-specifier">specifier</code></dfn>
attribute defines an exportable <span
data-x="specifier-resolution-record-specifier">specifier</span>.</p>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Maybe this could use a tiny bit clarification. specifier is a new thing to expose in any webidl interface, I think, and it isn't super clear to the reader what it might be about. The link to specifier doesn't really help much since that is equally vague.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeah, I agree that the existing definitions are vague. I'll see if there's a better place to link to, or if I need to add a definition here.

Comment thread source
all of the following conditions are true:</p>

<ul>
<li><p>The element is popped off the <span>stack of open elements</span> of an <span>HTML
Copy link
Copy Markdown

@smaug---- smaug---- May 21, 2026

Choose a reason for hiding this comment

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

The element? Which element exactly? Though, I guess some existing algorithms are equally vague.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeah, this is the language just above https://html.spec.whatwg.org/#update-a-style-block in the existing language. I'm happy to change it there too if there's a better way to phrase it.

Comment thread source
<li><p>Let <var>styleBlobURL</var> be the result of <span data-x="Add a Blob entry">adding a Blob entry</span>
to the <span>Blob URL Store</span>.</p></li>

<li><p>Create a JSON object <var>jsonObject</var> with a single key of "<code data-x="">imports</code>"
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

(Looks like the spec is equally vague on what is a "JSON object" elsewhere too, so fine)

@KurtCattiSchmidt
Copy link
Copy Markdown
Contributor Author

What like protect the specifier attribute as if it was a script? I find that confusing.

Not specifically the specifier attribute, I'm suggesting we gate processing the entire <style type="module"> based on the stricter CSP between style and script. So a script-src CSP of 'none' and a style-src CSP of 'unsafe-inline' would block the <style type="module"> processing due to the 'none' script-src CSP and vice versa. I'm not sure this will work across all of the CSP expressions and what to do if there are collisions in nonces though.

Alternatively, we could look at only the script-src CSP and ignore the style-src CSP, since this is more a script-like feature than an inline style. That's probably the simplest option, but seems somewhat non-intuitive.

Moving from <style type="module"> to <script type="css-module"> would address the CSP issue and be more similar to other potential inline modules. It would also automatically handle the "already started" behavior I wanted to discuss at the weekly sync today. Feedback was significantly against this approach, but perhaps the CSP aspect is enough of an argument for this approach.

I get that IDs are tree scoped but as we said before importmap specifiers aren't, right? So you could locate the importmap and the inline style that it references in the same shadow tree, and it would act like a global "export" of sorts. An importmap is a script tag so it would require script privileges anyway.

e.g.

<elem-a>
  <template shadowrootmode=open>
    <style id=local-theme> elem-a { color: blue } </style>
    <script type=importmap>{ "theme-a": "#local-theme" }</script>
  </template>
</elem-a>

<elem-b>
  <template shadowrootmode=open>
    <style id=local-theme> elem-b { color: green } </style>
    <script type=importmap>{ "theme-b": "#local-theme" }</script>
  </template>
</elem-b>

This idea is really clever, but I'm unclear of how the styles could be shared between shadow roots. This example defines two exports, but what would imports look like?

One issue is that all fragment identifiers in URL's are only accessible in the light DOM today, so #local-theme is only accessible from the light DOM. This is how all fragment identifiers currently work in HTML today (e.g. anchor fragments and <link rel=expect>). Also, since <elem-a> is a non-module style element, we'd have to define semantics for sharing it with other shadow roots, which modules and adoptedStyleSheets are already designed for.

@noamr
Copy link
Copy Markdown
Collaborator

noamr commented May 22, 2026

What like protect the specifier attribute as if it was a script? I find that confusing.

Not specifically the specifier attribute, I'm suggesting we gate processing the entire <style type="module"> based on the stricter CSP between style and script. So a script-src CSP of 'none' and a style-src CSP of 'unsafe-inline' would block the <style type="module"> processing due to the 'none' script-src CSP and vice versa. I'm not sure this will work across all of the CSP expressions and what to do if there are collisions in nonces though.

Alternatively, we could look at only the script-src CSP and ignore the style-src CSP, since this is more a script-like feature than an inline style. That's probably the simplest option, but seems somewhat non-intuitive.

Moving from <style type="module"> to <script type="css-module"> would address the CSP issue and be more similar to other potential inline modules. It would also automatically handle the "already started" behavior I wanted to discuss at the weekly sync today. Feedback was significantly against this approach, but perhaps the CSP aspect is enough of an argument for this approach.

This means that using style modules would require people to grant script CSP privileges. I don't think that's great security wise.

I get that IDs are tree scoped but as we said before importmap specifiers aren't, right? So you could locate the importmap and the inline style that it references in the same shadow tree, and it would act like a global "export" of sorts. An importmap is a script tag so it would require script privileges anyway.

e.g.

<elem-a>
  <template shadowrootmode=open>
    <style id=local-theme> elem-a { color: blue } </style>
    <script type=importmap>{ "theme-a": "#local-theme" }</script>
  </template>
</elem-a>

<elem-b>
  <template shadowrootmode=open>
    <style id=local-theme> elem-b { color: green } </style>
    <script type=importmap>{ "theme-b": "#local-theme" }</script>
  </template>
</elem-b>

This idea is really clever, but I'm unclear of how the styles could be shared between shadow roots. This example defines two exports, but what would imports look like?

One issue is that all fragment identifiers in URL's are only accessible in the light DOM today, so #local-theme is only accessible from the light DOM. This is how all fragment identifiers currently work in HTML today (e.g. anchor fragments and <link rel=expect>). Also, since <elem-a> is a non-module style element, we'd have to define semantics for sharing it with other shadow roots, which modules and adoptedStyleSheets are already designed for.

Sorry it would still be a module type or some such
Suggesting that the import map entries hold an implicit tree scope or something for resolving IDs so that they can be shared across trees. But it constraints this access to exported IDs and not to style modules in general.

I am not sure this is the right approach but I feel that the current proposal isn't there yet either.

@noamr
Copy link
Copy Markdown
Collaborator

noamr commented May 22, 2026

... Though I think in general we should find a solution to sharing styles between elements from the same class/registry that doesn't rely on exporting them to global scope or relies on elevating anything to script priveleges.

Eg

<style type=module scope=instance|class|registry id=foo> (default is instance) import style from "module:foo"

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

Labels

agenda+ To be discussed at a triage meeting

Development

Successfully merging this pull request may close these issues.

10 participants