Shadow DOM Slots, Composition

Different types of components like menus, tabs, image galleries, and more, need the content for rendering.

The <custom-tabs> might wait for the actual tab content to be passed like the <select> expects the <option> items.

The code implementing the <custom-menu> is as follows:

<custom-menu>
  <title>Book</title>
  <item>Javascript</item>
  <item>Html</item>
  <item>Css</item>
</custom-menu>

Then the component above should render it adequately.

But it’s essential to know how to perform it.

It is possible to analyze the element content and copy-rearrange nodes of the DOM. It is doable, yet while moving the elements to shadow DOM, the CSS styles from the document don’t apply. Hence, you may lose the visual styling. To avoid it, there are <slot> elements supported by shadow Dom. They are automatically fulfilled by the content from the light DOM.

The Named Slots

Here, we are going to demonstrate a simple example that shows how slots operate.

In the example below, two slots filled from the light DOM are provided by shadow DOM:

<!DOCTYPE html>
<html>
  <head>
    <title>Title of the document</title>
  </head>
  <body>
    <script>
      customElements.define('user-card', class extends HTMLElement {
        connectedCallback() {
          this.attachShadow({mode: 'open'});
          this.shadowRoot.innerHTML = `
            <div>Name:
              <slot name="username"></slot>
            </div>
            <div>Id:
              <slot name="id"></slot>
            </div>
          `;
        }
      });
    </script>
    <site-card>
      <span slot="username">Jack Brown</span>
      <span slot="id">1125</span>
    </site-card>
  </body>
</html>

Within the shadow DOM, <slot name="X"> specifies the insertion point ( where slot="X" elements are rendered).

Afterward, composition is implemented by the browser. The composition takes elements from the light DOM rendering them in the matching slots of the shadow DOM. At the end, there is a component, which should be fulfilled with data.

Let’s take a look at the DOM structure after the script without taking into account the composition:

<!DOCTYPE html>
<html>
  <head>
    <title>Title of the document</title>
  </head>
  <body>
    <user-card>
      #shadow-root
      <div>
        Name:
        <slot name="username"></slot>
      </div>
      <div>
        Id:
        <slot name="id"></slot>
      </div>
      <span slot="username">Jack Brown </span>
      <span slot="id">1125</span>
    </user-card>
  </body>
</html>

As you can see, shadow DOM is created under #shadow-root. So, the element has both shadow and light DOM.

In the shadow DOM, for every <slot name="...">>, the browser searches for slot="..." with the same name in the light DOM. It is done for rendering purposes.

The result is known as “flattened” DOM. It will look like this:

<!DOCTYPE html>
<html>
  <head>
    <title>Title of the document</title>
  </head>
  <body>
    <user-card>
      #shadow-root
      <div>
        Name:
        <slot name="username">
          <!-- slot element is inserted into the slot -->
          <span slot="username">Jack Brown</span>
        </slot>
      </div>
      <div>
        Id:
        <slot name="id">
          <span slot="id">1125</span>
        </slot>
      </div>
    </user-card>
  </body>
</html>

But, note that the “flattened” DOM can only be used for event-handling and rendering. Yet, the document nodes won’t move.

If you try to run querySelectorAll>, you will see that the nodes are at their places, like here:

<!DOCTYPE html>
<html>
  <head>
    <title>Title of the document</title>
  </head>
  <body>
    <user-card>
      #shadow-root
      <div>
        Name:
        <slot name="username">
          <!-- slot element is inserted into the slot -->
          <span slot="username">Jack Brown</span>
        </slot>
      </div>
      <div>
        Id:
        <slot name="id">
          <span slot="id">1125</span>
        </slot>
      </div>
    </user-card>
    <script>
      // light DOM <span> nodes are still at the same place, under `<user-card>`
      alert( document.querySelectorAll('user-card span').length ); // 2      
    </script>
  </body>
</html>

So, the “flattened” DOM is extracted from the shadow DOM by integrating slots. It is rendered and used by the browser for style inheritance and event propagation. However, JavaScript will see it in the before-flattening condition. It is important to note that slot="..." can be valid for the shadow host direct children only. It will be ignored for any nested element.

In the example below, the second span is ignored:

<!DOCTYPE html>
<html>
  <head>
    <title>Title of the document</title>
  </head>
  <body>
    <user-card>
      <span slot="username">John Brown</span>
      <div>
        <!-- wrong slot, must be a direct child of user-card -->
        <span slot="id">1125</span>
      </div>
    </user-card>
  </body>
</html>

In case there are several elements with the same name inside the light DOM, they can be added into the slot, one by one.

Let’s see how it can be done:

<user-card>
  <span slot="username">Jack</span>
  <span slot="username">Brown</span>
</user-card>

And, below find an example of this flattened DOM with two elements within:

<!DOCTYPE html>
<html>
  <head>
    <title>Title of the document</title>
  </head>
  <body>
    <slot name="username">
    : 
    <user-card>
      #shadow-root
      <div>
        Name:
        <slot name="username">
          <span slot="username">Jack</span>
          <span slot="username">Brown</span>
        </slot>
      </div>
      <div>
        Id:
        <slot name="id"></slot>
      </div>
    </user-card>
  </body>
</html>

Fallback Content of the Slot

After placing something inside a <slot>, it transforms into a fallback. It is demonstrated by the browser when no matching filler is found in the light DOM.

Let’s take a look at an example where Anonymous is rendering the presence of slot="username" in the light DOM:

<!DOCTYPE html>
<html>
  <head>
    <title>Title of the document</title>
  </head>
  <body>
    <div>
      Name:
      <slot name="username">Anonymous</slot>
    </div>
  </body>
</html>

The Default Slot

The so-called default slot is the first <slot> in the shadow DOM that doesn’t have a name yet. All the nodes from the light DOM that are not slotted anywhere are received by it.

In the example below, the default slot to the <user-card> showing the overall unslotted information about the user, is added:

<!DOCTYPE html>
<html>
  <head>
    <title>Title of the document</title>
  </head>
  <body>
    <script>
      customElements.define('user-card', class extends HTMLElement {
        connectedCallback() {
          this.attachShadow({mode: 'open'});
          this.shadowRoot.innerHTML = `
          <div>Name:
            <slot name="username"></slot>
          </div>
          <div>Id:
            <slot name="id"></slot>
          </div>
          <fieldset>
            <legend>Other information</legend>
            <slot></slot>
          </fieldset>
          `;
        }
      });
    </script>
    <user-card>
      <div>I like to swim.</div>
      <span slot="username">Jack Brown</span>
      <span slot="birthday">1125</span>
      <div>And play piano too.</div>
    </user-card>
  </body>
</html>

The entire content of the unslotted light DOM is placed in the “Other information” field.

The elements are attached to a slot one by one. So, both of the unslotted information pieces are put together in the default slot.

Finally, the flattened DOM appears be as follows:

<!DOCTYPE html>
<html>
  <head>
    <title>Title of the document</title>
  </head>
  <body>
    <user-card>
      #shadow-root
      <div>
        Name:
        <slot name="username">
          <span slot="username">Jack Brown</span>
        </slot>
      </div>
      <div>
        Id:
        <slot name="id">
          <span slot="id">1125</span>
        </slot>
      </div>
      <fieldset>
        <legend>About me</legend>
        <slot>
          <div>Hello</div>
          <div>I am Jack</div>
        </slot>
      </fieldset>
    </user-card>
  </body>
</html>

Now, let’s get back to the <custom-menu>.

Slots can be practised for distributing elements.

The markup of the <custom-menu> is the following:

<custom-menu>
  <span slot="title">Books</span>
  <li slot="item">Javascript</li>
  <li slot="item">Html</li>
  <li slot="item">Css</li>
</custom-menu>

And, the shadow DOM template including the proper slots is shown here:

<template id="tmpId">
  <style> /* menu styles */ </style>
  <div class="menu">
    <slot name="title"></slot>
    <ul>
      <slot name="item"></slot>
    </ul>
  </div>
</template>

Let’s describe what happens above:

  1. <span slot="title"> moves into <slot name="title">.
  2. Inside the template, you can find multiple <li slot="item">. But, there is a single <slot name="item">. Therefore, all those <li slot="item"> are attached to <slot name="item"> one by one. The list is formed in this way.

The flattened DOM transforms to:

<!DOCTYPE html>
<html>
  <head>
    <title>Title of the document</title>
  </head>
  <body>
    <custom-menu>
      #shadow-root
      <style> /* menu styles */ </style>
      <div class="menu">
        <slot name="title">
          <span slot="title">Books</span>
        </slot>
        <ul>
          <slot name="item">
            <li slot="item">Javascript</li>
            <li slot="item">Html</li>
            <li slot="item">Css</li>
          </slot>
        </ul>
      </div>
    </custom-menu>
  </body>
</html>

As a rule, <li> must be a direct child of <ul>, inside a valid DOM. However, that is a flattened DOM, describing the way the component is rendered. It is just necessary to insert click handler for opening or closing the list. And the <custom-menu> will be ready:

<!DOCTYPE html>
<html>
  <head>
    <title>Title of the Document</title>
    <style>
      ul {
        margin: 0;
        list-style: none;
        padding-left: 20px;
      }
      ::slotted([slot="title"]) {
        font-size: 18px;
        font-weight: bold;
        cursor: pointer;
      }
      ::slotted([slot="title"])::before {
        font-size: 14px;
      }
      .closed::slotted([slot="title"])::before {
        content: ;
      }
      .closed ul {
        display: none;
      }
    </style>
  </head>
  <body>
    <template id="tmpId">
      <div class="menu">
        <slot name="title"></slot>
        <ul>
          <slot name="item"></slot>
        </ul>
      </div>
    </template>
    <script>
      customElements.define('custom-menu', class extends HTMLElement {
        connectedCallback() {
          this.attachShadow({
            mode: 'open'
          });
          this.shadowRoot.append(tmpId.content.cloneNode(true));
          this.shadowRoot.querySelector('slot[name="title"]')
            .onclick = () => {
              this.shadowRoot.querySelector('.menu')
                .classList.toggle('closed');
            };
        }
      });
    </script>
    <custom-menu>
      <span slot="title">Books</span>
      <li slot="item">Javascript</li>
      <li slot="item">Html</li>
      <li slot="item">Cup Cake</li>
    </custom-menu>
  </body>
</html>

The full demo will look as follows:

Books

  • Javascript
  • Html
  • Css

Also, events, methods, and other functionality can be added to it.

Updating

The browser supervises the slots and updates rendering in case the slotted elements are added or removed.

The changes inside the light DOM nodes become visible immediately because they are not copied but just rendered in slots. So, you needn’t do anything for updating the rendering. But if the code component must get information about the slot changes, then slotchange is accessible.

Here is an example:

<!DOCTYPE HTML>
<html>
  <head>
    <title>Title of the Document</title>
  </head>
  <body>
    <custom-menu id="menu">
      <span slot="title">Books</span>
    </custom-menu>
    <script>
      customElements.define('custom-menu', class extends HTMLElement {
        connectedCallback() {
          this.attachShadow({
            mode: 'open'
          });
          this.shadowRoot.innerHTML = `<div class="menu">
            <slot name="title"></slot>
            <ul><slot name="item"></slot></ul>
          </div>`;
          // shadowRoot cannot have event handlers, so use the first child
          this.shadowRoot.firstElementChild.addEventListener('slotchange',
            e => alert("slotchange: " + e.target.name)
          );
        }
      });
      setTimeout(() => {
        menu.insertAdjacentHTML('beforeEnd', '<li slot="item">Javascript</li>')
      }, 1000);
      setTimeout(() => {
        menu.querySelector('[slot="title"]').innerHTML = "New menu";
      }, 2000);
    </script>
  </body>
</html>

The rendering of the menu is updated every time, without any intervention.

Twoslotchange events occur here:

  1. Initialization: during that, slot="title" occurs at once, as the slot="title" moves into the matching slot.
  2. After a second, slotchange: item happens, once a new <li slot="item"> is inserted.

Please, take into consideration that there isn’t any slotchange event after two seconds when the slot="title" content is modified. The reason is that no slot change is there. The content is modified inside the slotted element, that’s a different thing. For tracking internal modifications of the light DOM from JavaScript, there is a possibility of using a more generic mechanism, called MutationObserver.

The Slot API

Here, we will mention the slot-related methods of JavaScript. As you know, JavaScript considers the real DOM without flattening. But, when the shadow tree includes {mode: 'open'}, then it can be figured out what elements are assigned to a slot, and in reverse, the slot by the element inside that:

  • node.assignedSlot: returning the <slot> element, to which the node is assigned.
  • slot.assignedNodes({flatten: true/false}): the DOM nodes that are assigned to the slot.By default, the flatten option is false. By setting to true, it will look more deeply into the flattened DOM, and return nested slots when it comes to nested components and the fallback content if no node is assigned.
  • slot.assignedElements({flatten: true/false}): the elements of DOM that are assigned to the slot.

The above-mentioned methods are handy when you wish not only to show the slotted content but track it in JavaScript, too.

Here is an example:

<!DOCTYPE HTML>
<html>
  <head>
    <title>Title of the Document</title>
  </head>
  <body>
    <custom-menu id="menu">
      <span slot="title">Books</span>
    </custom-menu>
    <script>
      customElements.define('custom-menu', class extends HTMLElement {
        connectedCallback() {
          this.attachShadow({
            mode: 'open'
          });
          this.shadowRoot.innerHTML = `<div class="menu">
            <slot name="title"></slot>
            <ul><slot name="item"></slot></ul>
          </div>`;
          // shadowRoot cannot have event handlers, so use the first child
          this.shadowRoot.firstElementChild.addEventListener('slotchange',
            e => alert("slotchange: " + e.target.name)
          );
        }
      });
      setTimeout(() => {
        menu.insertAdjacentHTML('beforeEnd', '<li slot="item">Javascript</li>')
      }, 1000);
      setTimeout(() => {
        menu.querySelector('[slot="title"]').innerHTML = "New menu";
      }, 2000);
    </script>
  </body>
</html>

Summary

Generally, when an element obtains a shadow DOM, its light DOM is not illustrated. It is possible to find the light DOM elements in particular places of the shadow DOM.

As a rule, two types of slots are distinguished: named slots and default slots. The process of rendering the slotted elements inside their slots is known as composition. And, the “flattened” DOM is its result.




Do you find this helpful?

Related articles