DEV Community

Cover image for Shining a Light on Shadow DOM
Besworks
Besworks Subscriber

Posted on

Shining a Light on Shadow DOM

If you've been following along with this series you may have noticed that the components we have created so far all suffer from an issue known as FOUC which stands for Flash of Unstyled Content. This happens because—just like any scripts that manipulate the DOM—the custom element definitions we have created so far have to come after the content has been parsed in order to work with that content.

As a result, when the DOM is parsed our custom tags are initially treated as generic HTML elements and rendered without any of the structure or styles defined in our Shadow DOM being applied. Today we will discuss ways to avoid this issue.

The Quick and Dirty Method

The simplest way to prevent our elements from appearing before they are registered is by hiding them with CSS. We can add the following rule to our host document stylesheet to accomplish this.

:not(:defined) {
  display: none;
}
Enter fullscreen mode Exit fullscreen mode

That will cause any unregistered custom elements to not be rendered. Their display mode will be automatically updated when they become registered. However, this can have unintended consequences for basic elements that aren't registered or even others on the page that you don't control. It would be better to scope this pseudo-selector to target specific elements.

copy-text:not(:defined) {
  display: none;
}
Enter fullscreen mode Exit fullscreen mode

While this method will technically prevent our elements from appearing before they are ready, it can still cause layout shifting and is more of a workaround than a solution. Ideally, we want our custom elements to be registered before the DOM is parsed.

A Better Approach

Your first instinct might be to move your script tags into the document head. You would definitely be the right track if you did that but we will need to make some adjustments before this will work as expected. In our previous examples we did our configuration in the constructor of our class. If we try to do this before the DOM is parsed we will get errors because the child nodes don't exist yet.

To work around this issue we will need to use a feature of custom elements that we have not really discussed yet — Lifecycle Callbacks. In every custom element definition we can provide callbacks that will be triggered when specific events occur. We'll cover these more in the next article, today we will focus specifically on connectedCallback which is triggered any time an element instance is inserted into the DOM.

We will modify the <copy-text> element from our previous examples to allow it to work before the DOM is parsed. We will do this by moving our configuration out of the constructor. However, we only want this code to run once, so we will set a flag to indicate if our element has already been configured and return early if it has.

class CopyTextElement extends HTMLElement {
  #initialized = false;
  #shadow;

  constructor() {
    super();
  }

  connectedCallback() {
    if (this.#initialized) return;
    this.#configure();
  }

  #configure() {
    this.#shadow = this.attachShadow({ mode: 'closed' });
    this.#shadow.innerHTML = this.#template;
    const copyButton = this.#shadow.querySelector('button');
    copyButton.addEventListener('click', () => this.#copyText());
    this.#initialized = true;
  }

  get #template() {
    return `
      <style>
        :host { display: inline-flex; align-items: stretch; background: dimgrey; border: 1px solid black; border-radius: 0.2em; }
        span.content { margin: 0.5em; font-family: monospace; }
        button { display: flex; flex-flow: row nowrap; align-items: center; gap: 0.2em; border: 0; background: dodgerblue; cursor: pointer; }
        button:hover { background: skyblue; }
        button img { height: 1em; }
      </style>
      <span class="content">${this.textContent}</span>
      <button>
        <img src="copy.svg">
        <span> Copy </span>
      </button>
    `;
  }

  #copyText() {
    navigator.clipboard.writeText(this.textContent);
  }
}

customElements.define('copy-text', CopyTextElement);
Enter fullscreen mode Exit fullscreen mode

Now our custom element definition can be loaded before the DOM is parsed and any instances will be immediately upgraded. No more FOUC.

<!DOCTYPE html>
<title> No FOUC </title>
<script src="copy-text.js"></script>
<copy-text> This is a test </copy-text>
Enter fullscreen mode Exit fullscreen mode

Rendering Optimizations

If you have a lot of custom elements, waiting for them all to load can block your page from rendering. The best approach is to prioritize above-the-fold elements and defer others. You can also preload definitions for elements that will appear on other pages.

<!DOCTYPE html>
<title> Efficient Loading </title>
<script src="page-header.js"></script>
<script src="page-footer.js" defer></script>
<link rel="preload" href="tabbed-content.js" as="script">
<page-header> A Test Page </page-header>
<main> Your Content </main>
<page-footer> &copy; Your Company Name </page-footer>
Enter fullscreen mode Exit fullscreen mode

To speed up our First Contentful Paint metric, it's possible to avoid making another HTTP request by using Declarative Shadow DOM.

<page-header>
  <template shadowrootmode="closed">
    <style>
      h2 { font-family: sans-serif; }
      nav a { color: red; text-decoration: none; }
      nav a:hover { color: orange; }
    </style>
    <h1>
      <slot>
        <span>Fallback Title</span>
      </slot>
    </h1>
    <nav>
      <a href="index.html"> Home </a>
      <a href="about.html"> About </a>
      <a href="sitemap.html"> Site Map </a>
    </nav>
  </template>
  <span> Your Page Title </span>
</page-header>
Enter fullscreen mode Exit fullscreen mode

This will automatically apply the template to the parent element which contains it. We can then define the element's logic using an inline script tag or even a deferred external script. When doing so, we must access the declared shadow root using ElementInternals.

<script>
  class PageHeaderElement extends HTMLElement {
    #initialized = false;
    #internals;
    #shadow;

    constructor() {
      super();
      this.#internals = this.attachInternals();
      this.#shadow = this.#internals.shadowRoot;
    }

    get #links() {
      return this.#shadow.querySelectorAll('a[href]');
    }

    connectedCallback() {
      if (this.#initialized) return;

      this.#links.forEach(link => {
        link.addEventListener('click', event => {
          event.preventDefault();
          alert(`You clicked on: ${link.textContent}`);
        });
      });

      this.#initialized = true;
    }
  }

  customElements.define('page-header', PageHeaderElement);
</script>
Enter fullscreen mode Exit fullscreen mode

Using this method allows us to generate our page content server-side while still keeping all the benefits of using custom elements client-side. Our element will render quickly and not annoy our users. It does not work for elements that need to share a template like our <copy-text> element but works great for something like <page-header> which will appear only once per page.

Sharing Styles Between Shadow Roots

The main benefit of using Shadow DOM is to encapsulate structure and styles inside our element definition. But sometimes you may want to share styles between elements. Rather than duplicating CSS rules across multiple templates we have some tools that can make this task more streamlined.

The most basic of these is to simply include a link to our stylesheet in our Shadow DOM instead of an inline <style> tag.

<page-header>
  <template shadowrootmode="closed">
    <link rel="stylesheet" href="shared.css">
    <h1> Your Page Title </h1>
  </template>
</page-header>
Enter fullscreen mode Exit fullscreen mode

This comes with some caveats though. First of all, we need to make an HTTP request for this stylesheet, which could slow down our first render. Additionally, the path to this stylesheet is relative to the host document, not the file location of the module. If we're using Declarative Shadow DOM, that's not a big deal but if our elements are defined in a complex hierarchy and imported into several projects this will get complicated quickly.

If making an HTTP request is not a dealbreaker for your use-case, you can load the css file from a relative path based on the url meta-property of the current module.

const stylesheet = new URL('./shared.css', import.meta.url).pathname;

class PageHeaderElement extends HTMLElement {
  #shadow;

  constructor() {
    super();
    this.#shadow = this.attachShadow({ mode: 'closed' });
    this.#shadow.innerHTML = this.#template;
  }

  get #template() {
    return `
      <link rel="stylesheet" href="${stylesheet}">
      <h1><slot><span>Fallback</span></slot></h1>
    `;
  }
}
Enter fullscreen mode Exit fullscreen mode

If you do not want to make an HTTP request for this resource, we can include the stylesheet inline in our host document and adopt it into our Shadow DOM. This is a newly available method that lets us construct CSSStyleSheet instances on the fly and apply them to our shadow root just like how a linked stylesheet would be applied to a document.

<!DOCTYPE html>
<title> Adopted StyleSheet Example </title>
<style id="shared">
  h1 { font-family: sans-serif; color: green; }
</style>
<page-header>
  <span> Testing </span>
</page-header>
<script>
  class PageHeaderElement extends HTMLElement {
    #shadow;

    constructor() {
      super();
      this.#shadow = this.attachShadow({ mode: 'closed' });
      this.#shadow.innerHTML = `<h1><slot><span>Fallback</span></slot></h1>`;

      const sharedStyles = document.querySelector('style#shared');
      const css = new CSSStyleSheet();
      css.replaceSync(sharedStyles?.textContent);
      this.#shadow.adoptedStyleSheets = [ css ];
    }
  }

  customElements.define('page-header', PageHeaderElement);
</script>
Enter fullscreen mode Exit fullscreen mode

Emerge From The Darkness

We've covered some advanced techniques today. Hopefully by now you feel like Web Components are a little less mysterious. There are still more topics to cover but you should have all the tools you need to start bringing your ideas to life. This would be a great time to start experimenting and see what you can come up with. Next time we will discuss ways to polish your components from proof-of-concept quality into a production-ready, plug-and-play toolkit.

Top comments (5)

Collapse
 
parag_nandy_roy profile image
Parag Nandy Roy

Loving this series..

Collapse
 
darkwiiplayer profile image
𒎏Wii 🏳️‍⚧️
:not(:defined) {
  display: none;
}
Enter fullscreen mode Exit fullscreen mode

This one is really dangerous because custom elements don't have to be defined to be useful; some might have CSS to style them without needing any JavaScript to function.

As for declarative shadow-DOM, one thing worth mentioning here is that it can be useful even if server-side-rendering isn't an option, for example by populating the shadow-DOM with a spinner to prevent contents from showing and instead hint to the user that something is still loading.

As for deferring initialization until the contents of the element are loaded, however, is not something I would generally recommend, because any problem related to this is usually a hint that the element should be more reactive to changes, meaning you'll probably want a MutationObserver instead.

These are generally quite easy to set up for custom elements and almost always follow the same structure:

  1. define the observer
  2. have its callback call a method on the target (Personally I like mutatedCallback)
  3. define said method to reload the contents of the custom element

Of course it is generally best if you can avoid depending on the light-DOM at all to adjust behaviour and instead just read it in response to some other event; using a slot for the text to be copied would be the easiest way around this :)

Collapse
 
besworks profile image
Besworks

Those are all great points. I've explained mutation observers before but left them out here for brevity. I would definitely recommend adding extra reactivity for production use and plan to cover that more in the next article.

Collapse
 
besworks profile image
Besworks

I've updated the not(:defined) section to make it more clear why scoping the selector is better. But, I agree that this method should generally be avoided.

Collapse
 
michael_liang_0208 profile image
Michael Liang

Great post!