Web Components (Custom Elements, Shadow DOM)
1. Custom Elements (customElements.define, connectedCallback)
| Method | Description | Browser Support |
|---|---|---|
| customElements.define(name, constructor, options) | Registers custom element. Name must contain hyphen. Options: extends for customized built-ins. |
All Modern Browsers |
| customElements.get(name) | Gets constructor for custom element. Returns undefined if not defined. |
All Modern Browsers |
| customElements.whenDefined(name) | Returns Promise that resolves when element is defined. | All Modern Browsers |
| customElements.upgrade(root) | Upgrades custom elements in subtree that were created before definition. | All Modern Browsers |
| Lifecycle Callback | When Called | Use Case |
|---|---|---|
| constructor() | Element instance created. Called before attached to DOM. | Initialize state, create shadow root |
| connectedCallback() | Element inserted into DOM. Can be called multiple times. | Setup, add event listeners, fetch data |
| disconnectedCallback() | Element removed from DOM. | Cleanup, remove listeners, cancel requests |
| attributeChangedCallback(name, oldValue, newValue) | Observed attribute changed. | React to attribute changes |
| adoptedCallback() | Element moved to new document. | Update references (rare) |
| Static Property | Type | Description |
|---|---|---|
| static observedAttributes | string[] |
Array of attribute names to observe. Required for attributeChangedCallback. |
Example: Basic custom element
// Define custom element
class MyElement extends HTMLElement {
constructor() {
super();
console.log("Constructor called");
// Initialize state
this.count = 0;
// Create shadow DOM
this.attachShadow({ "mode": "open" });
this.render();
}
// Lifecycle: element added to DOM
connectedCallback() {
console.log("Connected to DOM");
// Add event listeners
this.addEventListener("click", this.handleClick);
// Can access attributes here
console.log("Initial count:", this.getAttribute("count"));
}
// Lifecycle: element removed from DOM
disconnectedCallback() {
console.log("Disconnected from DOM");
// Remove event listeners
this.removeEventListener("click", this.handleClick);
}
// Lifecycle: attribute changed
static get observedAttributes() {
return ["count", "disabled"];
}
attributeChangedCallback(name, oldValue, newValue) {
console.log(`Attribute ${name} changed: ${oldValue} → ${newValue}`);
if (name === "count") {
this.count = parseInt(newValue) || 0;
this.render();
}
}
// Methods
handleClick = () => {
this.count++;
this.setAttribute("count", this.count);
};
render() {
this.shadowRoot.innerHTML = `
<style>
button { padding: 10px 20px; font-size: 16px; }
</style>
<button>Count: ${this.count}</button>
`;
}
}
// Register custom element (name must have hyphen)
customElements.define("my-element", MyElement);
// Use in HTML: <my-element count="5"></my-element>
// Check if defined
const isDefined = customElements.get("my-element");
console.log("Defined:", isDefined !== undefined);
// Wait for definition
customElements.whenDefined("my-element").then(() => {
console.log("my-element is defined");
// Create programmatically
const el = document.createElement("my-element");
el.setAttribute("count", "10");
document.body.appendChild(el);
});
Example: Customized built-in element
// Extend built-in element (not supported in Safari)
class FancyButton extends HTMLButtonElement {
constructor() {
super();
this.addEventListener("click", this.handleClick);
}
handleClick() {
this.style.transform = "scale(0.95)";
setTimeout(() => {
this.style.transform = "";
}, 100);
}
}
// Register with extends option
customElements.define("fancy-button", FancyButton, { "extends": "button" });
// Use with "is" attribute: <button is="fancy-button">Click me</button>
// Create programmatically
const btn = document.createElement("button", { "is": "fancy-button" });
btn.textContent = "Fancy";
document.body.appendChild(btn);
Note: Custom element names must contain hyphen (e.g., "my-element" not "myelement"). Constructor runs before element attached to DOM - don't access children or attributes there. Use
connectedCallback for setup. Customized built-ins not supported in Safari.
Warning: Don't call
super() without arguments in constructor. Don't add attributes in constructor - use connectedCallback. connectedCallback can be called multiple times if element moved. Always cleanup in disconnectedCallback.
2. Shadow DOM (attachShadow, shadowRoot)
| Method/Property | Description | Browser Support |
|---|---|---|
| element.attachShadow(options) | Creates shadow root. Options: mode ("open" or "closed"), delegatesFocus. |
All Modern Browsers |
| element.shadowRoot | Returns shadow root if mode is "open", null if "closed". |
All Modern Browsers |
| shadowRoot.mode | "open" or "closed". Determines if shadowRoot accessible from outside. | All Modern Browsers |
| shadowRoot.host | Returns host element of shadow root. | All Modern Browsers |
| Shadow DOM Feature | Description | Benefit |
|---|---|---|
| Style Encapsulation | Styles inside shadow DOM don't affect outside, and vice versa. | Prevent CSS conflicts |
| DOM Encapsulation | Shadow DOM not accessible via querySelector from outside. |
Implementation hiding |
| Event Retargeting | Events from shadow DOM appear to come from host element. | Maintain encapsulation |
| CSS Pseudo-elements/Selectors | Description | Use Case |
|---|---|---|
| :host | Selects shadow host from inside shadow DOM. | Style host element |
| :host(selector) | Selects host if it matches selector. | Conditional host styling |
| :host-context(selector) | Selects host if ancestor matches selector. | Theme-based styling |
| ::slotted(selector) | Selects slotted content. | Style projected content |
| ::part(name) | Selects element with part attribute from outside. |
Allow external styling of parts |
Example: Shadow DOM with styling
class StyledCard extends HTMLElement {
constructor() {
super();
// Create shadow DOM (mode: "open" for accessibility)
const shadow = this.attachShadow({ "mode": "open" });
shadow.innerHTML = `
<style>
/* Styles scoped to shadow DOM */
:host {
display: block;
border: 2px solid #ccc;
border-radius: 8px;
padding: 20px;
background: white;
}
/* Style host when has .featured class */
:host(.featured) {
border-color: gold;
border-width: 4px;
}
/* Style host when disabled attribute present */
:host([disabled]) {
opacity: 0.5;
pointer-events: none;
}
/* Style host based on ancestor */
:host-context(.dark-theme) {
background: #333;
color: white;
border-color: #666;
}
h2 {
margin: 0 0 10px 0;
color: #333;
}
/* Expose styling hook with ::part */
.title {
font-size: 24px;
}
.content {
color: #666;
}
</style>
<div class="card">
<h2 class="title" part="title">
<slot name="title">Default Title</slot>
</h2>
<div class="content" part="content">
<slot>Default content</slot>
</div>
</div>
`;
}
}
customElements.define("styled-card", StyledCard);
// Usage in HTML:
// <styled-card class="featured">
// <span slot="title">My Title</span>
// <p>Card content here</p>
// </styled-card>
// Style parts from outside:
// styled-card::part(title) { color: blue; }
// styled-card::part(content) { font-size: 14px; }
// Closed shadow DOM (shadowRoot not accessible)
class PrivateComponent extends HTMLElement {
constructor() {
super();
// mode: "closed" - element.shadowRoot will be null
const shadow = this.attachShadow({ "mode": "closed" });
// Keep reference if needed internally
this._shadowRoot = shadow;
shadow.innerHTML = `<p>Private implementation</p>`;
}
}
customElements.define("private-component", PrivateComponent);
Note: Use
mode: "open" for better accessibility and debugging. Shadow DOM styles don't inherit from outside except inheritable properties (color, font-family, etc.). Use ::part() to expose styling hooks. :host-context() has limited browser support.
Warning: Not all elements can have shadow DOM (e.g.,
<input>, <img>). Closed shadow DOM still accessible via DevTools - not security feature. Events from shadow DOM appear to originate from host (event retargeting).
3. HTML Templates (template, content, cloneNode)
| Element/Property | Description | Browser Support |
|---|---|---|
| <template> | HTML element that holds client-side content not rendered on page load. | All Browsers |
| template.content | Returns DocumentFragment containing template's DOM subtree. |
All Browsers |
| document.importNode(node, deep) | Clones node from another document. Use with template content. | All Browsers |
Example: Template element usage
// HTML template
// <template id="card-template">
// <style>
// .card { border: 1px solid #ccc; padding: 20px; }
// </style>
// <div class="card">
// <h3 class="title"></h3>
// <p class="description"></p>
// </div>
// </template>
// Use template in custom element
class CardElement extends HTMLElement {
constructor() {
super();
// Get template
const template = document.getElementById("card-template");
// Clone template content
const content = template.content.cloneNode(true);
// Attach to shadow DOM
this.attachShadow({ "mode": "open" });
this.shadowRoot.appendChild(content);
}
connectedCallback() {
// Populate from attributes
const title = this.getAttribute("title");
const description = this.getAttribute("description");
this.shadowRoot.querySelector(".title").textContent = title;
this.shadowRoot.querySelector(".description").textContent = description;
}
}
customElements.define("card-element", CardElement);
// Template-based component factory
class TemplateComponent extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ "mode": "open" });
// Define template inline
const template = document.createElement("template");
template.innerHTML = `
<style>
:host { display: block; }
.container { padding: 20px; background: #f5f5f5; }
</style>
<div class="container">
<slot></slot>
</div>
`;
// Clone and append
shadow.appendChild(template.content.cloneNode(true));
}
}
customElements.define("template-component", TemplateComponent);
// Reusable template pattern
const createTemplate = (html) => {
const template = document.createElement("template");
template.innerHTML = html;
return template;
};
const buttonTemplate = createTemplate(`
<style>
button {
padding: 10px 20px;
background: #007bff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover { background: #0056b3; }
</style>
<button><slot>Click me</slot></button>
`);
class CustomButton extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ "mode": "open" });
shadow.appendChild(buttonTemplate.content.cloneNode(true));
}
}
customElements.define("custom-button", CustomButton);
Note: Template content is inert - scripts don't run, images don't load, styles don't apply until cloned into document. Use
cloneNode(true) to deep clone template content. Templates can be defined in HTML or created programmatically.
4. Slots (slot, slotchange event)
| Element/Attribute | Description | Use Case |
|---|---|---|
| <slot> | Placeholder for projected content from light DOM. | Content projection |
| <slot name="..."> | Named slot for specific content. | Multiple projection points |
| slot="name" | Attribute on light DOM element to target named slot. | Target specific slot |
| Slot Method/Property | Description |
|---|---|
| slot.assignedNodes(options) | Returns array of nodes assigned to slot. Options: flatten (include nested slots). |
| slot.assignedElements(options) | Returns array of elements (not text nodes) assigned to slot. |
| element.assignedSlot | Returns <slot> element that element is assigned to. |
| Slot Event | When Fired | Use Case |
|---|---|---|
| slotchange | Assigned nodes of slot change. | React to content changes |
Example: Slots for content projection
// Custom element with slots
class UserCard extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ "mode": "open" });
shadow.innerHTML = `
<style>
.card { border: 1px solid #ccc; padding: 20px; }
.header { display: flex; align-items: center; gap: 10px; }
.avatar { width: 50px; height: 50px; border-radius: 50%; }
.name { font-size: 20px; font-weight: bold; }
.bio { margin-top: 10px; color: #666; }
.actions { margin-top: 15px; }
</style>
<div class="card">
<div class="header">
<!-- Named slot for avatar -->
<slot name="avatar">
<div class="avatar">👤</div>
</slot>
<!-- Named slot for name -->
<div class="name">
<slot name="name">Anonymous</slot>
</div>
</div>
<!-- Default slot for bio -->
<div class="bio">
<slot>No bio provided</slot>
</div>
<!-- Named slot for actions -->
<div class="actions">
<slot name="actions"></slot>
</div>
</div>
`;
// Listen for slot changes
shadow.querySelectorAll("slot").forEach(slot => {
slot.addEventListener("slotchange", (e) => {
console.log("Slot changed:", e.target.name || "default");
console.log("Assigned nodes:", e.target.assignedNodes());
});
});
}
}
customElements.define("user-card", UserCard);
// Usage:
// <user-card>
// <img slot="avatar" src="avatar.jpg" class="avatar">
// <span slot="name">John Doe</span>
// <p>Software developer with passion for web technologies.</p>
// <div slot="actions">
// <button>Follow</button>
// <button>Message</button>
// </div>
// </user-card>
// Access slotted content
class SlotObserver extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ "mode": "open" });
shadow.innerHTML = `
<style>::slotted(h2) { color: blue; }</style>
<slot></slot>
`;
const slot = shadow.querySelector("slot");
// Get assigned nodes
slot.addEventListener("slotchange", () => {
const nodes = slot.assignedNodes();
console.log("Assigned nodes:", nodes);
// Only elements (not text nodes)
const elements = slot.assignedElements();
console.log("Assigned elements:", elements);
// Flatten nested slots
const flattenedNodes = slot.assignedNodes({ "flatten": true });
console.log("Flattened:", flattenedNodes);
});
}
connectedCallback() {
// Access assigned slot from light DOM
const children = this.children;
Array.from(children).forEach(child => {
console.log("Element's assigned slot:", child.assignedSlot);
});
}
}
customElements.define("slot-observer", SlotObserver);
Note: Slots enable content projection - light DOM content rendered in shadow DOM. Unnamed slots are default slots. Multiple elements can target same named slot. Use
::slotted() to style slotted content from shadow DOM.
Warning:
::slotted() can only select direct children, not descendants. Slotted elements remain in light DOM - only rendered location changes. Slot fallback content shown when no content assigned to slot.
5. Element Internals (attachInternals, form integration)
| Method/Property | Description | Browser Support |
|---|---|---|
| element.attachInternals() | Returns ElementInternals object for form/accessibility integration. |
All Modern Browsers |
| static formAssociated = true | Marks element as form-associated. Required for form integration. | All Modern Browsers |
| ElementInternals Property/Method | Description |
|---|---|
| form | Returns associated <form> element. |
| setFormValue(value, state) | Sets element's form value. Optional state for internal representation. |
| setValidity(flags, message, anchor) | Sets custom validity state. Flags object, validation message, anchor element. |
| willValidate | true if element participates in form validation. |
| validity | Returns ValidityState object. |
| validationMessage | Returns validation message string. |
| checkValidity() | Checks validity, fires invalid event if invalid. |
| reportValidity() | Checks validity and reports to user. |
| Form Lifecycle Callback | When Called |
|---|---|
| formAssociatedCallback(form) | Element associated with form. |
| formDisabledCallback(disabled) | Element's disabled state changed via <fieldset>. |
| formResetCallback() | Form reset. |
| formStateRestoreCallback(state, mode) | Browser restores state. Mode: "restore" or "autocomplete". |
Example: Form-associated custom element
// Custom input element with form integration
class CustomInput extends HTMLElement {
// Must set formAssociated to true
static formAssociated = true;
constructor() {
super();
// Attach internals for form integration
this._internals = this.attachInternals();
const shadow = this.attachShadow({ "mode": "open" });
shadow.innerHTML = `
<style>
input {
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
}
input:invalid { border-color: red; }
.error { color: red; font-size: 12px; }
</style>
<input type="text" />
<div class="error"></div>
`;
this._input = shadow.querySelector("input");
this._error = shadow.querySelector(".error");
// Sync internal input with form value
this._input.addEventListener("input", () => {
this.value = this._input.value;
});
}
// Form lifecycle callbacks
formAssociatedCallback(form) {
console.log("Associated with form:", form);
}
formResetCallback() {
console.log("Form reset");
this.value = "";
}
formDisabledCallback(disabled) {
console.log("Disabled:", disabled);
this._input.disabled = disabled;
}
formStateRestoreCallback(state, mode) {
console.log("State restore:", state, mode);
this.value = state;
}
// Value getter/setter
get value() {
return this._input.value;
}
set value(val) {
this._input.value = val;
// Update form value
this._internals.setFormValue(val);
// Validate
this.validate();
}
// Validation
validate() {
const value = this.value;
if (this.hasAttribute("required") && !value) {
this._internals.setValidity(
{ "valueMissing": true },
"This field is required",
this._input
);
this._error.textContent = "This field is required";
return false;
}
const minLength = parseInt(this.getAttribute("minlength")) || 0;
if (value.length < minLength) {
this._internals.setValidity(
{ "tooShort": true },
`Minimum ${minLength} characters required`,
this._input
);
this._error.textContent = `Minimum ${minLength} characters required`;
return false;
}
// Valid
this._internals.setValidity({});
this._error.textContent = "";
return true;
}
// Standard form methods
checkValidity() {
return this._internals.checkValidity();
}
reportValidity() {
return this._internals.reportValidity();
}
// Access to form
get form() {
return this._internals.form;
}
get validity() {
return this._internals.validity;
}
get validationMessage() {
return this._internals.validationMessage;
}
}
customElements.define("custom-input", CustomInput);
// Usage:
// <form>
// <custom-input name="username" required minlength="3"></custom-input>
// <button type="submit">Submit</button>
// </form>
// Access form value
const form = document.querySelector("form");
form.addEventListener("submit", (e) => {
e.preventDefault();
const formData = new FormData(form);
console.log("Username:", formData.get("username"));
});
Note: Set
static formAssociated = true to enable form integration. Use attachInternals() to get ElementInternals object. Custom elements integrate with native form features - validation, FormData, submit events. Use setFormValue() to sync with form.
Warning: Can only call
attachInternals() once. Must set formAssociated before element registered. Form callbacks may not fire in all scenarios - test thoroughly. ElementInternals has good browser support but check for older browsers.
6. Adoptable Stylesheets (CSSStyleSheet, adoptedStyleSheets)
| Method/Property | Description | Browser Support |
|---|---|---|
| new CSSStyleSheet() | Creates new stylesheet object (constructable). | All Modern Browsers |
| sheet.replace(css) | Replaces stylesheet content. Returns Promise. Async. | All Modern Browsers |
| sheet.replaceSync(css) | Replaces stylesheet content synchronously. | All Modern Browsers |
| document.adoptedStyleSheets | Array of stylesheets for document. | All Modern Browsers |
| shadowRoot.adoptedStyleSheets | Array of stylesheets for shadow root. | All Modern Browsers |
Example: Adoptable stylesheets
// Create shared stylesheet
const sharedStyles = new CSSStyleSheet();
sharedStyles.replaceSync(`
.card {
border: 1px solid #ccc;
border-radius: 8px;
padding: 20px;
background: white;
}
.card:hover {
box-shadow: 0 4px 8px rgba(0,0,0,0.1);
}
h2 { margin: 0 0 10px 0; }
p { color: #666; }
`);
// Use in multiple components
class Card1 extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ "mode": "open" });
// Adopt shared stylesheet
shadow.adoptedStyleSheets = [sharedStyles];
shadow.innerHTML = `
<div class="card">
<h2>Card 1</h2>
<p>Content</p>
</div>
`;
}
}
class Card2 extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ "mode": "open" });
// Reuse same stylesheet (no duplication)
shadow.adoptedStyleSheets = [sharedStyles];
shadow.innerHTML = `
<div class="card">
<h2>Card 2</h2>
<p>Content</p>
</div>
`;
}
}
customElements.define("card-1", Card1);
customElements.define("card-2", Card2);
// Multiple stylesheets
const baseStyles = new CSSStyleSheet();
baseStyles.replaceSync(`:host { display: block; }`);
const themeStyles = new CSSStyleSheet();
themeStyles.replaceSync(`
.dark { background: #333; color: white; }
`);
class ThemedComponent extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ "mode": "open" });
// Use multiple stylesheets
shadow.adoptedStyleSheets = [baseStyles, themeStyles];
shadow.innerHTML = `<div class="dark">Themed</div>`;
}
}
customElements.define("themed-component", ThemedComponent);
// Dynamic stylesheet updates
const dynamicStyles = new CSSStyleSheet();
dynamicStyles.replaceSync(`.text { color: blue; }`);
class DynamicStyled extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ "mode": "open" });
shadow.adoptedStyleSheets = [dynamicStyles];
shadow.innerHTML = `<p class="text">Dynamic</p>`;
}
setColor(color) {
// Update shared stylesheet (affects all instances)
dynamicStyles.replaceSync(`.text { color: ${color}; }`);
}
}
customElements.define("dynamic-styled", DynamicStyled);
// Async stylesheet loading
async function loadStylesheet(url) {
const response = await fetch(url);
const css = await response.text();
const sheet = new CSSStyleSheet();
await sheet.replace(css);
return sheet;
}
// Usage
const externalStyles = await loadStylesheet("/styles/component.css");
class StyledFromExternal extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ "mode": "open" });
shadow.adoptedStyleSheets = [externalStyles];
shadow.innerHTML = `<div>Styled from external CSS</div>`;
}
}
customElements.define("styled-from-external", StyledFromExternal);
Note: Adoptable Stylesheets enable stylesheet reuse across shadow roots - better performance than duplicating
<style> tags. Stylesheets created once, shared across components. Use replaceSync() for initial load, replace() for async updates.
Warning: Changes to shared stylesheet affect all components using it - be careful with dynamic updates.
adoptedStyleSheets replaces array - use spread to add: [...shadow.adoptedStyleSheets, newSheet]. Constructable stylesheets not available in older browsers.
Web Components Best Practices
- Always use hyphenated names for custom elements - required by spec
- Use
mode: "open"for shadow DOM - better accessibility and debugging - Initialize in constructor, setup in
connectedCallback - Always cleanup in
disconnectedCallback- remove listeners, cancel timers - Use
attributeChangedCallbackwithobservedAttributesfor reactive attributes - Use templates to avoid creating DOM strings repeatedly
- Use slots for content projection - more flexible than imperative DOM manipulation
- Use
::part()to expose styling hooks for component consumers - Set
formAssociated = truefor form-integrated custom elements - Use adoptable stylesheets to share styles across components efficiently
- Avoid customized built-ins - not supported in Safari
- Test components in isolation and with various content projections