WebDev Bites: W3C Components Tutorial

Custom Elements, Shadow DOM, and HTML Templates - a concise reference for experienced developers

1.0 - Overview

W3C Web Components is a platform-native suite of three interlocking standards: Custom Elements, Shadow DOM, and HTML Templates. Together they let you define new HTML tags with encapsulated behavior and style - no framework, no build step required. The three standards address distinct concerns and compose cleanly: Encapsulation is the central value. Each component owns its internal structure. Page-level styles cannot leak in; component styles cannot leak out. Components expose their public surface through attributes, properties, methods, and custom events - a stable contract independent of internal implementation.

2.0 - Custom Elements

Every custom element is a class that extends HTMLElement. The class binds to a tag name through customElements.define. Tag names must contain a hyphen - that hyphen permanently reserves the namespace for user-defined elements and avoids collisions with current and future standard elements. Lifecycle callbacks fire at predictable points during the element's lifetime: To receive attributeChangedCallback notifications, declare the attribute names in a static getter. The browser ignores changes to any attribute not listed there.
class MyTag extends HTMLElement {
  static get observedAttributes() {
    return ['label', 'color'];
  }

  constructor() {
    super();                          // always first
    this._shadow = this.attachShadow({ mode: 'open' });
  }

  connectedCallback() {
    this._render();
  }

  attributeChangedCallback(name, oldVal, newVal) {
    if (oldVal !== newVal) this._render();
  }

  _render() {
    const color = this.getAttribute('color') || 'inherit';
    const label = this.getAttribute('label') || '';
    this._shadow.innerHTML = `
      <style>span { color: ${color}; }</style>
      <span>${label}</span>
    `;
  }
}

customElements.define('my-tag', MyTag);
Properties and attributes are distinct. A property is a JavaScript value on the element object; an attribute is a string in the DOM. Reflecting one to the other requires explicit getters and setters - the browser does not do this automatically for custom elements.
get label() {
  return this.getAttribute('label');
}
set label(val) {
  this.setAttribute('label', val);   // triggers attributeChangedCallback
}

3.0 - Shadow DOM

Calling element.attachShadow({ mode: 'open' }) creates a shadow root - an isolated DOM tree hosted by the element. Styles declared inside the shadow root are scoped to it: they cannot affect the outer document, and outer styles cannot reach in, except for inherited CSS properties and CSS custom properties (variables). mode controls external JavaScript access to the shadow root: CSS custom properties cross the shadow boundary in both directions, making them the standard mechanism for theming components from the outside. Define variables on the host element and consume them inside the shadow:
/* outer page - sets the theme token */
my-tag {
  --accent: steelblue;
}

/* inside shadow root */
:host {
  display: block;
}
span {
  color: var(--accent, black);   /* falls back to black if unset */
}
:host inside a shadow stylesheet targets the custom element itself. :host(.active) matches only when the host carries that class, letting the component respond to host state without exposing shadow internals to external CSS.

4.0 - Templates and Slots

A <template> element holds inert HTML - parsed but not rendered, not part of the live DOM. Cloning its content with cloneNode(true) is faster than building the subtree programmatically and avoids repeated innerHTML parsing on each render cycle.
const tmpl = document.createElement('template');
tmpl.innerHTML = `
  <style>
    :host    { display: block; border: 1px solid var(--border, #ccc); padding: 0.5rem; }
    ::slotted(*) { font-style: italic; }
  </style>
  <div class="wrapper">
    <slot name="heading"></slot>
    <slot></slot>
  </div>
`;

class CardBox extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' })
        .appendChild(tmpl.content.cloneNode(true));
  }
}
customElements.define('card-box', CardBox);
Slots project light-DOM children into shadow DOM placeholders. A child carrying slot="heading" lands in the named slot; anything else falls into the default (unnamed) slot. Projected content stays in the light DOM - it is not moved or copied. Selecting it from JavaScript uses the host element, not the shadow root.
<card-box>
  <h3 slot="heading">Title</h3>
  <p>Body text goes into the default slot.</p>
</card-box>
::slotted(selector) in shadow CSS styles projected elements. Only the top-level slotted child is matched - descendants of that child are out of reach from shadow-scoped rules.

5.0 - Upgrade and Timing

The HTML parser creates elements as it encounters tags. If the matching class has not yet been registered, the element exists in an uncustomized state as a plain HTMLElement. When customElements.define runs later, the browser upgrades all matching instances - invoking the constructor and connectedCallback in document order. customElements.whenDefined('tag-name') returns a promise that resolves after the definition is registered. Use it to defer logic that depends on a component being fully initialized:
customElements.whenDefined('card-box').then(() => {
  document.querySelector('card-box').refresh();
});
Scripts that define components should load before the elements are used, or use type="module" - module scripts are always deferred and run after parsing completes, so upgrade happens before any inline script that follows the element in document order.

5.1 - Site Component Example: <view-image>

<view-image> (defined in Components/ViewImageComponent/js/ViewImage.js) displays a titled image inside a shadow-DOM encapsulated box. The src attribute sets the image path; direct text content becomes the title. Clicking the image widens the box by a configurable step; clicking the title narrows it back. It draws on all three standards. Shadow DOM isolates the image markup and scoped CSS; CSS custom properties (bg-color, title-bg-color) cross the boundary for theming. A module-scope template string cloned once per instance populates the shadow root. A default slot projects the host's direct text content into the title bar. Declare a <view-image> element with the image path in src and the figure title as direct text content. Clicking the image widens the box by step-px pixels; clicking the title narrows it:
<view-image src="path/to/image.jpg" title-bg-color="#ccc"
            bg-color="var(--light)" width="350px" step-px="50">
  Figure title
</view-image>
<view-image> renders a titled image inside a shadow-DOM encapsulated box. The src attribute sets the image path; direct text content becomes the title bar label. bg-color and title-bg-color cross the shadow boundary via CSS custom properties. Clicking the image widens the box by step-px pixels; clicking the title narrows it back. Fig 1. Salem Mass Gift Shop
js/ViewImage.js
const VI_STYLE = /* css */ `
  :host { display: inline-block; }

  .wrapper {
    padding: 1rem;
    box-sizing: border-box;
  }

  .view {
    border: 2px solid var(--dark, #333);
    padding: 0.5rem;
    display: flex;
    flex-direction: column;
    user-select: none;
    width: max-content;
    box-shadow: 5px 5px 5px #999;
    box-sizing: border-box;
    background-color: var(--view-bg, #f8f8f8);
  }

  .title {
    font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
    font-size: var(--title-font-size, 1rem);
    font-weight: bold;
    cursor: pointer;
    max-width: 100%;
    margin-bottom: 8px;
    line-height: 1.2;
    overflow-wrap: break-word;
    white-space: normal;
    color: var(--dark, #333);
    background-color: var(--title-bg, transparent);
    padding: 0.125rem 0.5rem;
  }

  .image-panel {
    display: block;
    cursor: pointer;
  }

  #img-internal {
    display: block;
    width: 100%;
    height: auto;
  }
`;

const VI_TEMPLATE = /* html */ `
  <style>${VI_STYLE}</style>
  <div class="wrapper">
    <div class="view" part="view">
      <div class="title" part="title"><slot></slot></div>
      <div class="image-panel">
        <img id="img-internal" alt="">
      </div>
    </div>
  </div>
`;

class ViewImage extends HTMLElement {
  static get observedAttributes() {
    return ['src', 'alt', 'width', 'bg-color', 'title-bg-color', 'step-px', 'min-width'];
  }

  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
    this.shadowRoot.innerHTML = VI_TEMPLATE;

    this._els = {
      title: this.shadowRoot.querySelector('.title'),
      view:  this.shadowRoot.querySelector('.view'),
      panel: this.shadowRoot.querySelector('.image-panel'),
      img:   this.shadowRoot.querySelector('#img-internal'),
    };

    this._originWidthPx   = null;
    this._stepsFromOrigin = 0;

    this._onBodyClick  = () => this._bumpWidth(+1);
    this._onTitleClick = () => this._bumpWidth(-1);
  }

  connectedCallback() {
    this._updateAll();
    this._els.panel.addEventListener('click', this._onBodyClick);
    this._els.title.addEventListener('click', this._onTitleClick);
  }

  disconnectedCallback() {
    this._els.panel.removeEventListener('click', this._onBodyClick);
    this._els.title.removeEventListener('click', this._onTitleClick);
  }

  attributeChangedCallback() {
    this._updateAll();
  }

  _updateAll() {
    this._applyBoxColors();
    this._applyImage();
    this._applySizing();
  }

  _applyBoxColors() {
    const viewBg  = this.getAttribute('bg-color')       || 'var(--light, #f8f8f8)';
    const titleBg = this.getAttribute('title-bg-color') || '#aaa';
    this.style.setProperty('--view-bg',  viewBg);
    this.style.setProperty('--title-bg', titleBg);
  }

  _applyImage() {
    this._els.img.src = this.getAttribute('src') || '';
    this._els.img.alt = this.getAttribute('alt') || '';
  }

  _applySizing() {
    const width = this.getAttribute('width');
    if (width) this._els.view.style.width = width;
  }

  _bumpWidth(dir) {
    if (this._originWidthPx == null) {
      const rect = this._els.view.getBoundingClientRect();
      this._originWidthPx   = rect.width > 0 ? rect.width : 320;
      this._stepsFromOrigin = 0;
    }
    const stepPx = parseFloat(this.getAttribute('step-px')) || 40;
    const minPx  = parseFloat(this.getAttribute('min-width')) || 120;
    let steps  = this._stepsFromOrigin + dir;
    let target = this._originWidthPx + steps * stepPx;
    if (target < minPx) {
      target = minPx;
      steps = Math.ceil((target - this._originWidthPx) / stepPx);
    }
    this._stepsFromOrigin = steps;
    this._els.view.style.width = `${Math.round(target)}px`;
  }
}

customElements.define('view-image', ViewImage);

6.0 - References

Reference Brief Comments
MDN - Web Components Overview High-level introduction with clear examples of custom elements, shadow DOM, and HTML templates.
WHATWG - Custom Elements Spec Canonical spec text defining custom-element creation, lifecycle callbacks, and naming rules.
WHATWG - Shadow Trees Spec Specification of shadow DOM encapsulation and shadow-root semantics.
MDN - Using Custom Elements Practical usage patterns, extending built-in elements, and best-practice examples.
MDN - Using Shadow DOM Step-by-step guide for attaching and styling shadow roots with encapsulated CSS.
MDN - Using Templates and Slots Using <template> for cloneable markup and <slot> for content projection.
WebComponents.org Specifications Readable specification overview with examples across all three standards.