Shadow DOM 201

CSS and Styling

HTML5 Rocks

This article discusses more of the amazing things you can do with Shadow DOM. It builds on the concepts discussed in Shadow DOM 101. If you're looking for an introduction, see that article.

Introduction

Let's face it. There's nothing sexy about unstyled markup. Lucky for us, the brilliant folks behind Web Components foresaw this and didn't leave us hanging. The CSS Scoping Module defines many options for styling content in a shadow tree.

In Chrome, turn on the "Enable experimental Web Platform features" in about:flags to experiment with everything covered in this article.

Style encapsulation

One of the core features of Shadow DOM is the shadow boundary. It has a lot of nice properties, but one of the best is that it provides style encapsulation for free. Stated another way:

CSS styles defined inside Shadow DOM are scoped to the ShadowRoot. This means styles are encapsulated by default.

Below is an example. If all goes well and your browser supports Shadow DOM (it doesn't!), you'll see "Shadow DOM".

<div><h3>Light DOM</h3></div>
<script>
var root = document.querySelector('div').createShadowRoot();
root.innerHTML = '<style>h3{ color: red; }</style>' + 
                 '<h3>Shadow DOM</h3>';
</script>

Light DOM

There are two interesting observations about this demo:

  • There are other h3s on this page, but the only one that matches the h3 selector, and therefore styled red, is the one in the ShadowRoot. Again, scoped styles by default.
  • Other styles rules defined on this page that target h3s don't bleed into my content. That's because selectors don't cross the shadow boundary.

Moral of the story? We have style encapsulation from the outside world. Thanks Shadow DOM!

Styling the host element

The :host allows you to select and style the element hosting a shadow tree:

<button class="red">My Button</button>
<script>
var button = document.querySelector('button');
var root = button.createShadowRoot();
root.innerHTML = '<style>' + 
    ':host { text-transform: uppercase; }' +
    '</style>' + 
    '<content></content>';
</script>

One gotcha is that rules in the parent page have higher specificity than :host rules defined in the element, but lower specificity than a style attribute defined on the host element. This allows users to override your styling from the outside. :host also only works in the context of a ShadowRoot so you can't use it outside of Shadow DOM.

The functional form of :host(<selector>) allows you to target the host element if it matches a <selector>.

Example - match only if the element itself has the class .different (e.g.. <x-foo class="different"></x-foo>):

:host(.different) {
  ...  
}

Reacting to user states

A common use case for :host is when you're creating a Custom Element and want to react to different user states (:hover, :focus, :active, etc.).

<style>
:host {
  opacity: 0.4;
  transition: opacity 420ms ease-in-out;
}
:host(:hover) {
  opacity: 1;
}
:host(:active) {
  position: relative;
  top: 3px;
  left: 3px;
}
</style>

Theming an element

The :host-context(<selector>) pseudo class matches the host element if it or any of its ancestors matches <selector>.

A common use of :host-context() is for theming an element based on its surrounds. For example, many people do theming by applying a class to <html> or <body>:

<body class="different">
  <x-foo></x-foo>
</body>

You can :host-context(.different) to style <x-foo> when it's a descendant of an element with the class .different:

:host-context(.different) {
  color: red;
}

This gives you the ability encapsulate style rules in an element's Shadow DOM that uniquely style it, based on its context.

Support multiple host types from within one shadow root

Another use for :host is if you're creating a theming library and want to support styling many types of host elements from within the same Shadow DOM.

:host(x-foo) { 
  /* Applies if the host is a <x-foo> element.*/
}

:host(x-foo:host) { 
  /* Same as above. Applies if the host is a <x-foo> element. */
}

:host(div) {  {
  /* Applies if the host element is a <div>. */
}

Styling Shadow DOM internals from the outside

The ::shadow pseudo-element and /deep/ combinator are like having a Vorpal sword of CSS authority. They allow piercing through Shadow DOM's boundary to style elements within shadow trees.

The ::shadow pseudo-element

If an element has at least one shadow tree, the ::shadow pseudo-element matches the shadow root itself. It allows you to write selectors that style nodes internal to an element's shadow dom.

For example, if an element is hosting a shadow root, you can write #host::shadow span {} to style all of the spans within its shadow tree.

<style>
  #host::shadow span {
    color: red;
  }
</style>

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

<script>
  var host = document.querySelector('div');
  var root = host.createShadowRoot();
  root.innerHTML = "<span>Shadow DOM</span>" + 
                   "<content></content>";
</script>
Light DOM

Example (custom elements) - <x-tabs> has <x-panel> children in its Shadow DOM. Each panel hosts its own shadow tree containing h2 headings. To style those headings from the main page, one could write:

x-tabs::shadow x-panel::shadow h2 {
  ...
}

The /deep/ combinator

The /deep/ combinator is similar to ::shadow, but more powerful. It completely ignores all shadow boundaries and crosses into any number of shadow trees. Put simply, /deep/ allows you to drill into an element's guts and target any node.

The /deep/ combinator is particularly useful in the world of Custom Elements where it's common to have multiple levels of Shadow DOM. Prime examples are nesting a bunch of custom elements (each hosting their own shadow tree) or creating an element that inherits from another using <shadow>.

Example (custom elements) - select all <x-panel> elements that are descendants of <x-tabs>, anywhere in the tree:

x-tabs /deep/ x-panel {
  ...
}

Example - style all elements with the class .library-theme, anywhere in a shadow tree:

body /deep/ .library-theme {
  ...
}

Working with querySelector()

Just like .shadowRoot opens shadow trees up for DOM traversal, the combinators open shadow trees for selector traversal. Instead of writing a nested chain of madness, you can write a single statement:

// No fun.
document.querySelector('x-tabs').shadowRoot
        .querySelector('x-panel').shadowRoot
        .querySelector('#foo');

// Fun.
document.querySelector('x-tabs::shadow x-panel::shadow #foo');

Styling native elements

Native HTML controls are a challenge to style. Many people simply give up and roll their own. However, with ::shadow and /deep/, any element in the web platform that uses Shadow DOM can be styled. Great examples are the <input> types and <video>:

video /deep/ input[type="range"] {
  background: hotpink;
}
Do the ::shadow pseudo-element and /deep/ combinator defeat the purpose of style encapsulation? Out of the box, Shadow DOM prevents accidental styling from outsiders but it never promises to be a bullet proof vest. Developers should be allowed to intentionally style inner parts of your Shadow tree...if they know what they're doing. Having more control is also good for flexibility, theming, and the re-usability of your elements.

Creating style hooks

Customization is good. In certain cases, you may want to poke holes in your Shadow's styling shield and create hooks for others to style.

Using ::shadow and /deep/

There's a lot of power behind /deep/. It gives component authors a way to designate individual elements as styleable or a slew of elements as themeable.

Example - style all elements that have the class .library-theme, ignoring all shadow trees:

body /deep/ .library-theme {
  ...
}

Using CSS Variables

A powerful way to create theming hooks will be through CSS Variables. Essentially, creating "style placeholders" for other users to fill in.

Imagine a custom element author who marks out variable placeholders in their Shadow DOM. One for styling an internal button's font and another for its color:

button {
  color: var(--button-text-color, pink); /* default color will be pink */
  font-family: var(--button-font);
}

Then, the embedder of the element defines those values to their liking. Perhaps to match the super cool Comic Sans theme of their own page:

#host {
  --button-text-color: green;
  --button-font: "Comic Sans MS", "Comic Sans", cursive;
}

Due to the way CSS Variables inherit, everything is peachy and this works beautifully! The whole picture looks like this:

<style>
  #host {
    --button-text-color: green;
    --button-font: "Comic Sans MS", "Comic Sans", cursive;
  }
</style>
<div id="host">Host node</div>
<script>
var root = document.querySelector('#host').createShadowRoot();
root.innerHTML = '<style>' + 
    'button {' + 
      'color: var(--button-text-color, pink);' + 
      'font-family: var(--button-font);' + 
    '}' +
    '</style>' +
    '<content></content>';
</script>
I've already mentioned Custom Elements a few times in this article. For now, just know that Shadow DOM forms their structural foundation by providing styling and DOM encapsulation. The concepts here pertain to styling Custom Elements.

Styling distributed nodes

Distributed nodes are elements that render at an insertion point (a <content> element). The <content> element allows you to select nodes from the Light DOM and render them at predefined locations in your Shadow DOM. They're not logically in the Shadow DOM; they're still children of the host element. Insertion points are just a rendering thing.

Distributed nodes retain styles from the main document. That is, style rules from the main page continue to apply to the elements, even when they render at an insertion point. Again, distributed nodes are still logically in the light dom and don't move. They just render elsewhere. However, when the nodes get distributed into the Shadow DOM, they can take on additional styles defined inside the shadow tree.

::content pseudo element

Distributed nodes are children of the host element, so how can we target them from within the Shadow DOM? The answer is the CSS ::content pseudo element. It's a way to target Light DOM nodes that pass through an insertion point. For example:

::content > h3 styles any h3 tags that pass through an insertion point.

Let's see an example:

<div>
  <h3>Light DOM</h3>
  <section>
    <div>I'm not underlined</div>
    <p>I'm underlined in Shadow DOM!</p>
  </section>
</div>

<script>
var div = document.querySelector('div');
var root = div.createShadowRoot();
root.innerHTML = '\
    <style>\
      h3 { color: red; }\
      content[select="h3"]::content > h3 {\
        color: green;\
      }\
      ::content section p {\
        text-decoration: underline;\
      }\
    </style>\
    <h3>Shadow DOM</h3>\
    <content select="h3"></content>\
    <content select="section"></content>';
</script>

Light DOM

I'm not underlined

I'm underlined in Shadow DOM!

You should see "Shadow DOM" and "Light DOM" below it. Also note that "Light DOM" is still retaining the styles (margins etc.) defined on this page. That's because the page's styles still match!

Remember: styles defined in the host document continue to apply to nodes they target, even when those nodes get distributed "inside" the Shadow DOM. Going into an insertion point doesn't change what's applied.

Conclusion

As authors of custom elements, we have a ton of options for controlling the look and feel of our content. Shadow DOM forms the basis for this brave new world.

Shadow DOM gives us scoped style encapsulation and a means to let in as much (or as little) of the outside world as we choose. By defining custom pseudo elements or including CSS Variable placeholders, authors can provide third-parties convenient styling hooks to further customize their content. All in all, web authors are in full control of how their content is represented.

Thanks to Dominic Cooney and Dimitri Glazkov for reviewing the content of this tutorial.

Comments

0