Custom Elements and Web Components
1. Custom Element Registration and Lifecycle
| Element Type | Extends | Tag Name | Use Case |
|---|---|---|---|
| Autonomous Element | HTMLElement | Must contain hyphen (e.g., my-button) |
Completely new element with custom behavior |
| Customized Built-in | Specific HTML element (e.g., HTMLButtonElement) | Use is attribute on standard element |
Extend existing element with extra features |
| Lifecycle Callback | When Called | Use For |
|---|---|---|
| constructor() | Element created or upgraded | Initialize state, create Shadow DOM, event listeners setup (don't touch attributes/children) |
| connectedCallback() | Element added to DOM | Setup, fetch data, start timers, render content (can be called multiple times) |
| disconnectedCallback() | Element removed from DOM | Cleanup, remove listeners, cancel timers/requests |
| attributeChangedCallback() | Observed attribute changed | React to attribute changes, update UI |
| adoptedCallback() | Element moved to new document | Rare - handle document adoption (iframe, document.adoptNode) |
| Static Property | Type | Purpose |
|---|---|---|
| observedAttributes | string[] |
List of attributes to watch for changes (triggers attributeChangedCallback) |
Example: Autonomous custom element with lifecycle
// Define custom element class
class MyCounter extends HTMLElement {
// Define which attributes to observe
static get observedAttributes() {
return ['count', 'step'];
}
constructor() {
super(); // Always call first
// Initialize private state
this._count = 0;
this._step = 1;
// Create shadow DOM for encapsulation
this.attachShadow({ mode: 'open' });
// Setup initial structure
this.shadowRoot.innerHTML = `
<style>
:host {
display: inline-block;
padding: 10px;
border: 2px solid #333;
}
button {
margin: 0 5px;
padding: 5px 10px;
}
.count {
font-size: 24px;
font-weight: bold;
}
</style>
<div>
<button class="decrement">-</button>
<span class="count">0</span>
<button class="increment">+</button>
</div>
`;
// Store references (in shadow DOM)
this._countDisplay = this.shadowRoot.querySelector('.count');
this._incrementBtn = this.shadowRoot.querySelector('.increment');
this._decrementBtn = this.shadowRoot.querySelector('.decrement');
}
// Called when element added to DOM
connectedCallback() {
console.log('Counter connected to DOM');
// Attach event listeners
this._incrementBtn.addEventListener('click', this._handleIncrement);
this._decrementBtn.addEventListener('click', this._handleDecrement);
// Read initial attributes
if (this.hasAttribute('count')) {
this._count = parseInt(this.getAttribute('count'));
this._updateDisplay();
}
if (this.hasAttribute('step')) {
this._step = parseInt(this.getAttribute('step'));
}
}
// Called when element removed from DOM
disconnectedCallback() {
console.log('Counter disconnected from DOM');
// Clean up event listeners
this._incrementBtn.removeEventListener('click', this._handleIncrement);
this._decrementBtn.removeEventListener('click', this._handleDecrement);
}
// Called when observed attribute changes
attributeChangedCallback(name, oldValue, newValue) {
console.log(`Attribute ${name} changed from ${oldValue} to ${newValue}`);
if (name === 'count') {
this._count = parseInt(newValue);
this._updateDisplay();
} else if (name === 'step') {
this._step = parseInt(newValue);
}
}
// Private methods (arrow functions to preserve 'this')
_handleIncrement = () => {
this._count += this._step;
this._updateDisplay();
this._dispatchEvent();
}
_handleDecrement = () => {
this._count -= this._step;
this._updateDisplay();
this._dispatchEvent();
}
_updateDisplay() {
this._countDisplay.textContent = this._count;
}
_dispatchEvent() {
// Dispatch custom event
this.dispatchEvent(new CustomEvent('countchange', {
detail: { count: this._count },
bubbles: true,
composed: true // Allow event to cross shadow DOM boundary
}));
}
// Public API (getters/setters)
get count() {
return this._count;
}
set count(value) {
this.setAttribute('count', value);
}
get step() {
return this._step;
}
set step(value) {
this.setAttribute('step', value);
}
// Public methods
reset() {
this.count = 0;
}
}
// Register custom element
customElements.define('my-counter', MyCounter);
// Usage in HTML:
// <my-counter count="5" step="2"></my-counter>
// Usage in JavaScript:
const counter = document.querySelector('my-counter');
counter.addEventListener('countchange', (e) => {
console.log('Count changed to:', e.detail.count);
});
counter.count = 10; // Set count
counter.reset(); // Call method
Example: Customized built-in element
// Extend existing button element
class FancyButton extends HTMLButtonElement {
constructor() {
super();
this.addEventListener('click', this._addRipple);
}
connectedCallback() {
this.style.position = 'relative';
this.style.overflow = 'hidden';
}
_addRipple = (e) => {
const ripple = document.createElement('span');
ripple.classList.add('ripple');
ripple.style.left = e.offsetX + 'px';
ripple.style.top = e.offsetY + 'px';
this.appendChild(ripple);
setTimeout(() => ripple.remove(), 600);
}
}
// Register as customized built-in (note the 'extends' option)
customElements.define('fancy-button', FancyButton, { extends: 'button' });
// Usage with 'is' attribute:
// <button is="fancy-button">Click Me</button>
// Or create programmatically:
const btn = document.createElement('button', { is: 'fancy-button' });
btn.textContent = 'Click Me';
document.body.appendChild(btn);
Best Practices: Always call
super() first in constructor. Don't access attributes
or children in constructor - wait for connectedCallback. Always remove event listeners in disconnectedCallback
to prevent memory leaks. Use underscore prefix for private properties/methods.
2. Shadow DOM and Encapsulation
| Shadow DOM Mode | Access from Outside | Use Case |
|---|---|---|
| open | ✅ Accessible via element.shadowRoot |
Most common - allows external access for testing/styling |
| closed | ❌ Returns null |
Complete encapsulation (rare, harder to test) |
| Feature | Benefit | Example |
|---|---|---|
| Style Encapsulation | CSS doesn't leak in/out | Component styles won't affect page, page styles won't affect component |
| DOM Encapsulation | Internal structure hidden | querySelector() from outside can't find shadow DOM elements |
| :host Selector | Style the host element | :host { display: block; } |
| :host() Function | Conditional host styling | :host(.active) { color: red; } |
| :host-context() | Style based on ancestor | :host-context(.dark-theme) { color: white; } |
| ::slotted() | Style slotted content | ::slotted(p) { margin: 0; } |
| CSS Custom Properties | Behavior | Use Case |
|---|---|---|
| Inherit into Shadow DOM | ✅ Yes | Allow external theming via CSS variables |
| Regular styles | ❌ Blocked | Component styles are isolated |
Example: Shadow DOM with style encapsulation
class StyledCard extends HTMLElement {
constructor() {
super();
// Create shadow DOM (open mode)
const shadow = this.attachShadow({ mode: 'open' });
// Add styles (scoped to shadow DOM)
shadow.innerHTML = `
<style>
/* :host styles the custom element itself */
:host {
display: block;
border: 1px solid var(--card-border, #ddd);
border-radius: 8px;
padding: 16px;
background: var(--card-bg, white);
}
/* :host() with selector - style host when it has class */
:host(.featured) {
border-color: gold;
border-width: 3px;
}
/* :host-context() - style based on ancestor */
:host-context(.dark-theme) {
background: #333;
color: white;
}
/* Regular styles (only affect shadow DOM) */
h2 {
margin: 0 0 10px 0;
color: var(--card-title-color, #333);
}
p {
color: #666;
line-height: 1.5;
}
/* This won't affect outside elements */
.button {
background: blue;
color: white;
padding: 8px 16px;
border: none;
border-radius: 4px;
}
</style>
<div class="card-content">
<h2><slot name="title">Default Title</slot></h2>
<p><slot>Default content</slot></p>
<button class="button">Action</button>
</div>
`;
}
}
customElements.define('styled-card', StyledCard);
// Usage - styles won't leak in or out:
// <style>
// /* This CSS variable WILL pass through shadow boundary */
// styled-card {
// --card-bg: #f0f0f0;
// --card-title-color: #0066cc;
// }
//
// /* These styles WON'T affect shadow DOM */
// h2 { color: red; } /* Won't affect h2 in shadow DOM */
// .button { background: green; } /* Won't affect .button in shadow DOM */
// </style>
//
// <styled-card class="featured">
// <span slot="title">Card Title</span>
// This is the card content
// </styled-card>
Example: Access and manipulation
class MyElement extends HTMLElement {
constructor() {
super();
this.shadow = this.attachShadow({ mode: 'open' });
this.shadow.innerHTML = `
<div class="inner">Shadow content</div>
`;
}
}
customElements.define('my-element', MyElement);
const el = document.querySelector('my-element');
// Access shadow DOM (only works with mode: 'open')
console.log(el.shadowRoot); // ShadowRoot object
console.log(el.shadowRoot.querySelector('.inner')); // <div class="inner">
// From outside, can't access shadow DOM with regular queries
console.log(document.querySelector('.inner')); // null (not found)
// With mode: 'closed', shadowRoot would be null
const closedShadow = this.attachShadow({ mode: 'closed' });
console.log(el.shadowRoot); // null
Browser Support: All modern browsers - Shadow DOM is well
supported but some CSS pseudo-classes may have limited support. Use feature detection:
if ('attachShadow' in Element.prototype). Polyfills available for older browsers.
3. HTML Templates and Content Cloning
| Element | Purpose | Parsed | Rendered |
|---|---|---|---|
| <template> | Hold inert HTML for cloning | ✅ Yes (valid HTML) | ❌ No (not in DOM tree) |
| template.content | DocumentFragment containing template contents | ✅ Available immediately | Only when cloned and appended |
| Cloning Method | Returns | Deep Clone | Use Case |
|---|---|---|---|
| cloneNode(true) | Node (element clone) | ✅ Yes (with descendants) | Clone single element and children |
| cloneNode(false) | Node (element only) | ❌ No (shallow) | Clone element without children |
| importNode(node, true) | Node (from another document) | ✅ Yes | Import from template or iframe |
Example: Template with cloning and population
<!-- Define template (not rendered) -->
<template id="card-template">
<style>
.card {
border: 1px solid #ddd;
border-radius: 8px;
padding: 16px;
margin: 10px;
}
.card-title {
font-size: 18px;
font-weight: bold;
margin-bottom: 8px;
}
.card-body {
color: #666;
}
</style>
<div class="card">
<div class="card-title"></div>
<div class="card-body"></div>
<button class="card-action">Action</button>
</div>
</template>
<div id="cards-container"></div>
<script>
// Get template
const template = document.getElementById('card-template');
// Create cards from data
const cardsData = [
{ title: 'Card 1', body: 'Content for card 1' },
{ title: 'Card 2', body: 'Content for card 2' },
{ title: 'Card 3', body: 'Content for card 3' }
];
const container = document.getElementById('cards-container');
cardsData.forEach(data => {
// Clone template content (deep clone)
const clone = template.content.cloneNode(true);
// Populate cloned content
clone.querySelector('.card-title').textContent = data.title;
clone.querySelector('.card-body').textContent = data.body;
// Add event listener
clone.querySelector('.card-action').addEventListener('click', () => {
alert(`Action for: ${data.title}`);
});
// Append to DOM
container.appendChild(clone);
});
</script>
Example: Template in Web Component
class UserCard extends HTMLElement {
constructor() {
super();
// Create shadow DOM
const shadow = this.attachShadow({ mode: 'open' });
// Create template programmatically
const template = document.createElement('template');
template.innerHTML = `
<style>
:host {
display: block;
border: 2px solid #0066cc;
border-radius: 8px;
padding: 16px;
max-width: 300px;
}
.avatar {
width: 80px;
height: 80px;
border-radius: 50%;
object-fit: cover;
}
.name {
font-size: 20px;
font-weight: bold;
margin: 10px 0 5px 0;
}
.email {
color: #666;
}
</style>
<div class="user-card">
<img class="avatar" alt="User avatar">
<div class="name"></div>
<div class="email"></div>
</div>
`;
// Clone and append template
shadow.appendChild(template.content.cloneNode(true));
// Store references
this._avatar = shadow.querySelector('.avatar');
this._name = shadow.querySelector('.name');
this._email = shadow.querySelector('.email');
}
connectedCallback() {
this._render();
}
static get observedAttributes() {
return ['name', 'email', 'avatar'];
}
attributeChangedCallback() {
this._render();
}
_render() {
this._name.textContent = this.getAttribute('name') || 'Unknown';
this._email.textContent = this.getAttribute('email') || '';
this._avatar.src = this.getAttribute('avatar') || 'default-avatar.png';
}
}
customElements.define('user-card', UserCard);
// Usage:
// <user-card
// name="John Doe"
// email="john@example.com"
// avatar="john.jpg">
// </user-card>
Template Benefits: Content is parsed but not rendered - efficient for repeated structures.
Scripts don't execute, images don't load until cloned and inserted. Can contain any valid HTML including
<style> and <script>. Perfect for component blueprints.
4. Slot Elements and Content Projection
| Slot Type | Attribute | Matches | Priority |
|---|---|---|---|
| Named Slot | <slot name="header"> |
Elements with slot="header" |
High - explicit match |
| Default Slot | <slot> (no name) |
All unslotted content | Low - catches remaining content |
| Fallback Content | Content inside <slot> |
Shown when slot is empty | N/A - default |
| Slot API | Returns | Description |
|---|---|---|
| slot.assignedNodes() | Node[] |
Get nodes assigned to slot (includes text nodes) |
| slot.assignedNodes({flatten: true}) | Node[] |
Get flattened assigned nodes (including nested slots) |
| slot.assignedElements() | Element[] |
Get only element nodes (no text nodes) |
| element.assignedSlot | HTMLSlotElement | null |
Get slot element is assigned to |
| Slot Event | When Fired | Event Target |
|---|---|---|
| slotchange | Slot's assigned nodes change | The <slot> element |
Example: Named and default slots
// Component definition
class BlogPost extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
:host {
display: block;
border: 1px solid #ddd;
border-radius: 8px;
overflow: hidden;
}
.header {
background: #0066cc;
color: white;
padding: 20px;
}
.content {
padding: 20px;
line-height: 1.6;
}
.footer {
background: #f5f5f5;
padding: 10px 20px;
border-top: 1px solid #ddd;
}
/* Style slotted content */
::slotted(h1) {
margin: 0;
font-size: 24px;
}
::slotted(p) {
margin: 10px 0;
}
</style>
<div class="header">
<!-- Named slot for title -->
<slot name="title">Default Title</slot>
<!-- Named slot for subtitle -->
<slot name="subtitle"></slot>
</div>
<div class="content">
<!-- Default slot for main content -->
<slot>No content provided</slot>
</div>
<div class="footer">
<!-- Named slot for metadata -->
<slot name="meta"></slot>
</div>
`;
}
}
customElements.define('blog-post', BlogPost);
// HTML Usage:
<blog-post>
<!-- Content projected into named slots -->
<h1 slot="title">My Blog Post</h1>
<span slot="subtitle">A brief introduction</span>
<!-- Content without slot attr goes to default slot -->
<p>This is the main content of the blog post.</p>
<p>It can contain multiple paragraphs.</p>
<!-- Another named slot -->
<div slot="meta">
<span>Author: John Doe</span>
<span>Date: 2025-12-22</span>
</div>
</blog-post>
// Result: Content is projected into respective slots
// - h1 and span appear in header
// - p elements appear in content area
// - div appears in footer
Example: Slot API and slotchange event
class DynamicList extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
.count {
font-weight: bold;
margin-bottom: 10px;
}
::slotted(li) {
padding: 5px;
border-bottom: 1px solid #ddd;
}
</style>
<div class="count">Items: 0</div>
<ul>
<slot></slot>
</ul>
`;
this._slot = shadow.querySelector('slot');
this._countDisplay = shadow.querySelector('.count');
// Listen for slot changes
this._slot.addEventListener('slotchange', () => {
this._updateCount();
});
}
connectedCallback() {
this._updateCount();
}
_updateCount() {
// Get assigned elements (only <li> elements)
const items = this._slot.assignedElements();
this._countDisplay.textContent = `Items: ${items.length}`;
// Log assigned nodes
console.log('Assigned nodes:', this._slot.assignedNodes());
console.log('Assigned elements:', items);
// You can also manipulate slotted content
items.forEach((item, index) => {
item.setAttribute('data-index', index);
});
}
// Public method to get items
getItems() {
return this._slot.assignedElements();
}
}
customElements.define('dynamic-list', DynamicList);
// Usage:
<dynamic-list id="myList">
<li>Item 1</li>
<li>Item 2</li>
<li>Item 3</li>
</dynamic-list>
<script>
const list = document.getElementById('myList');
// Add new item dynamically
const newItem = document.createElement('li');
newItem.textContent = 'Item 4';
list.appendChild(newItem); // Triggers slotchange event
// Get items from component
console.log(list.getItems()); // [li, li, li, li]
// Check which slot an element is in
const firstItem = list.querySelector('li');
console.log(firstItem.assignedSlot); // <slot> element
</script>
Example: Multiple named slots with fallback
class MediaCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>
:host { display: flex; gap: 15px; }
.media { flex: 0 0 200px; }
.body { flex: 1; }
img { width: 100%; height: auto; }
</style>
<div class="media">
<!-- Fallback content shown if no image provided -->
<slot name="image">
<img src="placeholder.png" alt="Placeholder">
</slot>
</div>
<div class="body">
<slot name="title">
<h3>Untitled</h3>
</slot>
<slot></slot>
</div>
`;
}
}
customElements.define('media-card', MediaCard);
// With image:
<media-card>
<img slot="image" src="photo.jpg" alt="Photo">
<h2 slot="title">Card Title</h2>
<p>Card description</p>
</media-card>
// Without image (shows fallback):
<media-card>
<h2 slot="title">Card Title</h2>
<p>Card description</p>
</media-card>
Slot Styling: Use
::slotted(selector) to style slotted content from within shadow
DOM. Can only target direct children of the host element. Slotted content maintains its original styles from
light DOM. CSS custom properties pierce shadow boundary for theming.
5. Custom Attributes and Properties
| Type | Syntax | Reflected | Use Case |
|---|---|---|---|
| HTML Attribute | <my-el attr="value"> |
Always strings | Initial configuration, serialization, declarative |
| JS Property | element.prop = value |
Any type | Programmatic API, complex data, methods |
| Reflected Property | Synced attribute ↔ property | Both ways | Keep HTML and JS in sync |
| Pattern | Implementation | Benefits |
|---|---|---|
| Getter/Setter | get prop() { } set prop(val) { } |
Validation, side effects, computed values |
| Boolean Attributes | Presence = true, absence = false | Match native HTML behavior (disabled, hidden) |
| Data Attributes | data-* for custom data |
Valid HTML, CSS selectors, dataset API |
Example: Reflected properties and boolean attributes
class ToggleSwitch extends HTMLElement {
// Define observed attributes
static get observedAttributes() {
return ['checked', 'disabled', 'label'];
}
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
:host {
display: inline-block;
user-select: none;
}
.switch {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
}
.switch.disabled {
opacity: 0.5;
cursor: not-allowed;
}
.toggle {
width: 50px;
height: 24px;
background: #ccc;
border-radius: 12px;
position: relative;
transition: background 0.3s;
}
.toggle.checked {
background: #4caf50;
}
.slider {
width: 20px;
height: 20px;
background: white;
border-radius: 50%;
position: absolute;
top: 2px;
left: 2px;
transition: left 0.3s;
}
.toggle.checked .slider {
left: 28px;
}
</style>
<div class="switch">
<div class="toggle">
<div class="slider"></div>
</div>
<span class="label"></span>
</div>
`;
this._switch = shadow.querySelector('.switch');
this._toggle = shadow.querySelector('.toggle');
this._label = shadow.querySelector('.label');
this._switch.addEventListener('click', this._handleClick);
}
// BOOLEAN ATTRIBUTE: 'checked'
get checked() {
return this.hasAttribute('checked');
}
set checked(value) {
if (value) {
this.setAttribute('checked', '');
} else {
this.removeAttribute('checked');
}
}
// BOOLEAN ATTRIBUTE: 'disabled'
get disabled() {
return this.hasAttribute('disabled');
}
set disabled(value) {
if (value) {
this.setAttribute('disabled', '');
} else {
this.removeAttribute('disabled');
}
}
// STRING ATTRIBUTE: 'label'
get label() {
return this.getAttribute('label') || '';
}
set label(value) {
if (value) {
this.setAttribute('label', value);
} else {
this.removeAttribute('label');
}
}
// NUMBER PROPERTY (not reflected to attribute)
get value() {
return this._value || 0;
}
set value(val) {
this._value = Number(val);
}
// OBJECT PROPERTY (cannot be attribute)
get data() {
return this._data;
}
set data(obj) {
this._data = obj;
}
// Handle attribute changes
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'checked') {
this._toggle.classList.toggle('checked', this.checked);
} else if (name === 'disabled') {
this._switch.classList.toggle('disabled', this.disabled);
} else if (name === 'label') {
this._label.textContent = newValue;
}
}
_handleClick = () => {
if (this.disabled) return;
// Toggle checked state
this.checked = !this.checked;
// Dispatch event
this.dispatchEvent(new CustomEvent('toggle', {
detail: { checked: this.checked },
bubbles: true
}));
}
// Public methods
toggle() {
this.checked = !this.checked;
}
}
customElements.define('toggle-switch', ToggleSwitch);
// HTML Usage:
<toggle-switch
checked
label="Enable notifications"
data-user-id="123">
</toggle-switch>
// JavaScript Usage:
const toggle = document.querySelector('toggle-switch');
// Boolean attributes/properties
console.log(toggle.checked); // true
toggle.checked = false; // Removes 'checked' attribute
console.log(toggle.disabled); // false
toggle.disabled = true; // Adds 'disabled' attribute
// String property
toggle.label = 'New label'; // Updates attribute
// Number property (not reflected)
toggle.value = 42;
console.log(toggle.getAttribute('value')); // null (not reflected)
// Object property
toggle.data = { userId: 123, settings: {} };
// Event listener
toggle.addEventListener('toggle', (e) => {
console.log('Toggled:', e.detail.checked);
});
Example: Data attributes and dataset API
class ProductCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>
.card { padding: 20px; border: 1px solid #ddd; }
.price { font-size: 24px; font-weight: bold; color: #0066cc; }
.sale { color: #ff0000; }
</style>
<div class="card">
<h3 class="name"></h3>
<p class="price"></p>
<button class="buy">Buy Now</button>
</div>
`;
}
connectedCallback() {
// Access data attributes via dataset
const name = this.dataset.productName;
const price = this.dataset.price;
const onSale = this.dataset.sale !== undefined;
const shadow = this.shadowRoot;
shadow.querySelector('.name').textContent = name;
shadow.querySelector('.price').textContent = `${price}`;
if (onSale) {
shadow.querySelector('.price').classList.add('sale');
}
shadow.querySelector('.buy').addEventListener('click', () => {
this.dispatchEvent(new CustomEvent('purchase', {
detail: {
productId: this.dataset.productId,
name: this.dataset.productName,
price: this.dataset.price
}
}));
});
}
}
customElements.define('product-card', ProductCard);
// HTML with data attributes:
<product-card
data-product-id="12345"
data-product-name="Laptop"
data-price="999"
data-sale>
</product-card>
// JavaScript access:
const card = document.querySelector('product-card');
// Read data attributes
console.log(card.dataset.productId); // "12345"
console.log(card.dataset.productName); // "Laptop"
// Write data attributes
card.dataset.price = "899";
card.dataset.category = "electronics";
// Check existence
if ('sale' in card.dataset) {
console.log('On sale!');
}
Best Practices: Use attributes for simple, serializable values (strings, numbers, booleans).
Use properties for complex data (objects, arrays, functions). Reflect important properties to attributes for CSS
selectors and HTML serialization. Follow HTML conventions: boolean attributes (presence = true), lowercase names
with hyphens.
6. Web Component Best Practices
| Category | Best Practice | Reason |
|---|---|---|
| Naming | Use hyphenated names (min 2 words) | Required by spec, avoids conflicts with native elements |
| Encapsulation | Always use Shadow DOM | Style isolation, implementation hiding |
| Performance | Defer heavy work to connectedCallback | Constructor must be lightweight |
| Cleanup | Remove listeners in disconnectedCallback | Prevent memory leaks |
| Events | Use CustomEvent with composed: true | Allow events to cross shadow boundary |
| Theming | Expose CSS custom properties | Enable external styling without breaking encapsulation |
| Accessibility | Add ARIA attributes, keyboard support | Screen readers, keyboard navigation |
| Error Handling | Validate inputs, provide fallbacks | Graceful degradation, better UX |
| Anti-Pattern | Why Avoid | Better Approach |
|---|---|---|
| Modifying attributes in constructor | Not yet in DOM, parser conflicts | Wait for connectedCallback |
| Accessing children in constructor | Children not yet parsed | Use connectedCallback or slotchange |
| Single-word tag names | Invalid, conflicts with native elements | Use hyphenated names (my-element) |
| Not calling super() | Breaks inheritance chain | Always call super() first in constructor |
| Modifying light DOM from component | Breaks user expectations, conflicts | Use slots, dispatch events instead |
| Global styles in component | Pollutes global namespace | Use Shadow DOM styles or CSS custom properties |
Example: Production-ready component with best practices
/**
* Accessible rating component
* @element star-rating
* @attr {number} value - Current rating (0-5)
* @attr {number} max - Maximum rating (default: 5)
* @attr {boolean} readonly - Disable interaction
* @fires rating-change - When rating changes
* @cssprop --star-size - Size of stars (default: 24px)
* @cssprop --star-color - Color of filled stars (default: gold)
*/
class StarRating extends HTMLElement {
static get observedAttributes() {
return ['value', 'max', 'readonly'];
}
constructor() {
super();
// Initialize state
this._value = 0;
this._max = 5;
this._readonly = false;
// Create shadow DOM
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
:host {
display: inline-flex;
gap: 4px;
--star-size: 24px;
--star-color: gold;
--star-empty: #ddd;
}
:host([readonly]) {
pointer-events: none;
}
.star {
width: var(--star-size);
height: var(--star-size);
cursor: pointer;
fill: var(--star-empty);
transition: fill 0.2s;
}
.star.filled {
fill: var(--star-color);
}
.star:hover,
.star.preview {
fill: var(--star-color);
opacity: 0.7;
}
:host([readonly]) .star {
cursor: default;
}
/* Focus styles for accessibility */
.star:focus {
outline: 2px solid #0066cc;
outline-offset: 2px;
}
</style>
<div class="stars" role="radiogroup" aria-label="Rating"></div>
`;
this._starsContainer = shadow.querySelector('.stars');
}
connectedCallback() {
// Read attributes
this._value = parseFloat(this.getAttribute('value')) || 0;
this._max = parseInt(this.getAttribute('max')) || 5;
this._readonly = this.hasAttribute('readonly');
// Build UI
this._render();
// Add event listeners (if not readonly)
if (!this._readonly) {
this._starsContainer.addEventListener('click', this._handleClick);
this._starsContainer.addEventListener('mouseover', this._handleHover);
this._starsContainer.addEventListener('mouseout', this._handleMouseOut);
this._starsContainer.addEventListener('keydown', this._handleKeydown);
}
}
disconnectedCallback() {
// Clean up event listeners
this._starsContainer.removeEventListener('click', this._handleClick);
this._starsContainer.removeEventListener('mouseover', this._handleHover);
this._starsContainer.removeEventListener('mouseout', this._handleMouseOut);
this._starsContainer.removeEventListener('keydown', this._handleKeydown);
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) return;
switch(name) {
case 'value':
this._value = parseFloat(newValue) || 0;
this._updateStars();
break;
case 'max':
this._max = parseInt(newValue) || 5;
this._render();
break;
case 'readonly':
this._readonly = this.hasAttribute('readonly');
break;
}
}
// Render stars
_render() {
this._starsContainer.innerHTML = '';
for (let i = 1; i <= this._max; i++) {
const star = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
star.setAttribute('class', 'star');
star.setAttribute('viewBox', '0 0 24 24');
star.setAttribute('data-value', i);
star.setAttribute('role', 'radio');
star.setAttribute('aria-checked', i <= this._value);
star.setAttribute('tabindex', i === Math.ceil(this._value) ? 0 : -1);
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', 'M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z');
star.appendChild(path);
this._starsContainer.appendChild(star);
}
this._updateStars();
}
// Update visual state
_updateStars() {
const stars = this._starsContainer.querySelectorAll('.star');
stars.forEach((star, index) => {
const value = index + 1;
star.classList.toggle('filled', value <= this._value);
star.setAttribute('aria-checked', value <= this._value);
});
}
// Event handlers
_handleClick = (e) => {
const star = e.target.closest('.star');
if (!star) return;
const value = parseInt(star.dataset.value);
this.value = value;
}
_handleHover = (e) => {
const star = e.target.closest('.star');
if (!star) return;
const value = parseInt(star.dataset.value);
const stars = this._starsContainer.querySelectorAll('.star');
stars.forEach((s, index) => {
s.classList.toggle('preview', index < value);
});
}
_handleMouseOut = () => {
const stars = this._starsContainer.querySelectorAll('.star');
stars.forEach(s => s.classList.remove('preview'));
}
_handleKeydown = (e) => {
let newValue;
switch(e.key) {
case 'ArrowRight':
case 'ArrowUp':
newValue = Math.min(this._value + 1, this._max);
break;
case 'ArrowLeft':
case 'ArrowDown':
newValue = Math.max(this._value - 1, 0);
break;
case 'Home':
newValue = 0;
break;
case 'End':
newValue = this._max;
break;
default:
return;
}
e.preventDefault();
this.value = newValue;
// Update focus
const stars = this._starsContainer.querySelectorAll('.star');
stars[newValue - 1]?.focus();
}
// Public API
get value() {
return this._value;
}
set value(val) {
const newValue = Math.max(0, Math.min(val, this._max));
if (newValue === this._value) return;
const oldValue = this._value;
this._value = newValue;
this.setAttribute('value', newValue);
// Dispatch event
this.dispatchEvent(new CustomEvent('rating-change', {
detail: { value: newValue, oldValue },
bubbles: true,
composed: true // Cross shadow boundary
}));
}
get max() {
return this._max;
}
set max(val) {
this.setAttribute('max', val);
}
get readonly() {
return this._readonly;
}
set readonly(val) {
if (val) {
this.setAttribute('readonly', '');
} else {
this.removeAttribute('readonly');
}
}
}
customElements.define('star-rating', StarRating);
// Usage:
<star-rating
value="3.5"
max="5"
style="--star-size: 32px; --star-color: #ff9800">
</star-rating>
<script>
const rating = document.querySelector('star-rating');
rating.addEventListener('rating-change', (e) => {
console.log('New rating:', e.detail.value);
});
// Make readonly after selection
rating.addEventListener('rating-change', () => {
rating.readonly = true;
});
</script>
Section 12 Key Takeaways
- Custom element names must contain hyphen (my-element); autonomous extend HTMLElement, customized built-ins extend specific elements
- Constructor initializes state; connectedCallback for DOM manipulation; disconnectedCallback for cleanup
- Shadow DOM provides style and DOM encapsulation; use mode: 'open' for testability
- :host styles the custom element; ::slotted() styles projected content; CSS custom properties pierce shadow boundary
- <template> content is parsed but not rendered; perfect for component blueprints and repeated structures
- Named slots (<slot name="x">) for specific content; default slot for remaining; slotchange event for dynamic updates
- Reflect important properties to attributes for CSS selectors; use properties for complex data
- Boolean attributes: presence = true (checked, disabled); use dataset for custom data
- Always call super() first; validate inputs; dispatch CustomEvent with composed: true to cross shadow boundary
- Follow accessibility: ARIA attributes, keyboard navigation, focus management, semantic HTML