DEV Community

Brian Neville-O'Neill
Brian Neville-O'Neill

Posted on • Originally published at blog.logrocket.com on

Understanding Shadow DOM v1

The shadow DOM is not a villain from a superhero movie. It’s not the dark side of the DOM, either. The shadow DOM is simply a way to address the lack of tree encapsulation in the Document Object Model (or DOM for short).

It’s common for a webpage to use data and widgets from external sources. With no encapsulation, styles may affect unwanted parts of the HTML, forcing developers to use excessively specific selectors and !important rules to avoid style conflicts.

Still, these efforts don’t seem to be very effective when writing large programs, and a significant portion of development time is wasted on preventing CSS and JavaScript collisions. The Shadow DOM API aims to solve these and other problems by providing a mechanism to encapsulate DOM trees.

Shadow DOM is one of the primary technologies used to create Web Components; the other two are Custom Elements and HTML templates. The specification of Web Components was originally proposed by Google to simplify the development of widgets for the web.

Although the three technologies are designed to work together, you have the freedom to use each one separately. The scope of this tutorial is limited to the shadow DOM.

What is the DOM?

Before we delve into how to create shadow DOMs, it’s important to understand what DOM is. The W3C Document Object Model (DOM) provides a platform- and language-neutral application programming interface (API) for representing and manipulating information stored in HTML and XML documents.

With DOM, programmers can access, add, delete, or change elements and content. The DOM treats a webpage as a tree structure, with each branch ending in a node and each node holding an object, which can be modified using a scripting language like JavaScript. Consider the following HTML document:

<html>
  <head>
    <title>Sample document</title>
  </head>
  <body>
    <h1>Heading</h1>
    <a href="https://example.com">Link</a>
  </body>
</html>

The DOM presentation of this HTML is as follows:

All boxes in this figure are nodes.

The terminology used to describe parts of the DOM resembles that of a family tree in the real world:

  • The node one level above a given node is the parent of that node
  • The node one level below a given node is the child of that node
  • Nodes that have the same parent are siblings
  • All nodes above a given node, including parent and grandparent, are called the ancestors of that node
  • Finally, all nodes below a given node are called the descendants of that node

The type of a node depends on the kind of HTML element it represents. An HTML tag is referred to as an element node. Nested tags form a tree of elements. The text within an element is called a text node. A text node may not have children; you can think of it as a leaf of the tree.

To access the tree, DOM provides a set of methods with which the programmer can modify the content and structure of the document. When you write document.createElement('p');, for example, you are using a method provided by DOM. Without DOM, JavaScript wouldn’t understand the structure of HTML and XML documents.

The following JavaScript code shows how to use DOM methods to create two HTML elements, nest one inside the other, set text content, and append them to the document body:

const section = document.createElement('section');
const p = document.createElement('p');

p.textContent = 'Hello!';

section.appendChild(p);

document.body.appendChild(section);

Here is the resulting DOM structure after running this JavaScript code:

<body>
  <section>
    <p>Hello!</p>
  </section>
</body>

What is the shadow DOM?

Encapsulation is a fundamental feature of object-oriented programming, which enables the programmer to restrict unauthorized access to some of the object’s components.

Under this definition, an object provides an interface in the form of publicly accessible methods as a way to interact with its data. In this way, the internal representation of the object is not directly accessible from outside the object’s definition.

Shadow DOM brings this concept to HTML. It enables you to link a hidden, separated DOM to an element, which means you can have local scoping for HTML and CSS. You can now use more generic CSS selectors without worrying about naming conflicts, and styles no longer leak or apply to elements that they were not supposed to.

In effect, the Shadow DOM API is exactly what library and widget developers needed to separate the HTML structure, style, and behavior from other parts of the code.

Shadow root is the topmost node in a shadow tree. This is what gets attached to a regular DOM node when creating a shadow DOM. The node that has a shadow root associated with it is known as a shadow host.

You can attach elements to a shadow root the same way you would to a normal DOM. The nodes linked to the shadow root form a shadow tree. A diagram should make this clearer:

The term light DOM is often used to distinguish the normal DOM from the shadow DOM. Together, the shadow DOM and the light DOM are called the logical DOM. The point at which the light DOM is separated from the shadow DOM is referred to as the shadow boundary. DOM queries and CSS rules cannot go to the other side of the shadow boundary, thus creating encapsulation.

Creating a shadow DOM

To create a shadow DOM, you need to attach a shadow root to an element by using the Element.attachShadow() method. Here’s the syntax:

var shadowroot = element.attachShadow(shadowRootInit);

Let’s look at a simple example:

<div id="host"><p>Default text</p></div>

<script>
  const elem = document.querySelector('#host');

  // attach a shadow root to #host
  const shadowRoot = elem.attachShadow({mode: 'open'});

  // create a <p> element
  const p = document.createElement('p');

  // add <p> to the shadow DOM
  shadowRoot.appendChild(p);

  // add text to <p> 
  p.textContent = 'Hello!';
</script>

This code attaches a shadow DOM tree to a div element whose id is host. This tree is separate from the actual children of the div, and anything added to it will be local to the hosting element.

Shadow root in Chrome DevTools.

Notice how the existing element in #host is replaced by the shadow root. Browsers that don’t support the shadow DOM will use the default content.

Now, when adding CSS to the main document, style rules won’t affect the shadow DOM:

<div><p>Light DOM</p></div>
<div id="host"></div>

<script>
  const elem = document.querySelector('#host');

  // attach a shadow root to #host
  const shadowRoot = elem.attachShadow({mode: 'open'});

  // set the HTML contained within the shadow root
  shadowRoot.innerHTML = '<p>Shadow DOM</p>';
</script>

<style>
  p {color: red}
</style>

Styles defined in the light DOM cannot cross the shadow boundary. As a result, only paragraphs in the light DOM will turn red.

Conversely, the CSS you add to the shadow DOM is local to the hosting element and does not affect other elements in the DOM:

<div><p>Light DOM</p></div>
<div id="host"></div>

<script>
  const elem = document.querySelector('#host');
  const shadowRoot = elem.attachShadow({mode: 'open'});
  shadowRoot.innerHTML = `
    <p>Shadow DOM</p>
    <style>p {color: red}</style>`;

</script>

You can also put style rules in an external stylesheet, like this:

shadowRoot.innerHTML = `
  <p>Shadow DOM</p>
  <link rel="stylesheet" href="style.css">`;

To get a reference to the element to which the shadowRoot is attached, you can use the host property:

<div id="host"></div>

<script>
  const elem = document.querySelector('#host');
  const shadowRoot = elem.attachShadow({mode: 'open'});

  console.log(shadowRoot.host);    // => <div id="host"></div>
</script>

To do the opposite and get a reference to the shadow root hosted by an element, use the shadowRoot property of the element:

<div id="host"></div>

<script>
  const elem = document.querySelector('#host');
  const shadowRoot = elem.attachShadow({mode: 'open'});

  console.log(elem.shadowRoot);    // => #shadow-root (open)
</script>

shadowRoot mod

When calling the Element.attachShadow() method to attach a shadow root, you must specify the encapsulation mode for the shadow DOM tree by passing an object as an argument, otherwise a TypeError is thrown. The object must have a mode property with a value of either open or closed.

An open shadow root allows you to use the shadowRoot property of the host element to access the elements of the shadow root from outside the root, as shown in this example:

<div><p>Light DOM</p></div>
<div id="host"></div>

<script>
  const elem = document.querySelector('#host');

  // attach an open shadow root to #host
  const shadowRoot = elem.attachShadow({mode: 'open'});

  shadowRoot.innerHTML = `<p>Shadow DOM</p>`;

  // Nodes of an open shadow DOM are accessible
  // from outside the shadow root
  elem.shadowRoot.querySelector('p').innerText = 'Changed from outside the shadow root';
  elem.shadowRoot.querySelector('p').style.color = 'red';
</script>

But if the mode property has a value of closed, attempting to use JavaScript from outside the root to access the elements of the shadow root throws a TypeError:

<div><p>Light DOM</p></div>
<div id="host"></div>

<script>
  const elem = document.querySelector('#host');

  // attach a closed shadow root to #host
  const shadowRoot = elem.attachShadow({mode: 'closed'});

  shadowRoot.innerHTML = `<p>Shadow DOM</p>`;

  elem.shadowRoot.querySelector('p').innerText = 'Now nodes cannot be accessed from outside';
  // => TypeError: Cannot read property 'querySelector' of null 
</script>

When mode is set to closed, the shadowRoot property returns null. Because a null value doesn’t have any property or method, calling querySelector() on it causes a TypeError. The closed shadow root is commonly used by browsers to make the implementation internals of some elements inaccessible and unchangeable from JavaScript.

To determine whether a shadow DOM is in open or closed mode, you can refer to the mode property of the shadow root:

<div id="host"></div>

<script>
  const elem = document.querySelector('#host');
  const shadowRoot = elem.attachShadow({mode: 'closed'});

  console.log(shadowRoot.mode);    // => closed
</script>

On the surface, a closed shadow DOM appears very handy for Web Component authors who don’t want to expose the shadow root of their components; however, in practice, it’s not hard to bypass closed shadow DOMs. In general, the effort required to completely hide a shadow DOM is more than it’s worth.

Not all HTML elements can host a shadow DOM

Only a limited set of elements may host a shadow DOM. The following table lists the supported elements:

+----------------+----------------+----------------+
|    article     |      aside     |   blockquote   |
+----------------+----------------+----------------+
|     body       |       div      |     footer     |
+----------------+----------------+----------------+
|      h1        |       h2       |       h3       |
+----------------+----------------+----------------+
|      h4        |       h5       |       h6       |
+----------------+----------------+----------------+
|    header      |      main      |      nav       |
+----------------+----------------+----------------+
|      p         |     section    |      span      |
+----------------+----------------+----------------+

Trying to attach a shadow DOM tree to any other element results in a DOMException error. For instance:

document.createElement('img').attachShadow({mode: 'open'});    
// => DOMException

It’s not reasonable to use an element as a shadow host, so it shouldn’t come as a surprise that this code throws an error. Another reason you might get a DOMException error is that the browser already uses that element to host a shadow DOM.

Browsers automatically attach a shadow DOM to some elements

Shadow DOM has existed for quite some time, and browsers have been using it to hide the inner structure of elements such as <input>, <textarea>, and <video>.

When you use the <video> element in your HTML, the browser automatically attaches a shadow DOM to the element, which contains default browser controls. But the only thing visible in the DOM is the <video> element itself:

To make the shadow root of such elements visible in Chrome, open Chrome DevTools settings (press F1), and under the “elements” section check “Show user agent shadow DOM”:

Once the “Show user agent shadow DOM” option is checked, the shadow root node and its children become visible. Here is how the same code looks after this option is enabled:

Hosting a shadow DOM on a custom element

A custom element created by the Custom Elements API may host a shadow DOM like any other element. Consider the following example:

<my-element></my-element>

<script>
  class MyElement extends HTMLElement {
    constructor() {

      // must be called before the this keyword
      super();

      // attach a shadow root to <my-element>
      const shadowRoot = this.attachShadow({mode: 'open'});

      shadowRoot.innerHTML = `
        <style>p {color: red}</style>
        <p>Hello</p>`;
    }
  }

  // register a custom element on the page
  customElements.define('my-element', MyElement);
</script>

This code creates an autonomous custom element that hosts a shadow DOM. To do that, it calls the customElements.define() method, with the element name as its first argument and a class object as its second argument. The class extends HTMLElement and defines the behavior of the element.

Inside the constructor, super() is used to establish a prototype chain, and a shadow root is attached to the custom element. Now, when you use on your page, it creates its own shadow DOM:

Keep in mind that a valid custom element cannot be a single word and must have a hyphen (-) in its name. For example, myelement cannot be used as a name for a custom element and will throw a DOMException error.

Styling the host element

Normally, to style the host element, you’d add CSS to the light DOM because that’s where the host element is located. But what if you need to style the host element from within the shadow DOM?

That’s where the host() pseudo-class function comes in. This selector allows you to access the shadow host from anywhere within the shadow root. Here’s an example:

<div id="host"></div>

<script>
  const elem = document.querySelector('#host');
  const shadowRoot = elem.attachShadow({mode: 'open'});

  shadowRoot.innerHTML = `
    <p>Shadow DOM</p>
    <style>
      :host {
        display: inline-block;
        border: solid 3px #ccc;
        padding: 0 15px;
      }
    </style>`;

</script>

It’s worth noting that :host is only valid within a shadow root. Also keep in mind that style rules defined outside the shadow root have a higher specificity than rules defined in :host.

For instance, #host { font-size: 16px; } beats the shadow DOM’s :host { font-size: 20px; }. This is actually useful because it allows you to define default style for your component and let the user of the component override your styling. The only exception is !important rules, which have a higher specificity inside a shadow DOM.

You can also pass a selector as an argument to :host(), which allows you to target the host only if it is matched by the specified selector. In other words, it allows you to target different states of the same host:

<style>
  :host(:focus) {
    /* style host only if it has received focus */
  }

  :host(.blue) {
    /* style host only if has a blue class */
  }

  :host([disabled]) {
    /* style host only if it's disabled */
  }
</style>

Styling based on context

To select a shadow root host that is inside a particular ancestor, you can use the :host-context() pseudo-class function. For example:

:host-context(.main) {
  font-weight: bold;
}

This CSS code selects a shadow host only if it is a descendant of .main:

<body class="main">
  <div id="host">
  </div>
</body>

:host-context() is especially useful for theming because it allows the author to style a component based on the context in which it’s used.

Style hooks

An interesting aspect of shadow DOM is its ability to create “style placeholders” and allow the user to fill them in. This can be done by using CSS custom properties. Let’s look at a simple example:

<div id="host"></div>

<style>
  #host {--size: 20px;}
</style>

<script>
  const elem = document.querySelector('#host');
  const shadowRoot = elem.attachShadow({mode: 'open'});

  shadowRoot.innerHTML = `
    <p>Shadow DOM</p>
    <style>p {font-size: var(--size, 16px);}</style>`;

</script>

This shadow DOM allows users to override the font size of its paragraphs. The value is set using custom property notation ( — size: 20px) and the shadow DOM retrieves the value using the var() function (font-size: var( — size, 16px)). In terms of concept, this is similar to how the element works.

Inheritable styles

Shadow DOM allows you to create isolated DOM elements with no selector visibility from outside, but that doesn’t mean inherited properties won’t make their way through the shadow boundary.

Certain properties, such as color, background, and font-family, pass the shadow boundary and apply to the shadow tree. So, compared to an iframe, a shadow DOM is not a very strong barrier.

<style>
  div {
    font-size: 25px;
    text-transform: uppercase;
    color: red;
  }
</style>

<div><p>Light DOM</p></div>
<div id="host"></div>

<script>
  const elem = document.querySelector('#host');
  const shadowRoot = elem.attachShadow({mode: 'open'});

  shadowRoot.innerHTML = `<p>Shadow DOM</p>`;
</script>

The workaround is simple: reset inheritable styles to their initial value by declaring all: initial, like this:

<style>
  div {
    font-size: 25px;
    text-transform: uppercase;
    color: red;
  }
</style>

<div><p>Light DOM</p></div>
<div id="host"></div>

<script>
  const elem = document.querySelector('#host');
  const shadowRoot = elem.attachShadow({mode: 'open'});

  shadowRoot.innerHTML = `
    <p>Shadow DOM</p>
    <style>
      :host p {
        all: initial;
      }
    </style>`;
</script>

In this example, the elements are forced back to the initial state, so styles crossing the shadow boundary have no effect.

Event retargeting

An event triggered within a shadow DOM may cross the shadow boundary and bubble up the light DOM; however, the value of Event.target is automatically changed so it looks as if the event was originated from the host element that contains the shadow tree rather than the actual element.

This change is known as event retargeting, and the reasoning behind it is to preserve shadow DOM encapsulation. Consider the following example:

<div id="host"></div>

<script>
  const elem = document.querySelector('#host');
  const shadowRoot = elem.attachShadow({mode: 'open'});

  shadowRoot.innerHTML = `
    <ul>
      <li>One</li>
      <li>Two</li>
      <li>Three</li>
    <ul>
    `;

  document.addEventListener('click', (event) => {
    console.log(event.target);
  }, false);
</script>

This code logs <div id="host">…</div> to the console when you click anywhere in the shadow DOM, so the listener cannot see the actual element that dispatched the event.

Retargeting does not occur in the shadow DOM, however, and you can readily find the actual element an event is associated with:

<div id="host"></div>

<script>
  const elem = document.querySelector('#host');
  const shadowRoot = elem.attachShadow({mode: 'open'});

  shadowRoot.innerHTML = `
    <ul>
      <li>One</li>
      <li>Two</li>
      <li>Three</li>
    </ul>`;

  shadowRoot.querySelector('ul').addEventListener('click', (event) => {
    console.log(event.target);
  }, false);  
</script>

Note that not all events propagate out of the shadow DOM. Those that do are retargeted, but others are simply ignored. If you are using custom events, you’ll need to use the composed: true flag, otherwise the event won’t bubble out of the shadow boundary.

Shadow DOM v0 vs. v1

The original version of the Shadow DOM specification was implemented in Chrome 25 and was known as Shadow DOM v0 at the time. The updated version of the specification improves many aspects of the Shadow DOM API.

For example, an element can no longer host more than one shadow DOM, and some elements cannot host a shadow DOM at all. Violating these rules causes an error.

Additionally, Shadow DOM v1 provides a set of new features, such as open shadow mode, fallback contents, and more. You can find a comprehensive side-by-side comparison of v0 and v1 here, written by one of the specification authors. A full description of Shadow DOM v1 can be found at W3C.

Browser support for Shadow DOM v1

At the time of this writing, Firefox and Chrome fully support Shadow DOM v1. Unfortunately, Edge has not implemented v1 yet, and Safari partially supports it. An up-to-date list of supported browsers is available on Can I use... .

To implement shadow DOM on browsers that do not support Shadow DOM v1, you can use the shadydom and shadycss polyfills.

Wrapping up

The lack of encapsulation in DOM has long been problematic for web developers. The Shadow DOM API offers an elegant solution to this problem by giving us the ability to create scoped DOM.

Now, style collisions are no longer a source of concern, and selectors do not grow out of control. The shadow DOM is a game changer for widget developers. It’s a huge plus to be able to create widgets that are encapsulated from the rest of the page and not affected by the presence of other stylesheets and scripts.

As mentioned earlier, Web Components consists of three main technologies, and shadow DOM is a key part of it. Hopefully, after reading this post, you will have an easier time understanding how all three technologies work together to build Web Components.

Do you have some tips to share? Let us know in the comments!


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.


The post Understanding Shadow DOM v1 appeared first on LogRocket Blog.

Top comments (0)