Andrea Chiarelli Software developer and technical author. https://andreachiarelli.it

Discovering the Shadow DOM

7 min read 2152


We are all fully aware of the Document Object Model (DOM). Thanks to it, we can manipulate the structure and the content of an HTML page via JavaScript in an effective way.

Of course, the DOM is not free from defects. One of them, for example, is the lack of encapsulation, that is the possibility to protect a part of the document structure from global processing, such as CSS rules application and JavaScript manipulation.

In this article, we will analyze an example of this issue, how it is traditionally tackled and how we can resolve it by using the Shadow DOM, a standard feature that has been around for a while but is not as well-known by front-end developers.


Using the DOM

Consider the following JavaScript code:

const myGlossary = {};
​
myGlossary.renderSearchBox = function (idContainer) {
 const box = document.createElement("div");
 const form = document.createElement("form");
 const label = document.createElement("label");
 const textbox = document.createElement("input");
 const button = document.createElement("input");
 const definition = document.createElement("div");
​
 label.htmlFor = "txtTerm";
 label.innerHTML = "<h4>My Glossary</h4>";
 form.appendChild(label);
​
 textbox.type = "text";
 textbox.id = "txtTerm";
 form.appendChild(textbox);
​
 button.type = "submit";
 button.value = " Search ";
 form.appendChild(button);
 form.appendChild(document.createElement("br"));
​
 box.style.textAlign = "center";
 box.style.fontFamily = "Verdana";
 box.appendChild(form);
​
 definition.style.padding = "15px";
 box.appendChild(definition);
​
 document.getElementById(idContainer).appendChild(box);
​
 form.onsubmit = () => {
   const term = textbox.value.trim().toLocaleLowerCase();
   const definitions = {"html": "HTML is the Web markup language.", "css": "CSS is the language describing the style of HTML documents."};
​
   if (term) {
     definition.innerHTML = definitions[term] || `'${term}' not found.`;
  }
   return false;
};
}

Here, we define an object with a single method, renderSearchBox(). This method takes the identifier of an HTML element as its argument and appends to it the elements needed to build up a search textbox like the one shown below:


This search textbox is intended to allow the user to find glossary definitions from an online service. For simplicity, we have embedded the term definitions in the code.

When the user inserts a term in the textbox and clicks the search button, the form.onsubmit() event listener is executed. This function simply shows the definition associated with the inserted term, if any, otherwise it shows an appropriate message.

The following picture shows what the user will see when they submit the HTML term into the search text box:


You can use the myGlossary object in an HTML page:

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
 <title>Discovering the Shadow DOM</title>
</head>
<body>
 <div id="divBox"></div>
 <script type="text/javascript" src="myGlossaryBox.js"></script>
 <script type="text/javascript">
 myGlossary.renderSearchBox("divBox");
 </script>
</body>
</html>

As you can see, we imported the script from myGlossary.js file and invoked the renderSearchBox() method by passing the divBox string, as the identifier of the only div element in the page.

You can try this code on CodePen.

The problem with the DOM

The search box created by the script shown above works as expected. It is also self-contained enough to be re-used in other projects.

But what happens if you use the search box inside a page with a markup?

<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
 <title>Discovering the Shadow DOM</title>
 <style>
   h4 {
     text-align: center;
     color: red;
  }
 </style>
</head>
<body>
   <div>
       <h4>Welcome to my great page!</h4>
   </div>
   <!-- other markup -->
 <div id="divBox"></div>
   <!-- other markup -->
 <script type="text/javascript" src="myGlossaryBox.js"></script>
 <script type="text/javascript">
 myGlossary.renderSearchBox("divBox");
 </script>
</body>
</html>

In this page, you notice a CSS rule re-defining the colour of the h4 element. Of course, the page designer meant to customize the welcome message. However, this rule also affects the title of the imported search box:


Since the DOM is a global entity, the CSS rule shown above affects any h4 element in the page, even the ones dynamically added from an external script. You have no way of protecting the specific h4 element defined when building the search box. You cannot encapsulate it if you just rely on the DOM.

But this issue is not only related to the CSS rule application. Imagine what happens if somewhere on the page you have the following JavaScript statement:

document.getElementById("txtTerm").style.visibility = "hidden";

Your search textbox magically disappears:


Try this code on CodePen.

You cannot prevent the txtTerm id from being used elsewhere on the page. And you cannot control what any external code may do, intentionally or not, with the DOM elements you created for your search box.

Again, the DOM is a global entity: any element you add to it is publicly accessible which means conflicts may happen.

Using iframes

The most common solution to get around this problem is to embed the portion of DOM representing your component in a separate DOM, i.e. in a separate HTML page. This page is then appended to the main page via an iframe. This is the approach used by Google to allow developers to embed its maps and its videos from YouTube and Twitter.

Let’s take a look at how to implement this idea. Rearrange the previous code like this:

const myGlossary = {};
​
myGlossary.renderSearchBox = function (idContainer) {
 const iframe = document.createElement("iframe");
 const box = document.createElement("div");
 const form = document.createElement("form");
 const label = document.createElement("label");
 const textbox = document.createElement("input");
 const button = document.createElement("input");
 const definition = document.createElement("div");
​
 label.htmlFor = "txtTerm";
 label.innerHTML = "<h4>My Glossary</h4>";
 form.appendChild(label);
​
 textbox.type = "text";
 textbox.id = "txtTerm";
 form.appendChild(textbox);
​
 button.type = "submit";
 button.value = " Search ";
 form.appendChild(button);
 form.appendChild(document.createElement("br"));
​
 box.style.textAlign = "center";
 box.style.fontFamily = "Verdana";
 box.appendChild(form);
​
 definition.style.padding = "15px";
 box.appendChild(definition);
​
 iframe.style.border = 0;
 iframe.style.height = "200px";
 document.getElementById(idContainer).appendChild(iframe);
​
 iframe.contentDocument.body.appendChild(box);
​
 form.onsubmit = () => {
   const term = textbox.value.trim().toLocaleLowerCase();
   const definitions = {"html": "HTML is the Web markup language.", "css": "CSS is the language describing the style of HTML documents."};
​
   if (term) {
     definition.innerHTML = definitions[term] || `'${term}' not found.`;
  }
   return false;
};
}

The first difference, with respect to the previous version, is the creation of an iframe element:

const iframe = document.createElement("iframe");

The second difference is the way you append the portion of DOM representing the search box to the external element. With this approach, you don’t directly append the box element to the external element. You append the iframe element to the external element and then append the box element to the body of the document associated with the iframe. The code would look something like this:

 iframe.style.border = 0;
 iframe.style.height = "200px";
 document.getElementById(idContainer).appendChild(iframe);
​
 iframe.contentDocument.body.appendChild(box);

With this approach, you will no longer get the conflicts you experienced with the previous version of the search box.

You can try this on CodePen.

The problem with iframes

In the iframe-based approach, you should have noticed a couple of assignments concerning the style of the iframe. Let’s recall these two assignments in the following:

 iframe.style.border = 0;
 iframe.style.height = "200px";

These assignments define the border thickness and the height of the iframe itself. They are fundamental to provide the illusion that the search box is integrated with the main page.

But while the first instruction fulfills its task well, the second may be insufficient. In fact, you may have a chance that the size of the iframe doesn’t fit the size of the document, so you could have an effect similar to the following:


This is an ugly effect and you should avoid it.

Iframes are not very responsive. In addition, iframes are designed to embed another full document in the current page. This leads to more complexity when we want to pass data from the parent document to the child document, for example, in order to configure some elements in the child document.

Entering the Shadow DOM

A better solution to manage the encapsulation of a portion of the DOM inside the DOM of an HTML page is to use the Shadow DOM. This is a set of standard APIs enabling the encapsulation at the DOM level by allowing you to create a sort of private DOM for an HTML element.

Let’s see how to use the Shadow DOM to avoid the issues of both the global DOM and the iframes. Rewrite the JavaScript code implementing the search box as follows:

const myGlossary = {};
​
myGlossary.renderSearchBox = function (idContainer) {
 const box = document.createElement("div");
 const form = document.createElement("form");
 const label = document.createElement("label");
 const textbox = document.createElement("input");
 const button = document.createElement("input");
 const definition = document.createElement("div");
​
 label.htmlFor = "txtTerm";
 label.innerHTML = "<h4>My Glossary</h4>";
 form.appendChild(label);
​
 textbox.type = "text";
 textbox.id = "txtTerm";
 form.appendChild(textbox);
​
 button.type = "submit";
 button.value = " Search ";
 form.appendChild(button);
 form.appendChild(document.createElement("br"));
​
 box.style.textAlign = "center";
 box.style.fontFamily = "Verdana";
 box.appendChild(form);
​
 definition.style.padding = "15px";
 box.appendChild(definition);
​
 const container = document.getElementById(idContainer)
 container.attachShadow({mode: "open"});
​
 container.shadowRoot.appendChild(box);
​
 form.onsubmit = () => {
   const term = textbox.value.trim().toLocaleLowerCase();
   const definitions = {"html": "HTML is the Web markup language.", "css": "CSS is the language describing the style of HTML documents."};
​
   if (term) {
     definition.innerHTML = definitions[term] || `'${term}' not found.`;
  }
   return false;
};
}

In this case, the only difference with respect to the original version of the script consists of the following statements:

const container = document.getElementById(idContainer);
container.attachShadow({mode: "open"});
​
container.shadowRoot.appendChild(box);

Instead of directly attaching the box element to the external element, we first create a Shadow DOM to the external element via attachShadow(). This method is available for almost any HTML element and it generates the root node for the Shadow DOM of the element.

This root node is then accessible through the shadowRoot property. In the example above, we appended to the shadowRoot of the external element the box element representing the search box. These two simple instructions grant us protection against conflicts and undesired effects when the search box is included in an arbitrary HTML page. You can try this code on CodePen.

You may notice that we passed a literal object as an argument to the attachShadow() method. This object allows us to specify the creation mode of the Shadow DOM. In our example, we set the value open as the Shadow DOM mode. This means that the Shadow DOM will be hidden to standard global DOM manipulation such as CSS rules applications, node selection and the like.

However, the Shadow DOM is exposed to the outside world, so its elements will be accessible via JavaScript. In other words, you can access the Shadow DOM of the search box from any page with something like this:

const divBox = document.getElementById("divBox");
divBox.shadowRoot.querySelector("h4").innerText = "Changed from outside"

This code replaces the text of the label associated with the search textbox.

If you don’t want this to happen, you may choose to set the closed value:

container.attachShadow({mode: "closed"});

In this case, the Shadow DOM will not be accessible at all.

Conclusion

Iframes can still be useful when they do the work they were born for: embedding an independent external page in the current page. However, we have now seen how the Shadow DOM can replace the typical encapsulation workaround based on iframes which can have a lot of benefits.

Plug: LogRocket, a DVR for web apps

https://logrocket.com/signup/

LogRocket is a frontend logging tool that lets you replay problems as if they happened in your own browser. Instead of guessing why errors happen, or asking users for screenshots and log dumps, LogRocket lets you replay the session to quickly understand what went wrong. It works perfectly with any app, regardless of framework, and has plugins to log additional context from Redux, Vuex, and @ngrx/store.

In addition to logging Redux actions and state, LogRocket records console logs, JavaScript errors, stacktraces, network requests/responses with headers + bodies, browser metadata, and custom logs. It also instruments the DOM to record the HTML and CSS on the page, recreating pixel-perfect videos of even the most complex single page apps.

Try it for free.

Andrea Chiarelli Software developer and technical author. https://andreachiarelli.it

Leave a Reply