- Kurt Catti-Schmidt
- Daniel Clark
- Tien Mai
- Alison Maher
- Andy Luhrs
This document is intended as a starting point for engaging the community and standards bodies in developing collaborative solutions fit for standardization. As the solutions to problems described in this document progress along the standards-track, we will retain this document as an archive and use this section to keep the community up-to-date with the most current standards venue and content location of future work and discussions.
- This document status: Active
- Expected venues: WHATWG and the CSS Working Group
- Current version: this document
Modern web development practices have converged on reusable components rather than monolithic documents. Shadow DOM provides style isolation for those components, and Declarative Shadow DOM (DSD) makes them practical for server-rendered markup. In practice, however, that isolation can cause the same CSS to be duplicated across many shadow roots.
CSS module scripts
let JavaScript import a stylesheet as a CSSStyleSheet object that can be
applied to multiple tree scopes. Import maps can declaratively control how
module specifiers resolve, but the platform currently requires imperative
JavaScript to apply the resulting stylesheet to each scope.
This explainer proposes a declarative way to resolve a module specifier, fetch
the result as a CSS module, and apply the resulting shared stylesheet to a tree
scope using <link rel="stylesheet" type="module">.
Authors can place a <style> element in each declarative shadow root, but a
page containing many instances of a component must then transmit and parse the
same CSS many times. This increases payload size, CPU cost, and memory use.
Classic <link rel="stylesheet"> elements avoid duplicating the CSS source in
the HTML and can use the HTTP cache, but each element has its own associated
stylesheet. Constructed stylesheets and CSS module scripts can share one
stylesheet object, but applying them currently requires JavaScript and can
produce the same delayed styling problem.
These limitations are especially costly for streaming server-side rendering. Nested and sibling components can arrive incrementally and may need the same base styles, even though their shadow roots cannot directly reference one another. A module specifier resolved through the document's module map gives all of those scopes a common, declarative reference.
- Allow developers to declaratively apply the same CSS module stylesheet to multiple tree scopes.
- Allow the CSS to reside in an externally cacheable resource while reusing the CSS module instance for the same resolved URL and module type within the relevant module map.
- Avoid patterns that would cause measurable performance regressions, such as stylesheet duplication that cannot be deduplicated.
- Defining or exporting inline declarative CSS modules. That can be developed independently from the import mechanism proposed here.
- Allowing selectors to cross a shadow boundary or otherwise weakening Shadow DOM encapsulation.
- Making module
<link rel="stylesheet">elements identical in every respect to classic<link rel="stylesheet">elements,<link rel="modulepreload">elements,<style>elements, oradoptedStyleSheetsusage.
We propose a module mode for <link rel="stylesheet">, selected by
type="module". In this mode, the value of href is resolved as a module
specifier through import map processing, using the module
<link rel="stylesheet"> element's base URL. The resolved URL is imported as
a CSS module. Once the module has loaded, its stylesheet is applied to the
module <link rel="stylesheet"> element's tree scope.
For clarity, this explainer calls the existing form a classic
<link rel="stylesheet"> element and the proposed form a module
<link rel="stylesheet"> element.
<script type="importmap">
{
"imports": {
"foo": "https://example.com/foo.css"
}
}
</script>
<my-element>
<template shadowrootmode="open">
<link rel="stylesheet" type="module" href="foo">
<p>Inside Shadow DOM</p>
</template>
</my-element>Here, https://example.com/foo.css contains:
p { color: blue; }The text inside the shadow root is styled blue. The href value is resolved as
a module specifier using the import map, the resolved URL is fetched as a CSS
module, and the resulting stylesheet is applied to the shadow root. The shared
CSSStyleSheet appears in the shadow root's styleSheets collection.
Within a module map, module <link rel="stylesheet"> elements whose href
values resolve to the same URL use the same module map entry, keyed by that URL
and the CSS module type. They therefore apply the same CSSStyleSheet object.
The following example applies one module stylesheet to the document tree and to two distinct shadow roots:
<html>
<head>
<script type="importmap">
{
"imports": {
"foo": "https://example.com/foo.css"
}
}
</script>
<link rel="stylesheet" type="module" href="foo">
</head>
<body>
<p>Text in the document tree</p>
<first-element>
<template shadowrootmode="open">
<link rel="stylesheet" type="module" href="foo">
<p>Inside the first shadow root</p>
</template>
</first-element>
<second-element>
<template shadowrootmode="open">
<link rel="stylesheet" type="module" href="foo">
<p>Inside the second shadow root</p>
</template>
</second-element>
</body>
</html>Once all three module links have loaded, importing the same module resolves to the existing module map entry without another fetch:
const foo = (await import("foo", { with: { type: "css" } })).default;
foo.replaceSync("p { color: green; }");The update changes the text in all three scopes to green because every module
link applies the same underlying CSSStyleSheet object. This is not possible
with classic <link rel="stylesheet"> elements, for which each link has its
own associated stylesheet.
An import map is only needed to remap a module specifier. As with a JavaScript
module import, a relative URL can be used directly and is resolved against the
base URL of the module <link rel="stylesheet"> element.
The following example applies ./foo.css to two shadow roots without an import
map:
<first-element>
<template shadowrootmode="open">
<link rel="stylesheet" type="module" href="./foo.css">
<p>Inside the first shadow root</p>
</template>
</first-element>
<second-element>
<template shadowrootmode="open">
<link rel="stylesheet" type="module" href="./foo.css">
<p>Inside the second shadow root</p>
</template>
</second-element>Each href resolves ./foo.css against its link element's base URL. When both
links resolve to the same URL, they use the same CSS module map entry and apply
the same underlying CSSStyleSheet object. A JavaScript import that resolves
to that URL and module type also reuses the entry:
const foo = (await import("./foo.css", { with: { type: "css" } })).default;
foo.replaceSync("p { color: green; }");After the update, the text in both shadow roots is green.
Although the underlying CSSStyleSheet object is shared, it is deliberately
exposed through styleSheets instead of adoptedStyleSheets. Exposing a
declaratively linked sheet through adoptedStyleSheets would allow script to
remove or reorder the entry independently of its corresponding module link,
breaking synchronization between DOM order and the applied stylesheet list.
The presence and state of qualifying module links instead control membership
in the read-only styleSheets collection, and their tree order controls the
order of its entries. This distinction concerns the mutability of collection
membership; the shared CSSStyleSheet object itself remains mutable, as shown
above. The proposal therefore extends the CSSOM definition of which sheets are
represented by styleSheets, introducing a DOM-associated constructed
stylesheet.
Module and classic stylesheet links share fundamental HTMLLinkElement
behavior, but they cannot be identical because module links import a shared,
constructed stylesheet using module fetch semantics.
Existing behaviors that are not inherently tied to classic stylesheet fetches
or one-to-one stylesheet ownership will also apply to module links. For
example, the nonce attribute and the load and error events apply to
module <link rel="stylesheet"> elements.
Because the proposal builds on CSS module script imports, the associated stylesheet is constructed. Existing constructed stylesheet behavior therefore produces several differences from classic stylesheet links:
- CSS module scripts do not support CSS
@importrules. - A constructed stylesheet always has a
nullownerNode. - An empty-prelude
@scoperule normally derives its scoping root from the stylesheet'sownerNode. A constructed sheet cannot derive that root from a module link, so rule matching differs from a classic stylesheet link in this case.
Module links use the existing constructed stylesheet behavior in each case.
Classic stylesheet links have a one-to-one association with their
CSSStyleSheet objects. Module links deliberately allow many elements to share
one object. Attributes such as media and title therefore cannot be mapped
directly to the shared stylesheet when different module links specify different
values. For this proposal, we plan on ignoring media and title entirely.
Classic stylesheet and CSS module fetches differ. A classic stylesheet link
creates a potential-CORS request that uses no-cors mode by default; its
crossorigin attribute can opt into CORS. Module script requests use cors
mode, so a cross-origin response must pass a CORS check.
Their decoding also differs. Classic stylesheets can use response-provided or legacy encoding information, while module script responses are always decoded as UTF-8. Module stylesheet links inherit the stricter module script fetch and decoding semantics, with no option to fall back to the classic behavior.
This proposal originally introduced a shadowrootadoptedstylesheets attribute
on the <template> element, which accepts a space-separated list of
module specifiers and adds the corresponding CSS module exports to the shadow
root's adoptedStyleSheets list.
<script type="importmap">
{
"imports": {
"foo": "https://example.com/foo.css"
}
}
</script>
<my-element>
<template shadowrootmode="open" shadowrootadoptedstylesheets="foo">
<p>Inside Shadow DOM</p>
</template>
</my-element>This design closely maps to the imperative adoptedStyleSheets API and avoids
adding a link node for each stylesheet. Based on feedback, this proposal now uses
existing link semantics, exposes load and error events at the point of use, and
keeps stylesheet membership and ordering synchronized with the DOM.
Local References in Link Rel allow a shadow root to reference a stylesheet definition found through tree-scoped lookup. They use existing ID-reference concepts and can share a light-DOM stylesheet with descendant shadow roots, but they do not provide module specifier resolution or allow a definition in one shadow root to be exported to unrelated roots.
The @sheet proposal defines multiple named sheets
in CSS, while the
adoptStyles proposal
would let a shadow root inherit selected styles or layers. These approaches
integrate more directly with CSS, but their identifiers are tree-scoped unless
additional cross-shadow behavior is defined. Nested shadow roots would need to
pass styles through each level, which does not address all streaming SSR cases.
Inline module definitions could instead change module map keys from
(URL, module type) pairs to (string, module type) pairs, require fragment
identifiers, or introduce a local-reference URL scheme. Those options create
new interactions with import maps, document base URLs, fragment navigation,
shadow-tree scoping, and custom scheme handlers. The primary proposal avoids
those changes by resolving href with the existing module system.
For specific details on each property and how it is expected to work, please see the planning document
This consolidated explainer includes work, feedback, and advice from:
- Alison Maher
- Alex Russell
- Andy Luhrs
- Anne van Kesteren
- Daniel (Dan) Clark
- Emilio Cobos Álvarez
- Hoch Hochkeppel
- Jake Archibald
- Jeffrey Yasskin
- Justin Fagnani
- Keith Cirkel
- Kurt Catti-Schmidt
- Lea Verou
- Mason Freed
- Noam Rosenthal
- Steve Orvell
- Tien Mai