Web Components Guide
Beta

Rendering

When a Web Component initializes, it can start interacting with the document and make changes to it. While a component could make manipulate other parts of DOM or even it's children, there's a better way. Each component can have its own DOM, encapsulated from the rest of the page. This is called the ShadowDOM. A component can create and attach a ShadowDOM to itself by using attachShadow({ mode: 'open' }):

class StopWatchElement extends HTMLElement {
  static define(tag = "stop-watch") {
    customElements.define(tag, this)
  }

  // Make a ShadowDOM and assign it to public state
  shadowRoot = this.attachShadow({ mode: "open" })
}

The .shadowRoot property is always present on an element. It will return an elements open shadow root or null if it doesn't have one. Setting shadowRoot = isn't necessary - it's redundant - but it makes the code more readable.

This ShadowDOM tree is exclusive to your Web Component. The rest of the document can't accidentally influence it. You can add new elements into a ShadowDOM - even stylesheets - and it won't impact the rest of the DOM. Other logic or styles won't get access to a components ShadowDOM. CSS selectors do not cross the boundary between the main light DOM and the ShadowDOM. APIs like .querySelector(), or .children also don't cross this boundary. All this means the ShadowDOM is your components safe space to put whatever content it needs to render.

One way to make use of this ShadowDOM is to use the same DOM APIs to construct the contents. For example setting the .innerHTML whenever your element gets connected. To do this you can use the connectedCallback() lifecycle function:

class StopWatchElement extends HTMLElement {
  static define(tag = "stop-watch") {
    customElements.define(tag, this)
  }

  shadowRoot = this.attachShadow({ mode: "open" })

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <span>00:00:00</span>
      <button>Start</button>
    `
  }
}
<my-component></my-component>

You can define a <template> element up front. Using the cloneNode() API efficiently copies the template into the Shadow Root. This requires less computation than setting innerHTML each time, and can be easier to read. It also separates your template from your logic. Keeping templates away from logic keeps code cleaner as your component grows in complexity.

// The template can be defined up front and re-used
const template = document.createElement("template")
template.innerHTML = `
  <span>00:00:00</span>
  <button>Start</button>
`

class StopWatchElement extends HTMLElement {
  static define(tag = "stop-watch") {
    customElements.define(tag, this)
  }

  shadowRoot = this.attachShadow({ mode: "open" })

  connectedCallback() {
    // The template can be cloned and then added to the ShadowDOM
    this.shadowRoot.replaceChildren(template.content.cloneNode(true))
  }
}

Declaring your template in HTML

If you don't want to define the template within JavaScript, you can instead define it up-front in the HTML of your component with a declarative ShadowDOM template. When you define a declarative ShadowDOM template the browser will handle attaching a shadow root and cloning the contents of the template for you. To do this, you'll need to add a <template> tag as a child of your element, with a shadowrootmode attribute:

<stop-watch>
  <template shadowrootmode="open">
    <span>00:00:00</span>
    <button>Start</button>
  </template>
</stop-watch>
class StopWatchElement extends HTMLElement {
  static define(tag = "stop-watch") {
    customElements.define(tag, this)
  }

  connectedCallback() {
    // The component's ShadowDOM is now available as
    // this.shadowRoot:
    const el = this.shadowRoot.querySelector("span")
    console.assert(el.outerHTML === `<span>00:00:00</span>`)
  }
}

You will need to declare the <template> tag for each element you want to have a declarative ShadowDOM. This means repeating your <template> tag for every element in your HTML. It's a good idea to use a server side language that supports partials or some other system to avoid manually repeating this each time.

Advanced: Adding declarative ShadowDOM fallback

Given that a declarative ShadowDOM might not always be available, it can be a good idea to fall back to defining it in JavaScript instead. You can do this by checking if .shadowRoot is not null before calling attachShadow({ mode: 'open' }).

const template = document.createElement("template")
template.innerHTML = `
  <span>00:00:00</span>
  <button>Start</button>
`

class StopWatchElement extends HTMLElement {
  static define(tag = "stop-watch") {
    customElements.define(tag, this)
  }

  constructor() {
    super()
    if (!this.shadowRoot) {
      this.attachShadow({ mode: "open" })
      this.shadowRoot.replaceChildren(template.content.cloneNode(true))
    }
  }
}

Advanced: Using a closed ShadowDOM

You might have noticed that attachShadow() has to be passed mode: 'open' (and similarly declarative ShadowDOM is created using <template shadowrootmode="open">). This tells the ShadowRoot to be in "open" mode, which makes it public. Other elements will be able to access an open ShadowRoot via the .shadowRoot property - even if you don't set it yourself. Generally speaking, open ShadowRoots are the best choice; they still offer good isolation and are easy to work with.

Another option, however, is to set it to mode: 'closed'. This makes your ShadowRoot private. A closed ShadowRoot will not be accessible via .shadowRoot (unless you intentionally assign it to .shadowRoot). It will also change some other minor edge cases:

If those two points don't make sense, then don't worry! They're seldom used APIs. The point being, it's more difficult for outside code to find its way to your ShadowRoot. It's important to note that private doesn't mean secure. It's still possible to get a closed ShadowRoot, such as overriding the HTMLElement.prototype.attachShadow function itself. Don't rely on closed ShadowRoots for security.

Using a closed ShadowRoot does mean there's a bit more work involved within your component to access the ShadowRoot. If you're calling attachShadow in JavaScript, you will want to set it to a private field:

class StopWatchElement extends HTMLElement {
  static define(tag = "stop-watch") {
    customElements.define(tag, this)
  }

  // Define a "private" or closed shadow root
  #shadowRoot = this.attachShadow({ mode: "closed" })

  connectedCallback() {
    // The standard .shadowRoot property will be null
    console.assert(this.shadowRoot === null)

    // It can only be access via your private field
    console.assert(this.#shadowRoot instanceof ShadowRoot)
  }
}

If you're using a declarative ShadowDOM then you'll need to use an API called Element Internals. To get to a Web Components Element Internals, .attachInternals() can be called. It can only be called once though - subsequent calls will throw an error. Internals should be private in a Web Component class as - like the name suggests - they contain a variety of internal APIs for a Web Component. The Element Internals API also has a .shadowRoot property, but this one can also get the closed ShadowRoot:

<stop-watch>
  <template shadowrootmode="closed">
    <span>00:00:00</span>
    <button>Start</button>
  </template>
</stop-watch>
class StopWatchElement extends HTMLElement {
  static define(tag = "stop-watch") {
    customElements.define(tag, this)
  }

  // Capture the Element Internals
  #internals = this.attachInternals()

  // Get the closed declarative ShadowRoot from internals:
  #shadowRoot = this.#internals.shadowRoot
}