Web Components (Custom Elements, Shadow DOM)
1. Custom Elements v1 API (customElements.define)
| Feature | API | Browser Support | Polyfill |
|---|---|---|---|
| customElements.define() | customElements.define('my-element', MyElement) |
Modern browsers | @webcomponents/custom-elements |
| Element Class | class MyElement extends HTMLElement |
ES6 class syntax | Babel transpilation + polyfill |
| Lifecycle Callbacks | connectedCallback, disconnectedCallback |
Define behavior | MutationObserver for tracking |
| Attributes | observedAttributes, attributeChangedCallback |
Watch attribute changes | Polyfill observes mutations |
| Autonomous Elements | <my-element> |
Custom tag names | Full polyfill support |
| Customized Built-ins | <button is="my-button"> |
Extends native elements | Limited Safari support |
Example: Custom Elements polyfill
// Custom Elements v1 polyfill (simplified)
(function() {
if ('customElements' in window) {
return; // Native support
}
var CustomElementRegistry = function() {
this._definitions = new Map();
this._whenDefinedPromises = new Map();
this._upgradeQueue = [];
};
CustomElementRegistry.prototype.define = function(name, constructor, options) {
// Validate tag name
if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)+$/.test(name)) {
throw new Error('Invalid custom element name: ' + name);
}
if (this._definitions.has(name)) {
throw new Error('Element already defined: ' + name);
}
// Store definition
this._definitions.set(name, {
name: name,
constructor: constructor,
options: options || {}
});
// Resolve whenDefined promise
if (this._whenDefinedPromises.has(name)) {
this._whenDefinedPromises.get(name).resolve();
this._whenDefinedPromises.delete(name);
}
// Upgrade existing elements
this._upgradeElements(name);
};
CustomElementRegistry.prototype.get = function(name) {
var def = this._definitions.get(name);
return def ? def.constructor : undefined;
};
CustomElementRegistry.prototype.whenDefined = function(name) {
if (this._definitions.has(name)) {
return Promise.resolve();
}
if (!this._whenDefinedPromises.has(name)) {
var resolve, reject;
var promise = new Promise(function(res, rej) {
resolve = res;
reject = rej;
});
promise.resolve = resolve;
promise.reject = reject;
this._whenDefinedPromises.set(name, promise);
}
return this._whenDefinedPromises.get(name);
};
CustomElementRegistry.prototype._upgradeElements = function(name) {
var elements = document.getElementsByTagName(name);
for (var i = 0; i < elements.length; i++) {
this._upgradeElement(elements[i], name);
}
};
CustomElementRegistry.prototype._upgradeElement = function(element, name) {
if (element.__upgraded) {
return;
}
var definition = this._definitions.get(name);
if (!definition) {
return;
}
// Create instance
var instance = new definition.constructor();
// Copy attributes
for (var i = 0; i < element.attributes.length; i++) {
var attr = element.attributes[i];
instance.setAttribute(attr.name, attr.value);
}
// Copy children
while (element.firstChild) {
instance.appendChild(element.firstChild);
}
// Replace element
element.parentNode.replaceChild(instance, element);
// Mark as upgraded
instance.__upgraded = true;
// Call connectedCallback if present
if (instance.connectedCallback) {
instance.connectedCallback();
}
};
CustomElementRegistry.prototype._observeNewElements = function() {
var self = this;
if (!window.MutationObserver) {
return;
}
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
mutation.addedNodes.forEach(function(node) {
if (node.nodeType === Node.ELEMENT_NODE) {
var name = node.tagName.toLowerCase();
if (self._definitions.has(name)) {
self._upgradeElement(node, name);
}
}
});
});
});
observer.observe(document.documentElement, {
childList: true,
subtree: true
});
};
// Create global registry
var registry = new CustomElementRegistry();
registry._observeNewElements();
Object.defineProperty(window, 'customElements', {
value: registry,
writable: false,
configurable: true
});
// Polyfill HTMLElement if needed
if (typeof HTMLElement !== 'function') {
window.HTMLElement = function() {};
}
})();
// Usage example
class MyButton extends HTMLElement {
constructor() {
super();
this.addEventListener('click', this._handleClick.bind(this));
}
connectedCallback() {
this.innerHTML = '<button>' + (this.getAttribute('label') || 'Click me') + '</button>';
}
disconnectedCallback() {
// Cleanup
}
static get observedAttributes() {
return ['label'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'label') {
this.querySelector('button').textContent = newValue;
}
}
_handleClick() {
this.dispatchEvent(new CustomEvent('my-click', { detail: { message: 'Clicked!' } }));
}
}
customElements.define('my-button', MyButton);
Note: Use
@webcomponents/webcomponentsjs polyfill package for production. It
includes Custom Elements, Shadow DOM, and HTML Templates polyfills.
2. Shadow DOM v1 API (attachShadow) and Limitations
| Feature | API | Support | Polyfill Limitations |
|---|---|---|---|
| attachShadow() | element.attachShadow({ mode: 'open' }) |
Modern browsers | Not true encapsulation |
| Shadow Root | element.shadowRoot |
Access shadow content | Open mode only in polyfill |
| Style Encapsulation | Scoped styles inside shadow | Native isolation | Scoped class names instead |
| Slot Elements | <slot> for content projection |
Native support | Polyfill redistributes content |
| :host selector | Target host element from shadow | Native support | Replaced with scoped classes |
| ::slotted() | Style slotted content | Native support | Limited polyfill support |
Example: Shadow DOM polyfill (simplified)
// Shadow DOM v1 polyfill (simplified concept)
(function() {
if ('attachShadow' in Element.prototype) {
return; // Native support
}
var ShadowRoot = function(host, mode) {
this.host = host;
this.mode = mode;
this._innerHTML = '';
this._children = [];
// Create shadow container
this._container = document.createElement('div');
this._container.className = '__shadow-root__';
this._container.style.display = 'contents'; // Pass-through layout
};
// Implement appendChild, removeChild, etc.
ShadowRoot.prototype.appendChild = function(node) {
this._children.push(node);
this._container.appendChild(node);
return node;
};
Object.defineProperty(ShadowRoot.prototype, 'innerHTML', {
get: function() {
return this._container.innerHTML;
},
set: function(html) {
this._container.innerHTML = html;
this._scopeStyles();
this._processSlots();
}
});
ShadowRoot.prototype._scopeStyles = function() {
var styles = this._container.querySelectorAll('style');
var scopeId = this.host.getAttribute('data-shadow-id') ||
'shadow-' + Math.random().toString(36).substr(2, 9);
this.host.setAttribute('data-shadow-id', scopeId);
for (var i = 0; i < styles.length; i++) {
var style = styles[i];
var css = style.textContent;
// Scope selectors
css = css.replace(/:host\b/g, '[data-shadow-id="' + scopeId + '"]');
css = css.replace(/([^{}]+)\{/g, function(match, selectors) {
if (selectors.indexOf('@') === 0) return match; // Skip at-rules
var scoped = selectors.split(',').map(function(sel) {
sel = sel.trim();
if (sel.indexOf('[data-shadow-id') >= 0) return sel;
return '[data-shadow-id="' + scopeId + '"] ' + sel;
}).join(', ');
return scoped + ' {';
});
style.textContent = css;
}
// Add scope to all elements
var elements = this._container.querySelectorAll('*');
for (var i = 0; i < elements.length; i++) {
elements[i].setAttribute('data-shadow-child', scopeId);
}
};
ShadowRoot.prototype._processSlots = function() {
var slots = this._container.querySelectorAll('slot');
var host = this.host;
for (var i = 0; i < slots.length; i++) {
var slot = slots[i];
var slotName = slot.getAttribute('name') || '';
// Find content for this slot
var content;
if (slotName) {
content = host.querySelectorAll('[slot="' + slotName + '"]');
} else {
// Default slot gets all children without slot attribute
content = Array.prototype.filter.call(host.childNodes, function(node) {
return !node.getAttribute || !node.getAttribute('slot');
});
}
// Move content into slot
while (slot.firstChild) {
slot.removeChild(slot.firstChild);
}
for (var j = 0; j < content.length; j++) {
slot.appendChild(content[j].cloneNode(true));
}
}
};
// Polyfill attachShadow
Element.prototype.attachShadow = function(options) {
if (this.__shadowRoot) {
throw new Error('Shadow root already attached');
}
var mode = options && options.mode || 'open';
var shadowRoot = new ShadowRoot(this, mode);
this.__shadowRoot = shadowRoot;
// Clear host content and append shadow container
while (this.firstChild) {
shadowRoot._originalContent = shadowRoot._originalContent || [];
shadowRoot._originalContent.push(this.firstChild);
this.removeChild(this.firstChild);
}
this.appendChild(shadowRoot._container);
return shadowRoot;
};
// Polyfill shadowRoot getter
Object.defineProperty(Element.prototype, 'shadowRoot', {
get: function() {
return this.__shadowRoot && this.__shadowRoot.mode === 'open'
? this.__shadowRoot
: null;
}
});
})();
// Usage example
class ShadowCard extends HTMLElement {
constructor() {
super();
var shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
:host {
display: block;
border: 1px solid #ccc;
border-radius: 4px;
padding: 16px;
}
.header {
font-weight: bold;
margin-bottom: 8px;
}
.content {
color: #666;
}
</style>
<div class="header">
<slot name="header">Default Header</slot>
</div>
<div class="content">
<slot>Default Content</slot>
</div>
`;
}
}
customElements.define('shadow-card', ShadowCard);
Warning: Shadow DOM polyfills cannot provide true style
encapsulation. Global styles may still leak in. Consider using CSS Modules or scoped CSS instead for
better polyfill support.
3. HTML Template Element (<template>) Polyfills
| Feature | API | Browser Support | Polyfill Method |
|---|---|---|---|
| <template> | Inert content container | IE not supported | Hidden div fallback |
| content property | template.content |
DocumentFragment | Create fragment manually |
| cloneNode() | template.content.cloneNode(true) |
Clone template content | Standard cloning works |
| Inert Scripts | Scripts don't execute in template | Native behavior | Remove type or src attributes |
Example: HTML Template polyfill
// HTML Template element polyfill
(function() {
if ('content' in document.createElement('template')) {
return; // Native support
}
// Polyfill template element
var templates = document.getElementsByTagName('template');
for (var i = 0; i < templates.length; i++) {
var template = templates[i];
// Create DocumentFragment for content
var content = document.createDocumentFragment();
var child;
while (child = template.firstChild) {
content.appendChild(child);
}
// Store content as property
Object.defineProperty(template, 'content', {
get: function() {
return this._content || (this._content = content);
}
});
// Hide template
template.style.display = 'none';
}
// Observe new templates
if (window.MutationObserver) {
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
mutation.addedNodes.forEach(function(node) {
if (node.tagName === 'TEMPLATE') {
initTemplate(node);
}
});
});
});
observer.observe(document.documentElement, {
childList: true,
subtree: true
});
}
function initTemplate(template) {
if (template.content) return;
var content = document.createDocumentFragment();
var child;
while (child = template.firstChild) {
content.appendChild(child);
}
Object.defineProperty(template, 'content', {
get: function() {
return this._content || (this._content = content);
}
});
template.style.display = 'none';
}
})();
// Usage example
var template = document.getElementById('my-template');
var clone = document.importNode(template.content, true);
document.body.appendChild(clone);
// HTML:
// <template id="my-template">
// <div class="card">
// <h3>Title</h3>
// <p>Content</p>
// </div>
// </template>
Note: The
<template> element is well-supported in modern browsers. For IE,
use the polyfill or fallback to hidden <div> containers.
4. Slot Element and Content Projection Patterns
| Feature | Syntax | Description | Polyfill |
|---|---|---|---|
| Default Slot | <slot></slot> |
Unnamed slot for default content | Content redistribution |
| Named Slot | <slot name="header"></slot> |
Target specific content | Match by slot attribute |
| Slotted Content | <div slot="header"> |
Assign content to slot | Move/clone content |
| Fallback Content | Content inside <slot> | Shown if no slotted content | Keep if no match found |
| assignedNodes() | slot.assignedNodes() |
Get slotted nodes | Track assigned content |
| slotchange event | Fires when slot content changes | React to content updates | MutationObserver |
Example: Slot element polyfill
// Slot element polyfill (simplified)
var SlotPolyfill = {
processSlots: function(shadowRoot, hostElement) {
var slots = shadowRoot.querySelectorAll('slot');
for (var i = 0; i < slots.length; i++) {
this.processSlot(slots[i], hostElement);
}
},
processSlot: function(slot, hostElement) {
var slotName = slot.getAttribute('name');
var assignedNodes = [];
if (slotName) {
// Named slot - find matching content
var matches = hostElement.querySelectorAll('[slot="' + slotName + '"]');
assignedNodes = Array.prototype.slice.call(matches);
} else {
// Default slot - find unassigned content
assignedNodes = Array.prototype.filter.call(hostElement.childNodes, function(node) {
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent.trim() !== '';
}
return !node.getAttribute || !node.getAttribute('slot');
});
}
// Store assigned nodes for API
slot._assignedNodes = assignedNodes;
// Clear slot
while (slot.firstChild) {
slot.removeChild(slot.firstChild);
}
// Insert assigned content or fallback
if (assignedNodes.length > 0) {
assignedNodes.forEach(function(node) {
slot.appendChild(node.cloneNode(true));
});
}
// If no assigned nodes, slot keeps its fallback content
},
// Implement assignedNodes() method
polyfillAssignedNodes: function() {
if (!('assignedNodes' in HTMLSlotElement.prototype)) {
HTMLSlotElement.prototype.assignedNodes = function(options) {
return this._assignedNodes || [];
};
}
if (!('assignedElements' in HTMLSlotElement.prototype)) {
HTMLSlotElement.prototype.assignedElements = function(options) {
var nodes = this._assignedNodes || [];
return nodes.filter(function(node) {
return node.nodeType === Node.ELEMENT_NODE;
});
};
}
},
watchSlotChanges: function(slot, hostElement) {
if (!window.MutationObserver) return;
var self = this;
var observer = new MutationObserver(function(mutations) {
self.processSlot(slot, hostElement);
// Dispatch slotchange event
var event = document.createEvent('Event');
event.initEvent('slotchange', true, false);
slot.dispatchEvent(event);
});
observer.observe(hostElement, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['slot']
});
}
};
// Usage in custom element
class SlottedCard extends HTMLElement {
constructor() {
super();
var shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
.card {
border: 1px solid #ccc;
padding: 16px;
}
.header {
font-size: 18px;
font-weight: bold;
margin-bottom: 8px;
}
.footer {
margin-top: 8px;
font-size: 12px;
color: #666;
}
</style>
<div class="card">
<div class="header">
<slot name="header">Default Header</slot>
</div>
<div class="content">
<slot>Default content goes here</slot>
</div>
<div class="footer">
<slot name="footer">Default Footer</slot>
</div>
</div>
`;
// Process slots with polyfill if needed
if (!('assignedNodes' in HTMLSlotElement.prototype)) {
SlotPolyfill.processSlots(shadow, this);
}
}
}
customElements.define('slotted-card', SlottedCard);
// HTML usage:
// <slotted-card>
// <h2 slot="header">My Title</h2>
// <p>This is my content</p>
// <span slot="footer">Copyright 2025</span>
// </slotted-card>
Note: Slot polyfills work by cloning content, not moving it.
Event listeners on original content won't work. Re-attach listeners after slotting.
5. HTML Imports and ES Module Alternatives
| Technology | Status | Alternative | Use Case |
|---|---|---|---|
| HTML Imports | DEPRECATED | ES Modules | Component loading |
| <link rel="import"> | Never standardized | fetch() + innerHTML | Load HTML fragments |
| ES Modules | Modern browsers | <script type="module"> |
Preferred method |
| Dynamic import() | Modern browsers | import('./component.js') |
Lazy loading |
| Template Includes | No native support | Build tools / SSR | Server-side composition |
Example: HTML Imports alternative with fetch
// HTML Imports alternative using fetch
var HTMLLoader = {
cache: new Map(),
import: function(url) {
// Return cached promise if exists
if (this.cache.has(url)) {
return this.cache.get(url);
}
// Fetch and parse HTML
var promise = fetch(url)
.then(function(response) {
if (!response.ok) {
throw new Error('Failed to load: ' + url);
}
return response.text();
})
.then(function(html) {
// Parse HTML into document fragment
var template = document.createElement('template');
template.innerHTML = html;
return {
url: url,
content: template.content,
querySelector: function(selector) {
return template.content.querySelector(selector);
},
querySelectorAll: function(selector) {
return template.content.querySelectorAll(selector);
}
};
});
this.cache.set(url, promise);
return promise;
},
importAll: function(urls) {
return Promise.all(urls.map(this.import.bind(this)));
}
};
// Usage
HTMLLoader.import('/components/card.html')
.then(function(imported) {
// Get template from imported document
var template = imported.querySelector('template#card-template');
var clone = document.importNode(template.content, true);
document.body.appendChild(clone);
})
.catch(function(error) {
console.error('Import failed:', error);
});
// Load multiple components
HTMLLoader.importAll([
'/components/header.html',
'/components/footer.html',
'/components/sidebar.html'
]).then(function(imports) {
console.log('All components loaded');
});
Example: ES Module approach for Web Components
// Modern approach: ES Modules for Web Components
// component.js
export class MyComponent extends HTMLElement {
constructor() {
super();
var shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = this.getTemplate();
}
getTemplate() {
return `
<style>
:host {
display: block;
padding: 16px;
}
</style>
<div class="component">
<slot></slot>
</div>
`;
}
connectedCallback() {
console.log('Component connected');
}
}
// Auto-register when module loads
customElements.define('my-component', MyComponent);
// main.js - Import and use
import { MyComponent } from './component.js';
// Component is auto-registered, just use it
document.body.innerHTML = '<my-component>Hello!</my-component>';
// Or lazy load
async function loadComponent() {
const module = await import('./component.js');
console.log('Component loaded and registered');
}
// Load on demand
document.getElementById('load-btn').addEventListener('click', loadComponent);
Warning: HTML Imports are deprecated and removed from browsers.
Use ES Modules with
import statements or dynamic import() for component loading.
6. Scoped CSS and ::shadow Pseudo-element
| Method | Approach | Browser Support | Pros/Cons |
|---|---|---|---|
| Shadow DOM | Native style encapsulation | Modern browsers | True isolation, polyfill limitations |
| Scoped Attribute | <style scoped> |
REMOVED FROM SPEC | Never fully supported |
| CSS Modules | Build-time scoping | All browsers | Requires build step |
| BEM Naming | Naming convention | All browsers | Manual, no true isolation |
| CSS-in-JS | JavaScript-generated styles | All browsers | Runtime overhead |
| Data Attributes | Scope with unique attributes | All browsers | Simple, no build tools |
Example: Scoped CSS polyfill with data attributes
// Scoped CSS polyfill
var ScopedCSS = {
scopeId: 0,
applyScoped: function(container) {
var scopeId = 'scope-' + (++this.scopeId);
// Add scope attribute to container
container.setAttribute('data-scope', scopeId);
// Find style elements
var styles = container.querySelectorAll('style[scoped]');
for (var i = 0; i < styles.length; i++) {
this.scopeStyle(styles[i], scopeId);
}
// Add scope to all descendants
var elements = container.querySelectorAll('*');
for (var i = 0; i < elements.length; i++) {
if (elements[i].tagName !== 'STYLE') {
elements[i].setAttribute('data-scope-child', scopeId);
}
}
},
scopeStyle: function(styleElement, scopeId) {
var css = styleElement.textContent;
// Scope all selectors
css = css.replace(/([^{}]+)\{/g, function(match, selectors) {
// Skip at-rules
if (selectors.trim().indexOf('@') === 0) {
return match;
}
var scoped = selectors.split(',').map(function(selector) {
selector = selector.trim();
// Scope the selector
return '[data-scope="' + scopeId + '"] ' + selector;
}).join(', ');
return scoped + ' {';
});
styleElement.textContent = css;
styleElement.removeAttribute('scoped');
// Move style to head for better performance
if (styleElement.parentNode !== document.head) {
document.head.appendChild(styleElement);
}
},
// Auto-process on DOM ready
init: function() {
var containers = document.querySelectorAll('[data-scoped-css]');
for (var i = 0; i < containers.length; i++) {
this.applyScoped(containers[i]);
}
// Watch for new containers
this.observeDOM();
},
observeDOM: function() {
if (!window.MutationObserver) return;
var self = this;
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
mutation.addedNodes.forEach(function(node) {
if (node.nodeType === Node.ELEMENT_NODE) {
if (node.hasAttribute && node.hasAttribute('data-scoped-css')) {
self.applyScoped(node);
}
}
});
});
});
observer.observe(document.documentElement, {
childList: true,
subtree: true
});
}
};
// Initialize
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
ScopedCSS.init();
});
} else {
ScopedCSS.init();
}
// Usage:
// <div data-scoped-css>
// <style scoped>
// .title { color: blue; }
// p { font-size: 14px; }
// </style>
//
// <h1 class="title">Scoped Title</h1>
// <p>Scoped paragraph</p>
// </div>
Example: CSS-in-JS approach for components
// Simple CSS-in-JS for Web Components
class StyledComponent extends HTMLElement {
constructor() {
super();
this.scopeId = 'component-' + Math.random().toString(36).substr(2, 9);
this.setAttribute('data-component-id', this.scopeId);
this.injectStyles();
this.render();
}
getStyles() {
return {
'.container': {
padding: '16px',
border: '1px solid #ccc',
borderRadius: '4px'
},
'.title': {
fontSize: '18px',
fontWeight: 'bold',
marginBottom: '8px'
},
'.content': {
color: '#666'
}
};
}
injectStyles() {
var styles = this.getStyles();
var css = '';
// Convert styles object to CSS string
for (var selector in styles) {
var scopedSelector = '[data-component-id="' + this.scopeId + '"] ' + selector;
css += scopedSelector + ' {';
var rules = styles[selector];
for (var prop in rules) {
var cssProp = prop.replace(/([A-Z])/g, '-$1').toLowerCase();
css += cssProp + ': ' + rules[prop] + ';';
}
css += '}';
}
// Create and inject style element
var styleEl = document.createElement('style');
styleEl.textContent = css;
document.head.appendChild(styleEl);
// Store reference for cleanup
this._styleElement = styleEl;
}
render() {
this.innerHTML = `
<div class="container">
<div class="title">${this.getAttribute('title') || 'Title'}</div>
<div class="content">
<slot></slot>
</div>
</div>
`;
}
disconnectedCallback() {
// Cleanup styles when component removed
if (this._styleElement && this._styleElement.parentNode) {
this._styleElement.parentNode.removeChild(this._styleElement);
}
}
}
customElements.define('styled-component', StyledComponent);
// Usage:
// <styled-component title="My Component">
// This is the content
// </styled-component>
Key Takeaways - Web Components
- Custom Elements: Use @webcomponents/custom-elements polyfill for IE11
- Shadow DOM: Polyfills lack true encapsulation, consider CSS Modules
- Templates: Well-supported, simple polyfill for IE using DocumentFragment
- Slots: Polyfill clones content instead of moving it, re-attach listeners
- HTML Imports: Deprecated - use ES Modules with import()
- Scoped CSS: Use data attributes or CSS-in-JS for reliable scoping