DOM - Shadow DOM (Scoped DOM)

1 - About

Shadow DOM provides a way for an element to own, render, and style a chunk of DOM that's separate from the rest of the page. It's not in the global DOM. This is a scope functionality

An iframe offers also scope functionalities but the shadow dom offers more feature.

3 - Why

When the scope is global:

  • new HTML id/class may conflict with an existing name
  • CSS styling rule becomes then a huge issue (The !important rule becomes the rule), style selectors grow out of control, and performance can suffer.

4 - Example

custom HTML fancy-tabs creating tabs in a shadow dom (Adapted from source) - Hover on the result and click the try the code button to see the code.


<fancy-tabs background>
  <button slot="title">Tab 1</button>
  <button slot="title" selected>Tab 2</button>
  <button slot="title">Tab 3</button>
  <section>content panel 1</section>
  <section>content panel 2</section>
  <section>content panel 3</section>
</fancy-tabs>


(function() {
'use strict';
 
// Feature detect
if (!(window.customElements && document.body.attachShadow)) {
  document.querySelector('fancy-tabs').innerHTML = "<b>Your browser doesn't support Shadow DOM and Custom Elements v1.</b>";
  return;
}

let selected_ = null;
  
customElements.define('fancy-tabs', class extends HTMLElement {

  constructor() {
    super(); // always call super() first in the ctor.

    // Create shadow DOM for the component.
    let shadowRoot = this.attachShadow({mode: 'open'});
    shadowRoot.innerHTML = `
      <style>
        :host {
          display: inline-block;
          font-family: 'Roboto Slab';
          contain: content;
        }
        :host([background]) {
          background: var(--background-color, #9E9E9E);
          border-radius: 10px;
          padding: 10px;
        }
        #panels {
          box-shadow: 0 2px 2px rgba(0, 0, 0, .3);
          background: white;
          border-radius: 3px;
          padding: 16px;
          height: 50px;
          overflow: auto;
        }
        #tabs {
          display: inline-flex;
          -webkit-user-select: none;
          user-select: none;
        }
        #tabs slot {
          display: inline-flex; /* Safari bug. Treats <slot> as a parent */
        }
        /* Safari does not support #id prefixes on ::slotted
           See https://bugs.webkit.org/show_bug.cgi?id=160538 */
        #tabs ::slotted(*) {
          font: 400 16px/22px 'Roboto';
          padding: 16px 8px;
          margin: 0;
          text-align: center;
          width: 100px;
          text-overflow: ellipsis;
          white-space: nowrap;
          overflow: hidden;
          cursor: pointer;
          border-top-left-radius: 3px;
          border-top-right-radius: 3px;
          background: linear-gradient(#fafafa, #eee);
          border: none; /* if the user users a <button> */
        }
        #tabs ::slotted([aria-selected="true"]) {
          font-weight: 600;
          background: white;
          box-shadow: none;
        }
        #tabs ::slotted(:focus) {
          z-index: 1; /* make sure focus ring doesn't get buried */
        }
        #panels ::slotted([aria-hidden="true"]) {
          display: none ! important;
        }
      </style>
      <div id="tabs">
        <slot id="tabsSlot" name="title"></slot>
      </div>
      <div id="panels">
        <slot id="panelsSlot"></slot>
      </div>
    `;
  }
  
  get selected() {
    return selected_;
  }

  set selected(idx) {
    selected_ = idx;
    this._selectTab(idx);

    // Updated the element's selected attribute value when
    // backing property changes.
    this.setAttribute('selected', idx);
  }
  
  connectedCallback() {
    this.setAttribute('role', 'tablist');

    const tabsSlot = this.shadowRoot.querySelector('#tabsSlot');
    const panelsSlot = this.shadowRoot.querySelector('#panelsSlot');

    this.tabs = tabsSlot.assignedNodes({flatten: true});
    this.panels = panelsSlot.assignedNodes({flatten: true}).filter(el => {
      return el.nodeType === Node.ELEMENT_NODE;
    });
    
    // Add aria role="tabpanel" to each content panel.
    for (let [i, panel] of this.panels.entries()) {
      panel.setAttribute('role', 'tabpanel');
      panel.setAttribute('tabindex', 0);
    }
    
    // Save refer to we can remove listeners later.
    this._boundOnTitleClick = this._onTitleClick.bind(this);
    this._boundOnKeyDown = this._onKeyDown.bind(this);

    tabsSlot.addEventListener('click', this._boundOnTitleClick);
    tabsSlot.addEventListener('keydown', this._boundOnKeyDown);
    
    this.selected = this._findFirstSelectedTab() || 0;
  }
  
  disconnectedCallback() {
    const tabsSlot = this.shadowRoot.querySelector('#tabsSlot');
    tabsSlot.removeEventListener('click', this._boundOnTitleClick);
    tabsSlot.removeEventListener('keydown', this._boundOnKeyDown);
  }
  
  _onTitleClick(e) { 
    if (e.target.slot === 'title') {
      this.selected = this.tabs.indexOf(e.target);
      e.target.focus();
    }
  }
  
  _onKeyDown(e) {
    switch (e.code) {
      case 'ArrowUp':
      case 'ArrowLeft':
        e.preventDefault();
        var idx = this.selected - 1;
        idx = idx < 0 ? this.tabs.length - 1 : idx;
        this.tabs[idx].click();
        break;
      case 'ArrowDown':
      case 'ArrowRight':
        e.preventDefault();
        var idx = this.selected + 1;
        this.tabs[idx % this.tabs.length].click();
        break;
      default:
        break;
    }
  }

  _findFirstSelectedTab() {
    let selectedIdx;
    for (let [i, tab] of this.tabs.entries()) {
      tab.setAttribute('role', 'tab');

      // Allow users to declaratively select a tab
      // Highlight last tab which has the selected attribute.
      if (tab.hasAttribute('selected')) {
        selectedIdx = i;
      }
    }
    return selectedIdx;
  }
  
  _selectTab(idx = null) {
    for (let i = 0, tab; tab = this.tabs[i]; ++i) {
      let select = i === idx;
      tab.setAttribute('tabindex', select ? 0 : -1);
      tab.setAttribute('aria-selected', select);
      this.panels[i].setAttribute('aria-hidden', !select);
    }
  }
  
});
   
})();

5 - Styling

Ref

5.1 - Scoped

CSS selectors (In stylesheets or inline ) used inside shadow DOM apply only locally. Outer CSS selectors does not apply by default into a shadow dom.


<link rel="stylesheet" href="styles.css">
<style>
    #panels {
      box-shadow: 0 2px 2px rgba(0, 0, 0, .3);
      background: white;
      ...
    }
    #tabs {
      display: inline-flex;
      ...
    }
</style>

5.2 - Styling the shadow element

  • Shadow DOM components can style themselves too, by using the :host selector.

<style>
:host {
  display: block; /* by default, custom elements are display: inline */
  contain: content; /* CSS containment FTW. */
}
</style>

5.3 - Outer Styling

Example with the following html


<body class="darktheme">
  <fancy-tabs>
    ...
  </fancy-tabs>
</body>

The below :host-context(.darktheme) CSS selector will style <fancy-tabs> because it's a descendant of the .darktheme class.


:host-context(.darktheme) {
  color: white;
  background: black;
}

5.4 - Hook

hooks are CSS custom properties

6 - Documentation / Reference


Data Science
Data Analysis
Statistics
Data Science
Linear Algebra Mathematics
Trigonometry

Powered by ComboStrap