Dec 18

Declarative Shadow DOM

Declarative shadow DOM enables seamless server-side rendering for the web platform.

By Schalk Neethling

What we are able to build using the web platform has gone into overdrive, especially in the last two years. Most of the action has been in the areas of CSS and JavaScript, but there have also been some important additions to HTML.

However, one piece of the puzzle has proven to be elusive: the ability to build reusable and isolated components.

But wait, what about web components? I hear you ask, and you would be correct. With the HTML template element, custom elements, and the shadow DOM finally getting widespread adoption in user agents, we can build components that satisfy most of our needs.

The Problem

Leaving the complaints about the general developer experience when writing web components aside for the moment, there is one rather striking challenge that still has most web developers reaching for a framework: server-side rendering and/or static site generation.

There are multiple reasons we may want to take advantage of server-side rendering. Some of these are related to performance and accessibility, but also to search engine optimization, which is still an important topic in 2024, even though Perplexity and friends might want us to believe differently.

I would much prefer to know that users can access a page's content even if JavaScript fails to execute for whatever reason. Also, some applications (both desktop and web-based) do not execute JavaScript and instead opt to provide the user with a clean and distraction-free reading environment.

In these instances, web components will let you and your users down.

But…

The web and the technologies that power it are constantly evolving, and so too have web components. In this article, I will first highlight the challenge that has made using web components, specifically the shadow DOM, a non-starter for many projects. But then, we will explore an evolution of shadow DOM that is already well-supported in modern browsers, known as declarative shadow DOM, and learn how it breaks down one of the final obstacles and enables us to write reusable, isolated web components that can be server-side rendered or used as part of a statically generated website.

The Problem with Shadow DOM

To understand some of the challenges with shadow DOM and why you would want to use It, we will build a simple user card component. We do have a couple of requirements that need to be met when building and using this component.

Above is an example use case for the card component we will be building.
Above is an example use case for the card component we will be building.

Our Requirements

That is quite a set of requirements, but they are not uncommon when building software and websites for the web.

The HTML

Let us start by satisfying our first requirement: we want clean, semantic, and accessible HTML.

<nimbus-team></nimbus-team>

<template id="nimbus-team-user-card-tmpl">
<li>
<article class="user-card">
<h2 class="user-card-title"></h2>

<img class="user-card-avatar" src="" height="150" width="150" alt="" />

<span class="user-card-role"></span>
<span class="user-card-email">
<span class="visually-hidden">Email:</span>
<a href=""></a>
</span>
<span class="user-card-phone">
<span class="visually-hidden">Phone:</span>
<a href=""></a>
</span>

<ul class="user-card-social">
<li>
<a class="icon icon-social-bsky" href="" target="_blank" rel="noopener noreferrer">
<span class="visually-hidden">Follow @employee on @platform</span>
</a>
</li>
<li>
<a class="icon icon-social-linkedin" href="" target="_blank" rel="noopener noreferrer">
<span class="visually-hidden">Follow @employee on @platform</span>
</a>
</li>
<li>
<a class="icon icon-social-mastodon" href="" target="_blank" rel="noopener noreferrer">
<span class="visually-hidden">Follow @employee on @platform</span>
</a>
</li>
</ul>
</article>
</li>
</template>

That is a lot of HTML! For the most part, though, you have most likely seen all of it before. The only two items that might be new are the nimbus-team element and the template element. The first, nimbus-team is our custom element that does not really exist yet. The second is the template element we are using to well house our user card template.

There are some optimizations we can make to this template when we use it within a framework such as Astro, but for the moment, we will focus more on the more “traditional” way you would build this type of web component even when not inside a framework such as Astro. We will take full advantage of it later when we refactor this to use declarative shadow DOM.

If you open the HTML document in your browser right now, you will be presented with a blank page. This is because our custom element has not been defined and registered yet, and the content of a template element is not visible when viewed in a browser.

The initial custom element JavaScript

To address the first part, we need to add some JavaScript. In a folder called js, at the root of your project, create a new .js file called nimbus-team.js. Add the following to this file:

class NimbusTeam extends HTMLElement {
static #selectors = {
UserCardTmpl: "nimbus-team-user-card-tmpl",
};

#shadow;

constructor() {
super();
this.#shadow = this.attachShadow({ mode: "open" });
}

connectedCallback() {
const template = document.getElementById(
NimbusTeam.#selectors.UserCardTmpl
);

if (template) {
const content = template.content.cloneNode(true);
this.#shadow.appendChild(content);
}
}
}

customElements.define("nimbus-team", NimbusTeam);

Let’s break down what is happening here. We start by defining a class for our component and extending the HTMLElement base class so that we can inherit from it. We set two private JavaScript properties (those marked with #), one of which is marked as static.

Next, we define our constructor, and critically, we call super.

Next, we attach our shadow DOM root to our component and store a reference in our private #shadow property. Custom elements have several life cycle events with associated predefined functions (this will sound very familiar to you if you have been using almost any of the popular frontend JavaScript frameworks).

Here we are using the connectedCallback to insert the templates of our content into the shadow DOM we attached a little earlier. It is important to note that we do not simply get a reference to the template element and insert it into the shadow DOM. What we need to do instead and clone the content of the template and then inject this into the shadow DOM.

Finally we register our custom element using customElements.define. To avoid potential conflicts with native HTML element, custom element must always be hyphenated. The second argument to the function call is a reference to our class.

We need to do one more thing before we start seeing the fruits of our labor in the browser. Back in the HTML document, just before the closing body tag, add the following script element.

<script type="module" src="js/nimbus-team.js"></script>

The CSS

When you reload your browser, you will see our user card. Of course, it is a bit of a mess and completely unstyled right now.

I have provided the social media icon SVG files and the CSS, which you can find in this GitHub commit.

With the CSS and icons in place, we can link the stylesheet in the head of our HTML document.

<link href="css/user-card.css" rel="stylesheet" type="text/css" media="screen">

If you had reloaded the HTML page at this stage, absolutely nothing would have changed. But why? This result is because the shadow DOM is doing exactly what it is supposed to and what we want based on our second requirement.

We want our CSS to be scoped to our card component but, we do not want to have to write our CSS in JavaScript and would prefer to have our component CSS in its own CSS source file.

The shadow DOM is encapsulated, and we cannot pierce it from the outside (see the note below). As such, the CSS has no effect. There are several ways to get our CSS to be part of the shadow DOM, but in this article, we will load and apply it using fetch.

Fetching the CSS

Because we will fetch and apply our CSS using JavaScript, we can remove the link element from the HTML, as it will not be needed. We will add the following function to our JavaScript to load and apply our CSS.

async #loadCSS() {
const style = document.createElement("style");
const response = await fetch("css/user-card.css");

if (response.ok) {
style.textContent = await response.text();
this.#shadow.appendChild(style);
}
}

The code here is rather simple. We create a style element, load our CSS file using fetch and if it was successful, we get the text from the response and set it as the text content of the style element. Lastly we append the new style tag to our shadow DOM.

There is one more thing: we need to call our new function. We can do this either in the constructor or the connected callback as long as it is called after the shadow DOM root has been attached. I opted for doing so in the constructor.

constructor() {
super();
this.#shadow = this.attachShadow({ mode: "open" });
this.#loadCSS();
}

Reloading the page now will show a still rather broken card, but as you will note, some of our global CSS, notably the custom properties, have pierced the shadow DOM. If you read the linked post from earlier, this will be expected and is actually pretty neat!

We have met our second requirement, but if you look in the developer tools’ elements panel, you will notice that the browser is actually being pretty forgiving. 🙃 Right now, we are injecting a list element without a parent, which breaks our first requirement of clean, semantic, and accessible HTML

Let’s fix this.

Some more DOM manipulation

To fix our HTML, we will add the following to our connectedCallback:

const listContainer = document.createElement("ul");
...

listContainer.classList.add("card-list");
listContainer.appendChild(content);

With this, our DOM is fixed, but we have a problem. The visually-hidden class from our global CSS file is not having any effect (as we would expect). To remedy this, we will unfortunately have to either load the global CSS file (not recommended) or duplicate the class into the CSS file we are already loading.

With this change (view the commit here on GitHub), we are getting close to our final implementation.

Loading the data

To get some fictional data for our card and satisfy out next requirement, we will use a simple Netlify serverless function I spun up called Fictional Folks. We will first add a private Class property called #users and then add a function to load and store our user data in this property.

The data for each card will come from an external API, so we need a way to load it.

#users;

async #loadUsers() {
const response = await fetch(
"https://fictionalfolks.netlify.app/.netlify/functions/users?count=1"
);

if (response.ok) {
this.#users = await response.json();
}
}

With the above we will get one random user each time we reload our page. The code to actually update the user card with the data is quite a lot but there is nothing super interesting so, instead of listing all of it here, please review the commit on GitHub for all of the details (I do quite a bit of refactoring in the commit so, please read over the changes in the commit carefully).

You should now have a successfully rendered user card with all of the relevant data filled in and functional! You can also play around with the API call and see what happens when you load more than a single user.

A fork in the road

We have now reached the point where we will start to hit the limitations of this version of our web component. If we review the remaining requirements, we will quickly see that we cannot satisfy them.

On the last item, I will leave it up to you to determine how close or far we are from a great developer experience. While the experience thus far has definitely not been terrible, it has not been stellar either. To be honest, there is quite a bit one can do to optimize some of the code, but we will definitely still be left wanting.

Declarative shadow DOM to the rescue!

Before we dig into this part, I want to make clear that I will primarily share the coding parts that change. I will link to the repository with the full code, but to not stretch out this post, I will refrain from sharing all of the step-by-step details where they are not absolutely required.

Also, for this part I will hard code the data for the user card but, I will also link to an example where I use the same declarative component within the context of an Astro project where the data and construction of the cards will happen at build time.

With those stated caveats, let’s refactor!

The template

We will change two things concerning our template element.

  1. We will move it inside our custom element
  2. Remove the id attribute and instead add the shadowrootmode attribute
<nimbus-team>
<template shadowrootmode="open">
<ul class="card-list">
<li>
<article class="user-card">
<h2 class="user-card-title">Marlowe Solace</h2>
...

<ul class="user-card-social">
<li>
<a class="icon icon-social-bsky" href="https://bsky.app/profile/marlowe.solace" target="_blank" rel="noopener noreferrer">
<span class="visually-hidden">Follow MArlowe on Bluesky</span>
</a>
</li>
...
</ul>
</article>
</li>
</ul>
</template>
</nimbus-team>

You can review the full HTML as part of the following commit on GitHub.

If you started a new HTML file for this part of the article, also make sure that you have linked the global CSS in the head as we did in our previous example. Now for the first mind-blown moment. If you open this file in your browser, you will see an unstyled instance of the card.

No JavaScript. What!?

If we link the user-card.css file in the head and reload the page, you will still find that the CSS does not impact the card. What is happening here?

This is the beauty of declarative shadow DOM. The browser is simply ignoring the unknown nimbus-team element and parsing the next line of HTML. The word "parsing" is critical here as this conversion from template to shadow-root happens through the HTML parser which is why we do not need any JavaScript yet and why this can now be part of a server-side rendered or statically generated website or application.

You can see this by inspecting the DOM using the elements panel of the browser developer tools.

Shows the declaratively created shadow-root with the nested card list.
Shows the declaratively created shadow-root with the nested card list.

The Shadow and the Custom Element

Something else is also happening, though, as soon as we register our custom element, so let’s do that. Feel free to create a new blank JavaScript file as we will need very little of the previous JavaScript. All that is in my .js file now is the following:

class NimbusTeam extends HTMLElement {
constructor() {
super();
}
}

customElements.define("nimbus-team", NimbusTeam);

Notice that we have not called attachShadow anywhere in the script yet. And we will not need to. Because our static HTML is now being upgraded due to us registering the custom element, the browser has also already exposed the shadow-root to the custom element.

This means adding the following to the code above will indeed return an instance of the shadow-root and not null:

console.log(this.shadowRoot);

It is important to know that calling this.attachShadow inside a custom element that already has a declarative shadow-root attached will not throw an error. Instead, the Declarative Shadow Root will be emptied and returned. So, be careful here, as it literally means what it says. If you call this.attachShadow, everything that was added to the shadow-root during parsing will be removed, and you will need to rebuild the DOM for the shadow DOM. In other words, our entire unordered list and all of its children will be removed.

Although we can check for the existence of our shadowRoot using this.shadowRoot the shadowRoot is now also exposed on ElementInternals. ElementInternals is an entirely other topic, but it essentially means we can do the following:

class NimbusTeam extends HTMLElement {
constructor() {
super();

const internals = this.attachInternals();
let shadow = internals.shadowRoot;

if (!shadow) {
// we can now safely attach out shadowRoot without worrying
// that we will blow away any existing shadow DOM elements
shadow = this.attachShadow({ mode: "open" });
}
}
}

customElements.define("nimbus-team", NimbusTeam);

Depending on your use case, you may or may not need to do any of the above. I would say that if you know that the underlying HTML uses declarative shadow DOM, you can assume that a shadowRoot will exist and can proceed. The one use case where you may need to do this is if you need to support browsers that do not yet support declarative shadow DOM.

The folks over at Salesforce did quite a deep dive into polyfilling declarative shadow DOM, but there is also a simplified polyfill available in the Declarative Shadow DOM post on Web.Dev.

If you choose not to go with a polyfill, remember that in instances where declarative shadow Dom is not supported, the original template element will still be present in the DOM and can, therefore, be referenced, cloned, and inserted into the manually created shadow-root (this means you may want to keep the id attribute around for this purpose—choose your own adventure 🙃).

CSS in the Declarative Shadow

You may be a little disappointed here because all we need to do to get our CSS to work and be scoped to our component is to take the link element that is currently in the head and move it into the template element like so:

<nimbus-team>
<template shadowrootmode="open">
<link href="css/user-card.css" rel="stylesheet" type="text/css" media="screen">
...
</template>
</nimbus-team>

With this, you can go ahead and reload the page. As before, though, the visually-hidden class is not going to pierce our shadow, and we will need to copy it into our user card CSS file. To further prove to ourselves that our component is isolated and that only CSS we want to pierce the shadow get through and nothing leaks out, add the following to the HTML just before the custom element:

<h2>Marlowe Solace outside the Shadow</h2>

In global.css add the following:

h2 {
color: hotpink;
}

And inside user-cards.css change .user-card-title to h2:

h2 {
font-size: var(--typography-font-size-heading);
margin: 0;
}

When you reload the page, you will see that the outer heading is not affected by the font-size setting inside the user-card and is hotpink. You will also notice that the h2 inside our component is our dark purple color and not hotpink.

Some common questions

What about including the same component with the same CSS multiple times on the same page?

This is not a problem at all, and browsers are highly optimized for this exact case. To quote from the article on web.dev:

The browser uses a single backing CSSStyleSheet that is shared by all of the shadow roots, eliminating duplicate memory overhead.

What about a Flash Of Unstyled Content (FOUC)?

This is another problem that goes away with declarative shadow DOM. Should you need or prefer to support browsers in which declarative shadow DOM is not supported, I would recommend that you read the section in the web.dev article that touches on this very topic.

Could you generate the template server side for non-declarative shadow DOM?

You surely can, but your content will still not be available in the following cases:

  1. JavaScript fails or is not executed at all
  2. Search engine indexing

To clarify the second point here. As of the writing of this post at the tail-end of 2024, only Google’s crawler will crawl with JavaScript enabled for indexing when needed. None of the other large search engines that maintain their own index does this. This means that the content inside the template element will not be indexed.

There may also be some performance impacts to consider should one be cloning multiple large template elements and inserting them into the DOM. I have not done extensive testing in this regard, but it is something to keep in mind.

You can try out an example of this in the Shadow Core example project.

Additional Resources

Schalk Neethling

Schalk Neethling

Schalk Neethling is a passionate front-end engineer, podcast host, mentor, and open web and web accessibility evangelist. He strives to be a kind-hearted advocate for mental health and is committed to spreading kindness, mindfulness, and awareness about mental illness, devoid of stigma. His role as a mentor has shown him the joy of sharing knowledge and empowering others. He is dedicated to improving the world by amplifying voices with similar visions, striving to create a brighter future, one opportunity and voice at a time.

Schalk selected Distribute Aid for an honorary donation of $50

Distribute Aid

Distribute Aid is a nonprofit that enhances humanitarian logistics by supporting grassroots aid organizations with scalable infrastructure. Its mission is to meet basic human needs through efficient aid delivery while empowering local communities.