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:
- Custom Elements - register a JavaScript class as a new tag name with lifecycle callbacks
- Shadow DOM - attach an isolated DOM subtree with scoped CSS to any element
- Templates & Slots - declare reusable inert markup and project host content into named placeholders
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:
constructor() - element created; set up shadow root and internal state; do not read attributes or light-DOM children here
connectedCallback() - element inserted into document; safe to read attributes and access the DOM
disconnectedCallback() - element removed; tear down timers, event listeners, observers
attributeChangedCallback(name, oldVal, newVal) - an observed attribute changed
adoptedCallback() - element moved to a new document (rare; e.g., from an iframe)
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:
'open' - element.shadowRoot returns the shadow root; external code can traverse it
'closed' - element.shadowRoot returns null; the reference is only available inside the constructor
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