Introducing Slot-Based Shadow DOM API

We’re pleased to announce that basic support for the new slot-based shadow DOM API we proposed in April is now available in the nightly builds of WebKit after r190680. Shadow DOM is a part of Web Components, a set of specifications that were initially proposed by Google to enable the creation of reusable widgets and components on the Web. Shadow DOM, in particular, provides a lightweight encapsulation for DOM trees by allowing a creation of a parallel tree on an element called a “shadow tree” that replaces the rendering of the element without modifying the underlying DOM tree. Because a shadow tree is not an ordinary child of the “host” element to which it is attached, users of components cannot accidentally poke into it. Style rules are also scoped, meaning that CSS rules defined outside of a shadow tree do not apply to elements inside the shadow tree and rules defined inside the shadow tree do not apply to elements outside of it.

Style Isolation

One major benefit of using shadow DOM is style isolation. To see how, let’s say we want to create a custom progress bar. We can use two nested div’s to show the bar and another div with the text to show the percentage as follows:

<style>
.progress { position: relative; border: solid 1px #000; padding: 1px; width: 100px; height: 1rem; }
.progress > .bar { background: #9cf; height: 100%; }
.progress > .label { position: absolute; top: 0; left: 0; width: 100%;
    text-align: center; font-size: 0.8rem; line-height: 1.1rem; }
</style>
<template id="progress-bar-template">
    <div class="progress" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
        <div class="bar"></div>
        <div class="label">0%</div>
    </div>
</template>
<script>
function createProgressBar() {
    var fragment = document.getElementById('progress-bar-template').content.cloneNode(true);
    var progressBar = fragment.querySelector('div');
    progressBar.updateProgress = function (newPercentage) {
        this.setAttribute('aria-valuenow', newPercentage);
        this.querySelector('.label').textContent = newPercentage + '%';
        this.querySelector('.bar').style.width = newPercentage + '%';
    }
    return progressBar;
}
</script>

Note the use of the template element, which allows authors to include a snippet of HTML that can be instantiated later by cloning the content. This was the first feature of Web Components we implemented in WebKit that later got merged into the HTML5 specification. A template element can appear anywhere in a document (e.g. between table and tr elements), and content inside template elements is inert and does not run scripts or load images and other types of subresources. Then the user of this custom progress bar can instantiate it and update the progress as follows:

var progressBar = createProgressBar();
container.appendChild(progressBar);
...
progressBar.updateProgress(10);

The problem with this progress bar implementation is that its two internal divs are freely accessible to its users and its style rules are not scoped to the progress bar. For example, the style rules defined for the progress bar will apply to content outside the progress bar with the class name progress:

<section class="project">
    <p class="progress">Pending an approval</p>
</section>

Similarly, style rules defined for other elements could override rules in the progress bar:

<style>
.label { font-weight: bold; }
</style>

While we could work around these problems by using a custom element name such as custom-progressbar to scope rules and then initialize all other properties by all: initial, Shadow DOM provides a much more elegant solution. The idea here is to introduce an encapsulation layer at the outer div so that users of the progress bar don’t see its internals (such as divs created for the label and the bar) and styles defined for the progress bar don’t interfere with the rest of the page and vice versa. To do that, we first create a ShadowRoot on the progress bar by calling attachShadow({mode: 'closed'}), and then append various nodes needed for its implementation under it. Let’s say we’re still using a div to “host” this shadow root, then we can create a new div and attach a shadow root as follows:

<template id="progress-bar-template">
    <style>
        .progress { position: relative; border: solid 1px #000; padding: 1px; width: 100px; height: 1rem; }
        .progress > .bar { background: #9cf; height: 100%; }
        .progress > .label { position: absolute; top: 0; left: 0; width: 100%;
            text-align: center; font-size: 0.8rem; line-height: 1.1rem; }
    </style>
    <div class="progress" role="progressbar" aria-valuenow="0" aria-valuemin="0" aria-valuemax="100">
        <div class="bar"></div>
        <div class="label">0%</div>
    </div>
</template>
<script>
function createProgressBar() {
    var progressBar = document.createElement('div');
    var shadowRoot = progressBar.attachShadow({mode: 'closed'});
    shadowRoot.appendChild(document.getElementById('progress-bar-template').content.cloneNode(true));
    progressBar.updateProgress = function (newPercentage) {
        shadowRoot.querySelector('.progress').setAttribute('aria-valuenow', newPercentage);
        shadowRoot.querySelector('.label').textContent = newPercentage + '%';
        shadowRoot.querySelector('.bar').style.width = newPercentage + '%';
    }
    return progressBar;
}
</script>

Notice that the style element is inside the template element and cloned into the shadow root along with the divs. This allows the style rules defined inside the shadow root to be scoped. Style rules defined outside a shadow root do not apply to elements inside the shadow root either. Tip: while debugging your code, you may find it helpful to use shadow DOM’s open mode so that you can access the newly created shadow root via the shadowRoot property of the host element. e.g. {mode: DEBUG ? 'open' : 'closed'}

Composition with Slots

At this point, you might be wondering why this had to be done in DOM instead of CSS. Styling is a presentational concept, so why should we add new elements to the DOM? In fact, the first public working draft of the CSS Scoping Module Level 1 defines the @scope rule, which enables exactly that. So why did we need to add another mechanism to isolate styles? One motivation was to allow elements used in the implementation of components to be hidden from node traversal APIs such as querySelectorAll and getElementsByTagName. Because nodes inside a shadow root are not found by these APIs by default, users of components that utilize shadow DOM do not need to worry about how each component is implemented. Each component is presented as an opaque element whose implementation details are encapsulated in its shadow DOM. Note that shadow DOM does not provide a cross-origin iframe-like security boundary. Scripts can easily bypass the shadow DOM boundary if needed. Another reason we need a DOM-based solution is for composition. Let’s say we have a list of contacts:

<ul id="contacts">
    <li>
        Commit Queue
        (<a href="mailto:commit-queue@webkit.org">commit-queue@webkit.org</a>)<br>
        One Infinite Loop, Cupertino, CA 95014
    </li>
    <li>
        Niwa, Ryosuke
        (<a href="mailto:rniwa@webkit.org">rniwa@webkit.org</a>)<br>
        Two Infinite Loop, Cupertino, CA 95014
    </li>
</ul>

and we would like to add a fancy UI for each contact’s information in the list when scripts are enabled:

Instead of copying all of this text over to our own shadow DOM, we can use named slots to render the text elsewhere in our shadow DOM without modifying the DOM as follows:

<template id="contact-template">
    <style>
        :host { border: solid 1px #ccc; border-radius: 0.5rem; padding: 0.5rem; margin: 0.5rem; }
        b { display: inline-block; width: 5rem; }
    </style>
    <b>Name</b>: <slot name="fullName"><slot name="firstName"></slot> <slot name="lastName"></slot></slot><br>
    <b>Email</b>: <slot name="email">Unknown</slot><br>
    <b>Address</b>: <slot name="address">Unknown</slot>
</template>
<script>
window.addEventListener('DOMContentLoaded', function () {
    var contacts = document.getElementById('contacts').children;
    var template = document.getElementById('contact-template').content;
    for (var i = 0; i < contacts.length; i++)
        contacts[i].attachShadow({mode: 'closed'}).appendChild(template.cloneNode(true));
});
</script>

Conceptually, slots are holes in a shadow DOM that will be filled by children of its host element. Each element can be assigned into a slot of a specific name by the slot attribute as follows:

<ul id="contacts">
    <li>
        <span slot="fullName">Commit Queue</span>
        (<a slot="email" href="mailto:commit-queue@webkit.org">commit-queue@webkit.org</a>)<br>
        <span slot="address">One Infinite Loop, Cupertino, CA 95014</span>
    </li>
</ul>

Here, we’re attaching a shadow root to the li, and each span with a slot attribute is assigned to the slot of the same name inside the shadow DOM. Let’s take a closer look at the shadow DOM template:

<b>Name</b>:
<slot name="fullName">
    <slot name="firstName"></slot>
    <slot name="lastName"></slot>
</slot><br>
<b>Email</b>: <slot name="email">Unknown</slot><br>
<b>Address</b>: <slot name="address">Unknown</slot>

In this template, we have slots named fullName, which contains two other slots named firstName and lastName, and two additional slots named email and address. The fullName slot is taking the advantage of fallback content, and showing firstName and lastName only if there were no nodes assigned to the fullName slot. Even though there is exactly one node assigned to each slot in this example, multiple elements with the same slot attribute value can be assigned to a single slot, and they will appear in the order they appeared as the children of the host element. You can also use an unnamed default slot that will be filled by all of the host’s children that don’t have a slot attribute specified. When a Web browser renders this content, the content of the li element is replaced by the shadow DOM, and slots inside of it are replaced by their assigned node as if rendering the following DOM instead:

<ul id="contacts">
    <li>
        <!--shadow-root-start-->
            <b>Name</b>:
            <slot name="fullName">
                <!--slot-content-start-->
                    <span slot="fullName">Commit Queue</span>
                <!--slot-content-end-->
            </slot><br>
            <b>Email</b>:
            <slot name="email">
                <!--slot-content-start-->
                    <a slot="email" href="mailto:commit-queue@webkit.org">commit-queue@webkit.org</a>
                <!--slot-content-end-->
            </slot><br>
            <b>Address</b>:
            <slot name="address">
                <!--slot-content-start-->
                    <span slot="address">One Infinite Loop, Cupertino, CA 95014</span>
                <!--slot-content-end-->
            </slot>
        <!--shadow-root-end-->
    </li>
</ul>

As you can see, slot-based composition is a powerful tool that allows widgets to pull in the page content without cloning or modifying the DOM. With it, widgets can respond to changes made to its child nodes without MutationObservers or an explicit notification via script. In essence, composition turns the DOM into a communication medium between components.

Styling the Host Element

There is one more thing to note in the previous example, which had a mysterious pseudo-class :host:

<template id="contact-template">
    <style>
        :host { border: solid 1px #ccc; border-radius: 0.5rem; padding: 0.5rem; margin: 0.5rem; }
        b { display: inline-block; width: 5rem; }
    </style>
...
</template>

This pseudo class, as its name suggests, matches the host element of the shadow DOM in which this rule appears. By default, author style rules defined outside the shadow DOM have a higher precedence over rules defined in the shadow DOM. This allows a component to define its “default style”, and let users of the component override as needed. In addition, a component can use !important to force a certain style, such as width and display type, without which it cannot function properly with. Any !important rules defined inside a shadow DOM have a higher precedence over regular and !important rules defined outside the shadow DOM.

Future Work

There is still a lot of work left for Web Components. For styling, we would like to allow styling nodes assigned to a slot inside a shadow DOM. There is also a desire for components to respond to the document theme as well as exposing a stylable part to their users like CSS pseudo elements. In the longer term, we would like to see an imperative DOM API to manipulate slot assignments as we proposed a while ago. To complement shadow DOM, we’re also interested in custom elements. The custom elements API allows authors to associate a JavaScript class with a particular element name in HTML documents, and it’s a great way to attach shadow DOM and other custom behaviors idiomatically. Unfortunately, there are a few conflicting proposals on when and how to create a custom element. To help steer the discussion in W3C, we’re planning to prototype it in WebKit. For packaging and delivering Web Components, we’ve been working on ES6 modules. Like Mozilla, we believe modules will radically change the way authors strcuture their pages. We would also like to eventually design an API to create a fully isolated web component with an iframe-like security boundary on top of shadow DOM and custom elements. To conclude, we’re really excited about bringing a major feature of Web Components to WebKit, and we’ll keep you posted about more features coming your way. If you have any questions, please feel free to contact myself, @WebKit, or Jon Davis.

  翻译: