Sharing Styles in Declarative Shadow DOM

EisenbergEffect
6 min readMay 7, 2024

Recently, I wrote about how to use global styles in Shadow DOM. It turned out to be relatively simple, using just a tiny bit of JavaScript. In this post, I’d like to focus in on a similar but slightly different scenario, specifically with Declarative Shadow DOM (DSD).

The Problem

As a reminder, using DSD, we can generate HTML on the server that sets up client-side encapsulation not just for HTML rendering but also for the styles related to that HTML. Here’s a simple “hello world” example:

<hello-world>
<template shadowrootmode="open">
<style>
:host { display: block; color: red; }
</style>
Hello World
</template>
</hello-world>

In the above HTML, <hello-world>serves as our DSD "host". Inside of the host element, we have a <template> element with the shadowrootmode="open" attribute. This does not define a template. This declaratively creates a shadow root that is attached to the host. Then, inside the shadow root, we have a <style> element with some styles that target the host itself. We also have the text "Hello World".

What if we want to have three instances of our <hello-world> tag? That would look like this:

<hello-world>
<template shadowrootmode="open">
<style>
:host { display: block; color: red; }
</style>
Hello World
</template>
</hello-world>
<hello-world>
<template shadowrootmode="open">
<style>
:host { display: block; color: red; }
</style>
Hello World
</template>
</hello-world>
<hello-world>
<template shadowrootmode="open">
<style>
:host { display: block; color: red; }
</style>
Hello World
</template>
</hello-world>

Now, we can begin to see the problem: duplication. The HTML duplication is something we’re used to. Every server-only HTML solution handles components in this way and has for over two decades. What we’re not used to seeing is the duplicated CSS. Usually, that would be extracted to a stylesheet and then linked. But we can’t do this in DSD without a flash of un-styled content (FOUC). And we don’t want that. While the above duplication is not a big deal, due to its small amount of CSS, we need a solution to this problem for any real-world use of DSD.

So, how can we declaratively encapsulate our HTML and styles while avoiding both style duplication and FOUC?

NOTE: If you are interested in potentially eliminating the duplicate HTML, that’s something I hope will be possible in the future as well, via HTML Modules, Declarative Custom Elements, and other future declarative features.

Learn Web Component Engineering with veteran UI architect and engineer, Rob Eisenberg.

Interested in learning more about DSD, Web Components, and other related Web Standards? Check out the Web Component Engineering course I created, trusted by giants such as Adobe, Microsoft, Progress, and Reddit. Group rates for teams and PPP pricing available upon request.

The Solution

One solution to this problem is to create a custom element, its sole purpose being to facilitate style sharing across DSD boundaries. The element, let’s call it <shared-style>, will take a style-id and use it to look up styles in a local cache. If the styles are there, it will simply add them to the shadow root and remove itself. If the styles are not there, it will find them by id in the current scope, cache them, and then remove itself.

For this to work, we need a few things:

  • The server must be aware of the <shared-style> element when generating HTML (for de-duping).
  • Styles must have a unique id associated with them (for de-duping).
  • The <shared-style> element must be defined up-front, before any use of DSD (to avoid FOUC).

We aren’t going to show a server implementation in this post, but one can imagine the server doing something like the following:

  • When trying to emit styles, look up whether the styles have already been emitted during the current request.

If the styles have not been emitted:

  • Generate a unique id for the styles.
  • Emit a <style id="unique-id"> element into the DSD for the styles.
  • Emit a <shared-style style-id="unique-id"> element into the DSD, referencing the previously emitted styles.
  • Track that the response now includes the styles. (A map from style text to id would do the trick.)

If the styles have already been emitted:

  • Emit a <shared-style style-id="unique-id"> element into the DSD, referencing the previously emitted styles.

When the browser receives the HTML, it will look something like this:

<hello-world>
<template shadowrootmode="open">
<style id="hello-world">
:host { display: block; color: red; }
</style>
<shared-style style-id="hello-world"></shared-style>
Hello World
</template>
</hello-world>

<hello-world>
<template shadowrootmode="open">
<shared-style style-id="hello-world"></shared-style>
Hello World
</template>
</hello-world>

<hello-world>
<template shadowrootmode="open">
<shared-style style-id="hello-world"></shared-style>
Hello World
</template>
</hello-world>

Notice that the styles only appear in the markup the first time they are needed. Everywhere else references them via their unique id instead.

IMPORTANT: An alternative solution to the above would be to try to side-step the problem altogether. For example, enabling HTTP Compression would solve the issue of increased payload due to duplicate styles. You’ll want to test both approaches with your own application before making a decision about which one or combination to use.

The Implementation

With the high-level solution worked out, let’s look at an implementation for <shared-style>:

const lookup = new Map();

class SharedStyle extends HTMLElement {
connectedCallback() {
const id = this.getAttribute("style-id");
const root = this.getRootNode();
let styles = lookup.get(id);

if (styles) {
root.adoptedStyleSheets.push(styles);
} else {
styles = new CSSStyleSheet();
const element = root.getElementById(id);
styles.replaceSync(element.innerHTML);
lookup.set(id, styles);
}

this.remove();
}
}

customElements.define("shared-style", SharedStyle);

The <shared-style> element will have its connectedCallback() invoked by the browser as it streams the HTML into the DSD. At this point, our element will read its style-id attribute and use it to look up the styles in its cache.

If the cache already has an entry for the id:

  • The element adds the styles to the adoptedStyleSheets collection of the containing shadow root.
  • Then, the <shared-style> removes itself from the DSD.

If the styles are not present in the cache:

  • First, the element constructs a CSSStyleSheet instance.
  • Second, it locates the <style> element inside the containing DSD using the id.
  • Third, the <style> element's contents are used to provide the styles for the CSSStyleSheet.
  • Fourth, the style sheet is cached.
  • And finally, the <shared-style> element removes itself from the DSD.

There are a couple other details of the implementation worth pointing out:

  • We use this.getRootNode() to find the shadow root that the <shared-style> element is inside of. If it’s not inside of a shadow root, this API will return the document, which also has an adoptedStyleSheets collection.
  • If it is the first time <shared-style> is seeing a particular style-id, it doesn't need to push the styles into the adoptedStyleSheets of the root because an in-line <style> element is already present, fulfilling the same purpose.

That’s all there is to it. Don’t forget to include the small bit of JS above inline before any use of <shared-style> and you'll be able to share any number of styles across shadow root boundaries, with full support for DSD streaming and no FOUC.

Wrapping Up

Ideally, we’d be able to share styles across DSDs without any JavaScript. In fact, there is a proposal for a fully declarative way of doing this. But in the meantime, we can solve these types of issues ourselves, using our knowledge of how DSD works and filling in the gaps with a very small amount of code.

--

--

EisenbergEffect

W3C, WICG & TC39 contributor focused on Web Standards, UI Architecture & Engineering Culture. Former Technical Fellow, Principal Architect & VP-level tech lead.