Journey to Web Components: From Fundamentals to Advanced UI Architecture
Getting Started with Custom Elements
Creating Reusable and Encapsulated HTML Components

Introduction#
A custom element is an HTML tag you invent, such as <hello-tag>, together with the JavaScript that tells the browser what it should do. Write the tag on a page and the browser runs your code for it, the same way it runs its own code for <button>.
By the end, a line of text such as "Hi, reader" appears from a tag the browser never shipped with. We change one attribute on the tag (label="Hi" to label="Bye") and the text updates by itself. Along the way we see how an element learns that it was added, removed, or edited.
Recap if you are arriving here first: a custom element is a JavaScript class that extends HTMLElement (the browser's base class for every element), whose constructor starts with super(), and which is registered with customElements.define('some-name', TheClass). The name must contain a hyphen. That is all you need.
What Are Custom Elements?#
Custom Elements are a set of JavaScript APIs that allow developers to define and implement new types of HTML elements. These elements can encapsulate their structure, style, and behavior, promoting code reuse and reducing complexity in web applications. There are two main types of Custom Elements:
-
Autonomous Custom Elements: Standalone elements that are not based on existing HTML elements.
-
Customized Built-in Elements: Elements that extend native HTML elements, enhancing their functionality.
Defining an Autonomous Custom Element#
To create an autonomous custom element, we need to define a new class that extends the HTMLElement base class and then register it with the browser using the customElements.define() method.
This demo shows a live implementation of a simple custom element with all the lifecycle methods covered in this article. Users can interact with the element by adding, removing, and changing its attributes, seeing the lifecycle callbacks in action.
---html
<h1>Basic Custom Element</h1>
<div class="demo-container">
<p>Here's our custom element:</p>
<basic-element name="Web Components"></basic-element>
<button id="update-name">Change Name</button>
<button id="remove-element">Remove Element</button>
<button id="add-element">Add Element Back</button>
</div>
<div class="code">
<pre><code>// Define the custom element
class BasicElement extends HTMLElement {
// Watched attributes
static get observedAttributes() {
return ['name'];
}
constructor() {
super();
console.log('Constructor called');
}
// Called when added to DOM
connectedCallback() {
console.log('Connected to the DOM');
this._render();
}
// Called when removed from DOM
disconnectedCallback() {
console.log('Removed from the DOM');
}
// Called when attributes change
attributeChangedCallback(name, oldValue, newValue) {
console.log(`Attribute ${name} changed from ${oldValue} to ${newValue}`);
this._render();
}
_render() {
const name = this.getAttribute('name') || 'World';
this.innerHTML = `
<div style="border: 2px dashed #4a90e2; padding: 10px; margin: 10px 0;">
<h3>Hello, ${name}!</h3>
<p>I am an autonomous custom element</p>
</div>
`;
}
}
// Register the custom element
customElements.define('basic-element', BasicElement);</code></pre>
</div>
<div id="element-container"></div>
---css
:root {
color-scheme: light dark;
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 2rem;
line-height: 1.6;
}
button {
padding: 8px 16px;
background-color: #4a90e2;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
margin: 10px 0;
}
button:hover {
background-color: #357ab8;
}
.demo-container {
border: 1px solid #ccc;
padding: 20px;
border-radius: 8px;
margin: 20px 0;
}
.code {
background-color: rgba(125, 125, 125, 0.1);
padding: 15px;
border-radius: 4px;
margin: 15px 0;
overflow-x: auto;
}
/* Dark mode adjustments */
@media (prefers-color-scheme: dark) {
.demo-container {
border-color: #555;
background-color: #333;
}
button {
background-color: #5a9de2;
}
button:hover {
background-color: #458ad2;
}
}
---js
// Define the custom element
class BasicElement extends HTMLElement {
// Watched attributes
static get observedAttributes() {
return ["name"];
}
constructor() {
super();
console.log("Constructor called");
}
// Called when added to DOM
connectedCallback() {
console.log("Connected to the DOM");
this._render();
}
// Called when removed from DOM
disconnectedCallback() {
console.log("Removed from the DOM");
}
// Called when attributes change
attributeChangedCallback(name, oldValue, newValue) {
console.log(`Attribute ${name} changed from ${oldValue} to ${newValue}`);
this._render();
}
_render() {
const name = this.getAttribute("name") || "World";
this.innerHTML = `
<div style="border: 2px dashed #4a90e2; padding: 10px; margin: 10px 0;">
<h3>Hello, ${name}!</h3>
<p>I am an autonomous custom element</p>
</div>
`;
}}
// Register the custom element
customElements.define("basic-element", BasicElement);
// Demo functionality
document.getElementById("update-name").addEventListener("click", () => {
const element = document.querySelector("basic-element");
const names = [
"JavaScript",
"Web Components",
"HTML",
"Custom Elements",
"Browser APIs"];
const randomName = names[Math.floor(Math.random() * names.length)];
element.setAttribute("name", randomName);
});
document.getElementById("remove-element").addEventListener("click", () => {
const element = document.querySelector("basic-element");
if (element) {
element.parentNode.removeChild(element);
}
});
document.getElementById("add-element").addEventListener("click", () => {
const container = document.querySelector(".demo-container");
if (!container.querySelector("basic-element")) {
const element = document.createElement("basic-element");
element.setAttribute("name", "Web Components");
container.insertBefore(element, document.getElementById("update-name"));
}
});
Example:
// Define the class for the custom element
class MyCustomElement extends HTMLElement {
constructor() {
super();
// Element initialization
}
connectedCallback() {
// Called when the element is added to the document
this.innerHTML = '<p>Hello, I am a custom element!</p>';
}
disconnectedCallback() {
// Called when the element is removed from the document
console.log('Custom element removed from the page.');
}
adoptedCallback() {
// Called when the element is moved to a new document
console.log('Custom element adopted into a new document.');
}
static get observedAttributes() {
return ['data-custom-attribute'];
}
attributeChangedCallback(name, oldValue, newValue) {
// Called when observed attribute(s) change
console.log(`Attribute: ${name} changed from ${oldValue} to ${newValue}`);
}
}
// Register the custom element
customElements.define('my-custom-element', MyCustomElement);
In this example, a new custom element <my-custom-element> is defined with lifecycle callbacks to handle its addition, removal, adoption, and attribute changes.
Here is the same block as a timeline for one element:
constructorruns when the element is created. Callsuper()first. Keep it light: do not add children or attributes to the element here, and do not count on its attributes being available yet.connectedCallbackruns each time the element is put into the page. This is where you draw the element and start anything that needs cleaning up later. Moving an element to another place in the page runs the disconnect and connect callbacks again.attributeChangedCallback(name, oldValue, newValue)runs when an attribute listed inobservedAttributesis set, changed or removed. Also runs once for each listed attribute that is already in the HTML when the element starts up.disconnectedCallbackruns when the element is taken out of the page, for example byelement.remove(). This is where you stop timers and listeners.adoptedCallbackruns when the element is moved into a different document, for example from a page into an iframe. Most components never need it.
static get observedAttributes() is a list of attribute names the browser should watch for you. Attributes not in that list change silently and your callback never hears about it. Here is that failure, which the challenge below lets you fix yourself.
Coding challenge: Make the label live
<hello-tag label="Hi"> should show "Hi, reader". Right now it does, but pressing the button sets label to "Bye" and the text does not change.
Expected result: the preview first shows "Hi, reader". After you press the button, it shows "Bye, reader" without a reload.
---html
<hello-tag label="Hi"></hello-tag>
<p><button id="change">Set label to Bye</button></p>
---js
class HelloTag extends HTMLElement {
// TODO 1: tell the browser to watch the "label" attribute
static get observedAttributes() {
return [];
}
connectedCallback() {
this.render();
}
// TODO 2: call this.render() when the label changes
attributeChangedCallback(name, oldValue, newValue) {}
render() {
this.textContent = `${this.getAttribute('label') ?? 'Hello'}, reader`;
}
}
customElements.define('hello-tag', HelloTag);
document.querySelector('#change').addEventListener('click', () => {
document.querySelector('hello-tag').setAttribute('label', 'Bye');
});
This visualizer provides a step-by-step experience of the custom element lifecycle. Each lifecycle method is highlighted as it's called, with a detailed log showing exactly when and why each method is triggered, making the abstract concept tangible.
---html
<h1>Custom Element Lifecycle Visualizer</h1>
<div class="demo-container">
<h2>Lifecycle Methods</h2>
<p>Follow each step to see the lifecycle methods of a custom element in action. Each method will be highlighted as it's called.</p>
<div class="lifecycle-visualization">
<div class="lifecycle-stage" id="constructor-stage">
<div class="step-indicator">1</div>
<h3>constructor()</h3>
<p>Element instance is created</p>
<div class="step-arrow">→</div>
</div>
<div class="lifecycle-stage" id="connected-stage">
<div class="step-indicator">2</div>
<h3>connectedCallback()</h3>
<p>Element is added to the DOM</p>
<div class="step-arrow">→</div>
</div>
<div class="lifecycle-stage" id="attribute-stage">
<div class="step-indicator">3</div>
<h3>attributeChangedCallback()</h3>
<p>Attribute is changed</p>
<div class="step-arrow">→</div>
</div>
<div class="lifecycle-stage" id="disconnected-stage">
<div class="step-indicator">4</div>
<h3>disconnectedCallback()</h3>
<p>Element is removed from the DOM</p>
</div>
</div>
<div class="controls">
<button id="create-btn" class="action-btn">Step 1: Create Element</button>
<button id="connect-btn" class="action-btn" disabled>Step 2: Add to DOM</button>
<button id="attribute-btn" class="action-btn" disabled>Step 3: Change Attribute</button>
<button id="disconnect-btn" class="action-btn" disabled>Step 4: Remove from DOM</button>
<button id="reset-btn">Reset Demo</button>
</div>
<div class="element-container" id="element-container">
Element container is empty. Click "Step 1: Create Element" to begin.
</div>
<div class="log-container" id="log-container">
<div class="log-entry">
<span class="timestamp">[00:00:00]</span> Demo started. Click "Step 1: Create Element" to begin.
</div>
</div>
</div>
---css
:root {
color-scheme: light dark;
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 2rem;
line-height: 1.6;
}
.demo-container {
border: 1px solid #ccc;
padding: 20px;
border-radius: 8px;
margin: 20px 0;
}
.lifecycle-visualization {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 20px 0;
}
.lifecycle-stage {
text-align: center;
padding: 15px;
border-radius: 8px;
background-color: rgba(125, 125, 125, 0.1);
flex: 1;
min-width: 200px;
transition: all 0.5s ease;
position: relative;
}
.lifecycle-stage.active {
background-color: #4a90e2;
color: white;
transform: scale(1.05);
z-index: 1;
}
.lifecycle-stage.completed {
background-color: rgba(74, 144, 226, 0.3);
}
.controls {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 20px 0;
}
button {
padding: 8px 16px;
background-color: #4a90e2;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
}
button:hover:not(:disabled) {
background-color: #357ab8;
transform: translateY(-2px);
}
button:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
.element-container {
min-height: 200px;
padding: 20px;
border: 2px dashed #4a90e2;
border-radius: 8px;
margin-top: 20px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-style: italic;
color: #888;
}
.log-container {
background-color: rgba(125, 125, 125, 0.1);
border-radius: 4px;
padding: 15px;
margin-top: 20px;
max-height: 200px;
overflow-y: auto;
font-family: monospace;
}
.log-entry {
margin: 5px 0;
padding: 5px;
border-bottom: 1px solid rgba(125, 125, 125, 0.2);
}
.timestamp {
color: #888;
font-size: 0.8em;
}
.step-indicator {
position: absolute;
top: -10px;
left: 50%;
transform: translateX(-50%);
background-color: #4a90e2;
color: white;
border-radius: 50%;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
font-size: 14px;
font-weight: bold;
}
.step-arrow {
position: absolute;
top: 50%;
right: -15px;
transform: translateY(-50%);
color: #4a90e2;
font-size: 20px;
z-index: 2;
}
/* Dark mode adjustments */
@media (prefers-color-scheme: dark) {
body {
color: #eee;
background-color: #222;
}
.demo-container {
border-color: #555;
background-color: #333;
}
button {
background-color: #5a9de2;
}
button:hover:not(:disabled) {
background-color: #458ad2;
}
button:disabled {
background-color: #555;
color: #999;
}
.element-container {
border-color: #5a9de2;
color: #aaa;
}
.timestamp {
color: #aaa;
}
}
---js
// State tracking
let lifecycleElement = null;
let currentStep = 0;
// DOM references
const elementContainer = document.getElementById("element-container");
const createBtn = document.getElementById("create-btn");
const connectBtn = document.getElementById("connect-btn");
const attributeBtn = document.getElementById("attribute-btn");
const disconnectBtn = document.getElementById("disconnect-btn");
const resetBtn = document.getElementById("reset-btn");
// Log function with timestamp
function log(message) {
const now = new Date();
const time = now.toTimeString().substring(0, 8);
const logContainer = document.getElementById("log-container");
const logEntry = document.createElement("div");
logEntry.className = "log-entry";
logEntry.innerHTML = `<span class="timestamp">[${time}]</span> ${message}`;
logContainer.prepend(logEntry);
// Keep log size manageable
if (logContainer.children.length > 20) {
logContainer.removeChild(logContainer.lastChild);
}
}
// Highlight active lifecycle stage
function highlightStage(stageId, type = "active") {
const stages = [
"constructor-stage",
"connected-stage",
"attribute-stage",
"disconnected-stage"];
const stage = document.getElementById(stageId);
if (type === "active") {
// Mark previous stages as completed
const currentIndex = stages.indexOf(stageId);
for (let i = 0; i < currentIndex; i++) {
document.getElementById(stages[i]).classList.add("completed");
document.getElementById(stages[i]).classList.remove("active");
}
// Highlight the active stage
stage.classList.add("active");
// Remove active from all other stages
stages.forEach(id => {
if (id !== stageId) {
document.getElementById(id).classList.remove("active");
}
});
} else if (type === "completed") {
stage.classList.remove("active");
stage.classList.add("completed");
}
}
// Update buttons based on current step
function updateButtons() {
createBtn.disabled = currentStep !== 0;
connectBtn.disabled = currentStep !== 1 && currentStep !== 4; // Enable connect button at Step 1 or after disconnection (Step 4)
attributeBtn.disabled = currentStep < 2 || currentStep >= 4; // Enable after connection, disable after disconnection
disconnectBtn.disabled = currentStep < 2 || currentStep >= 4; // Enable after connection, disable after disconnection
}
// Define our lifecycle element
class LifecycleElement extends HTMLElement {
static get observedAttributes() {
return ["data-state"];
}
constructor() {
super();
// Log and highlight the constructor stage
log("🔨 constructor() called - Element instance created");
highlightStage("constructor-stage");
// Don't render content here - defer until connectedCallback or attributeChangedCallback
}
connectedCallback() {
// Log and highlight the connected stage
log("🔌 connectedCallback() called - Element added to the DOM");
highlightStage("connected-stage");
highlightStage("constructor-stage", "completed");
// Now that element is connected, render content
this.renderContent();
}
disconnectedCallback() {
// Log and highlight the disconnected stage
log("🔌 disconnectedCallback() called - Element removed from the DOM");
highlightStage("disconnected-stage");
highlightStage("attribute-stage", "completed");
}
attributeChangedCallback(name, oldValue, newValue) {
// Log and highlight the attribute stage
log(
`🔄 attributeChangedCallback() called - ${name} changed from "${oldValue}" to "${newValue}"`);
this.renderContent();
highlightStage("attribute-stage");
highlightStage("connected-stage", "completed");
}
renderContent() {
const state = this.getAttribute("data-state") || "initial";
// Create styled content based on the state
this.innerHTML = `
<div style="padding: 20px; border-radius: 8px; background-color: ${this.getStateColor(
state)
};
box-shadow: 0 4px 8px rgba(0,0,0,0.1); transition: all 0.3s ease; text-align: center;
color: ${this.getTextColor(
state)
}; width: 100%; max-width: 500px; margin: 0 auto;">
<h3 style="margin-top: 0; font-size: 1.4rem;">📝 Custom Element</h3>
<div style="padding: 15px; background-color: rgba(255,255,255,0.2); border-radius: 6px; margin: 15px 0;">
<p style="margin: 0; font-weight: bold; font-size: 1.1rem;">Current State:
<span style="background-color: rgba(0,0,0,0.1); padding: 2px 8px; border-radius: 4px;">${state}</span>
</p>
</div>
<p style="margin-bottom: 0;">This element demonstrates Custom Element lifecycle methods</p>
</div>
`;
}
getStateColor(state) {
// Light mode colors
const lightColors = {
initial: "#e0e0e0",
connected: "#a8e6cf",
updated: "#fdffab",
special: "#ffd3b6" };
// Dark mode colors (more vibrant to stand out against dark backgrounds)
const darkColors = {
initial: "#555",
connected: "#2d8a53",
updated: "#d4b726",
special: "#c44ed1" };
// Check if user prefers dark mode
const isDarkMode =
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches;
// Select appropriate color palette
const colors = isDarkMode ? darkColors : lightColors;
// Return the color for the current state, with fallback
return colors[state] || colors.initial;
}
getTextColor(state) {
// Check if user prefers dark mode
const isDarkMode =
window.matchMedia &&
window.matchMedia("(prefers-color-scheme: dark)").matches;
// For dark mode, most background colors are dark, so use light text
if (isDarkMode) return "#ffffff";
// For light mode, all our background colors are light, so use dark text
return "#333333";
}}
// Register the custom element
customElements.define("lifecycle-element", LifecycleElement);
// Step 1: Create the element
createBtn.addEventListener("click", () => {
// Create a new instance of our custom element
lifecycleElement = document.createElement("lifecycle-element");
lifecycleElement.setAttribute("data-state", "initial");
// Clear the element container and show a "created but not connected" message
elementContainer.innerHTML = `
<div style="text-align: center; padding: 20px;">
<div style="font-size: 40px; margin-bottom: 10px;">⚙️</div>
<p style="font-weight: bold; font-size: 18px;">Element Created</p>
<p>The element exists in JavaScript memory but is not visible</p>
<p>Click "Step 2: Add to DOM" to make it visible</p>
<div style="margin-top: 15px; padding: 10px; background-color: rgba(0,0,0,0.1); border-radius: 4px; display: inline-block;">
<code>const element = document.createElement('lifecycle-element');</code>
</div>
</div>
`;
// Update state
currentStep = 1;
updateButtons();
log("✅ Element created and ready to be added to the DOM.");
});
// Step 2: Connect the element to the DOM
connectBtn.addEventListener("click", () => {
// Remove the preview wrapper if it exists
elementContainer.innerHTML = "";
// Add our element directly to the DOM
elementContainer.appendChild(lifecycleElement);
// Add a visual indicator that the element is now connected
const connectedIndicator = document.createElement("div");
connectedIndicator.textContent = "✅ Connected to DOM";
connectedIndicator.style.textAlign = "center";
connectedIndicator.style.marginTop = "10px";
connectedIndicator.style.padding = "5px";
connectedIndicator.style.backgroundColor = "#2ecc71";
connectedIndicator.style.color = "white";
connectedIndicator.style.borderRadius = "4px";
connectedIndicator.style.fontWeight = "bold";
elementContainer.appendChild(connectedIndicator);
// Set the element state to reflect its new status immediately
lifecycleElement.setAttribute("data-state", "connected");
// Update state
currentStep = 2;
updateButtons();
log("✅ Element added to the DOM. connectedCallback() was triggered.");
});
// Step 3: Change an attribute
attributeBtn.addEventListener("click", () => {
// Cycle through states
const states = ["connected", "updated", "special"];
const currentState = lifecycleElement.getAttribute("data-state");
const currentIndex = states.indexOf(currentState);
// Determine the next state
const nextIndex = (currentIndex + 1) % states.length;
const nextState = states[nextIndex];
// Update the attribute
lifecycleElement.setAttribute("data-state", nextState);
// Update state
currentStep = 3;
updateButtons();
log(
`✅ Changed 'data-state' attribute from '${currentState}' to '${nextState}'.`);
});
// Step 4: Disconnect the element
disconnectBtn.addEventListener("click", () => {
// Store reference to element for the message
const state = lifecycleElement ?
lifecycleElement.getAttribute("data-state") :
"unknown";
// Find the connected indicator if it exists and remove it
const connectedIndicator = elementContainer.querySelector(
"div:not(lifecycle-element)");
if (connectedIndicator && connectedIndicator.parentNode === elementContainer) {
elementContainer.removeChild(connectedIndicator);
}
// Remove the element from the DOM if it exists and is a child of the container
if (lifecycleElement && lifecycleElement.parentNode === elementContainer) {
elementContainer.removeChild(lifecycleElement);
log(
`✅ Element (state: ${state}) removed from the DOM. disconnectedCallback() was triggered.`);
} else {
log(`ℹ️ Element was already removed from the DOM.`);
}
// Update the container text
elementContainer.innerHTML = `
<div style="text-align: center; padding: 20px;">
<div style="font-size: 40px; margin-bottom: 10px;">🔌</div>
<p style="font-weight: bold; font-size: 18px;">Element has been removed from the DOM</p>
<p>The disconnectedCallback() method was triggered</p>
</div>
`;
// Update state
currentStep = 4;
updateButtons();
});
// Reset the demo
resetBtn.addEventListener("click", () => {
// Clear the element container
elementContainer.innerHTML =
'Element container is empty. Click "Step 1: Create Element" to begin.';
// Reset the lifecycle element reference
lifecycleElement = null;
// Reset stages
document.querySelectorAll(".lifecycle-stage").forEach(stage => {
stage.classList.remove("active");
stage.classList.remove("completed");
});
// Reset state
currentStep = 0;
updateButtons();
// Log the reset
log('🔄 Demo reset. Start again with "Step 1: Create Element".');
});
Using the Custom Element#
Once defined and registered, the custom element can be used in HTML like any standard element:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Custom Element Example</title>
</head>
<body>
<my-custom-element data-custom-attribute="example"></my-custom-element>
<script src="my-custom-element.js"></script>
</body>
</html>
Ensure that the script defining the custom element (my-custom-element.js) is included in the HTML document.
This interactive example demonstrates how custom elements can react to attribute changes in real-time. Users can modify a card's properties through a control panel and see immediate updates to the component, helping them understand how reactivity works.
---html
<h1>Attribute Reactivity Demo</h1>
<div class="demo-container">
<p>This demo shows how a custom element reacts to attribute changes:</p>
<reactive-card title="My Card" theme="light" size="medium">
</reactive-card>
<div class="control-panel">
<button id="change-title">Change Title</button>
<button id="toggle-theme">Toggle Theme</button>
<button id="cycle-size">Cycle Size</button>
<button id="reset">Reset All</button>
</div>
<div class="log-container" id="log">
<div class="log-item">Attribute change log will appear here...</div>
</div>
</div>
---css
:root {
color-scheme: light dark;
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 2rem;
line-height: 1.6;
}
.control-panel {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 10px;
margin: 20px 0;
}
button {
padding: 8px 12px;
background-color: #4a90e2;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
}
button:hover {
background-color: #357ab8;
}
.demo-container {
border: 1px solid #ccc;
padding: 20px;
border-radius: 8px;
margin: 20px 0;
}
.log-container {
background-color: rgba(125, 125, 125, 0.1);
border-radius: 4px;
padding: 10px;
margin-top: 20px;
max-height: 150px;
overflow-y: auto;
font-family: monospace;
}
.log-item {
margin: 5px 0;
padding: 5px;
border-bottom: 1px solid rgba(125, 125, 125, 0.2);
}
/* Dark mode adjustments */
@media (prefers-color-scheme: dark) {
.demo-container {
border-color: #555;
background-color: #333;
}
button {
background-color: #5a9de2;
}
button:hover {
background-color: #458ad2;
}
}
---js
// Custom logger
function logEvent(message) {
const log = document.getElementById("log");
const logItem = document.createElement("div");
logItem.className = "log-item";
logItem.textContent = message;
log.prepend(logItem);
// Keep log size manageable
if (log.children.length > 10) {
log.removeChild(log.lastChild);
}
}
// Define the custom element
class ReactiveCard extends HTMLElement {
// Define observed attributes
static get observedAttributes() {
return ["title", "theme", "size"];
}
constructor() {
super();
logEvent("🔨 Constructor called");
}
connectedCallback() {
logEvent("🔌 Connected to DOM");
this.render();
}
disconnectedCallback() {
logEvent("🔌 Disconnected from DOM");
}
attributeChangedCallback(name, oldValue, newValue) {
logEvent(`🔄 Attribute "${name}" changed: ${oldValue} → ${newValue}`);
this.render();
}
render() {
// Get current attribute values with defaults
const title = this.getAttribute("title") || "Card Title";
const theme = this.getAttribute("theme") || "light";
const size = this.getAttribute("size") || "medium";
// Size mappings
const sizes = {
small: { padding: "10px", fontSize: "14px" },
medium: { padding: "20px", fontSize: "16px" },
large: { padding: "30px", fontSize: "18px" } };
// Theme mappings
const themes = {
light: { bg: "#f8f8f8", color: "#333", border: "#ddd" },
dark: { bg: "#333", color: "#f8f8f8", border: "#555" },
primary: { bg: "#4a90e2", color: "white", border: "#2a70c2" },
success: { bg: "#5cb85c", color: "white", border: "#3c983c" } };
// Get style values based on attributes
const currentTheme = themes[theme] || themes.light;
const currentSize = sizes[size] || sizes.medium;
// Apply styles and content
this.innerHTML = `
<div style="
background-color: ${currentTheme.bg};
color: ${currentTheme.color};
border: 2px solid ${currentTheme.border};
border-radius: 8px;
padding: ${currentSize.padding};
font-size: ${currentSize.fontSize};
transition: all 0.3s ease;
">
<h3>${title}</h3>
<p>Theme: ${theme}</p>
<p>Size: ${size}</p>
</div>
`;
}}
// Register the custom element
customElements.define("reactive-card", ReactiveCard);
// Demo controls
document.getElementById("change-title").addEventListener("click", () => {
const card = document.querySelector("reactive-card");
const titles = [
"Web Components Rock!",
"Custom Elements API",
"Attribute Changed",
"Reactive UI",
"Modern Web Development"];
card.setAttribute("title", titles[Math.floor(Math.random() * titles.length)]);
});
document.getElementById("toggle-theme").addEventListener("click", () => {
const card = document.querySelector("reactive-card");
const themes = ["light", "dark", "primary", "success"];
const currentTheme = card.getAttribute("theme") || "light";
const currentIndex = themes.indexOf(currentTheme);
const nextIndex = (currentIndex + 1) % themes.length;
card.setAttribute("theme", themes[nextIndex]);
});
document.getElementById("cycle-size").addEventListener("click", () => {
const card = document.querySelector("reactive-card");
const sizes = ["small", "medium", "large"];
const currentSize = card.getAttribute("size") || "medium";
const currentIndex = sizes.indexOf(currentSize);
const nextIndex = (currentIndex + 1) % sizes.length;
card.setAttribute("size", sizes[nextIndex]);
});
document.getElementById("reset").addEventListener("click", () => {
const card = document.querySelector("reactive-card");
card.setAttribute("title", "My Card");
card.setAttribute("theme", "light");
card.setAttribute("size", "medium");
});
Extending Built-in Elements#
Customized built-in elements allow us to extend existing HTML elements. To create one, extend the class of the specific element we want to enhance and specify the extends option during registration. The appeal is that you keep everything the native element already does (a button stays focusable, works with the keyboard, and submits forms).
Browser support caveat. Safari (WebKit) does not support the is="..." syntax. WebKit's developers have said they do not plan to implement it. As of October 2026 webstatus.dev still lists Safari as not implementing it, so check a current compatibility table such as MDN's before relying on it. In a browser without support, <button is="fancy-button"> is an ordinary button and your class never runs, with no error. Autonomous elements work in all current major browsers. So if a component must work for every reader, build an autonomous element and add the behavior yourself, or use a customized built-in only as a progressive enhancement where the plain element is still acceptable.
Example:
// Define the class for the custom button
class FancyButton extends HTMLButtonElement {
constructor() {
super();
this.addEventListener('click', () => {
alert('Fancy button clicked!');
});
}
}
// Register the custom element
customElements.define('fancy-button', FancyButton, { extends: 'button' });
Usage in HTML:
<button is="fancy-button">Click Me!</button>
Here, a <button> element is extended to create a fancy-button with custom behavior.
This demo showcases three different implementations of customized built-in elements: a fancy button with ripple effects, a self-validating input field, and an auto-saving list component. These examples demonstrate how to extend native HTML elements while preserving their inherent behaviors.
---html
<h1>Customized Built-in Elements</h1>
<div class="demo-container">
<div class="demo-section">
<h2>1. Fancy Button</h2>
<p>Extending the native button element with ripple effects and enhanced behavior:</p>
<button is="fancy-button" color="#4a90e2">Fancy Button</button>
<button is="fancy-button" color="#e74c3c">Danger Button</button>
<button is="fancy-button" color="#2ecc71">Success Button</button>
<p>Compare with standard buttons:</p>
<button>Standard Button</button>
</div>
<div class="demo-section">
<h2>2. Validated Input</h2>
<p>Extending the input element with built-in validation:</p>
<div class="input-wrapper">
<label for="email-input">Email Address:</label>
<input is="validated-input" id="email-input" type="email" placeholder="Enter your email" pattern="[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}$" data-error-message="Please enter a valid email address">
<div class="message" id="email-message"></div>
</div>
<div class="input-wrapper">
<label for="phone-input">Phone Number:</label>
<input is="validated-input" id="phone-input" type="tel" placeholder="Enter your phone (123-456-7890)" pattern="[0-9]{3}-[0-9]{3}-[0-9]{4}" data-error-message="Please enter a phone number in format: 123-456-7890">
<div class="message" id="phone-message"></div>
</div>
</div>
<div class="demo-section">
<h2>3. Auto-Saving List</h2>
<p>Extending the UL element with automatic saving to localStorage:</p>
<div class="list-container">
<div class="add-item">
<input type="text" id="new-item" placeholder="Add new item">
<button id="add-item-btn">Add Item</button>
</div>
<ul is="auto-list" data-storage-key="demo-list-items">
<!-- Items will be loaded from localStorage -->
</ul>
<button id="clear-list">Clear All Items</button>
</div>
</div>
<div class="log-container" id="log-container">
<div class="log-entry">Interaction log will appear here...</div>
</div>
</div>
---css
:root {
color-scheme: light dark;
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
max-width: 800px;
margin: 0 auto;
padding: 2rem;
line-height: 1.6;
}
.demo-container {
border: 1px solid #ccc;
padding: 20px;
border-radius: 8px;
margin: 20px 0;
}
.demo-section {
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 1px solid #eee;
}
h2 {
margin-top: 0;
}
button {
padding: 8px 16px;
background-color: #4a90e2;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
margin: 5px;
}
button:hover {
background-color: #357ab8;
}
/* For our cool button */
button.is-fancy {
/* Base styles before animation */
position: relative;
overflow: hidden;
}
/* For our fancy input */
.input-wrapper {
margin: 20px 0;
}
input {
padding: 8px;
border-radius: 4px;
border: 1px solid #ccc;
width: 250px;
}
.message {
margin-top: 10px;
padding: 10px;
border-radius: 4px;
display: none;
}
.success {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
/* For our auto-list */
ul.is-auto-list {
list-style-type: none;
padding: 0;
}
.list-container {
margin-top: 20px;
}
.add-item {
display: flex;
margin-bottom: 10px;
}
.add-item input {
margin-right: 10px;
}
/* Log display */
.log-container {
background-color: rgba(125, 125, 125, 0.1);
border-radius: 4px;
padding: 10px;
max-height: 150px;
overflow-y: auto;
font-family: monospace;
margin-top: 20px;
}
.log-entry {
border-bottom: 1px solid rgba(125, 125, 125, 0.2);
padding: 5px 0;
}
/* Dark mode adjustments */
@media (prefers-color-scheme: dark) {
.demo-container {
border-color: #555;
background-color: #333;
}
button {
background-color: #5a9de2;
}
button:hover {
background-color: #458ad2;
}
.demo-section {
border-bottom-color: #555;
}
input {
background-color: #444;
color: #eee;
border-color: #666;
}
.success {
background-color: #1e462e;
color: #d4edda;
border-color: #2c613e;
}
.error {
background-color: #462a2d;
color: #f8d7da;
border-color: #613438;
}
}
---js
// Utility function to log events
function logEvent(message) {
const logContainer = document.getElementById("log-container");
const logEntry = document.createElement("div");
logEntry.className = "log-entry";
logEntry.textContent = message;
logContainer.prepend(logEntry);
// Keep log size manageable
if (logContainer.children.length > 10) {
logContainer.removeChild(logContainer.lastChild);
}
}
// 1. Fancy Button - extending the native button
class FancyButton extends HTMLButtonElement {
constructor() {
super();
this.addEventListener("click", this._onClick);
this.addEventListener("mouseover", this._onMouseOver);
// Log
logEvent("FancyButton constructed");
}
connectedCallback() {
// Apply initial styles
this.style.position = "relative";
this.style.overflow = "hidden";
this.style.transition = "all 0.3s ease";
// Apply color from attribute
const color = this.getAttribute("color") || "#4a90e2";
this.style.backgroundColor = color;
logEvent("FancyButton connected to DOM");
}
_onClick = (e) => {
// Create ripple effect
const ripple = document.createElement("span");
const rect = this.getBoundingClientRect();
const size = Math.max(rect.width, rect.height);
const x = e.clientX - rect.left - size / 2;
const y = e.clientY - rect.top - size / 2;
ripple.style.width = ripple.style.height = `${size}px`;
ripple.style.left = `${x}px`;
ripple.style.top = `${y}px`;
ripple.style.position = "absolute";
ripple.style.borderRadius = "50%";
ripple.style.backgroundColor = "rgba(255, 255, 255, 0.3)";
ripple.style.transform = "scale(0)";
ripple.style.animation = "ripple 0.6s linear";
this.appendChild(ripple);
// Create the ripple animation style if it doesn't exist
if (!document.querySelector("#ripple-animation")) {
const style = document.createElement("style");
style.id = "ripple-animation";
style.textContent = `
@keyframes ripple {
to {
transform: scale(4);
opacity: 0;
}
}
`;
document.head.appendChild(style);
}
// Remove the ripple after animation
setTimeout(() => {
ripple.remove();
}, 600);
logEvent(`FancyButton clicked: ${this.textContent}`);
};
_onMouseOver = () => {
// Enhance the button on hover
this.style.transform = "translateY(-2px)";
this.style.boxShadow = "0 4px 8px rgba(0, 0, 0, 0.2)";
// Reset after hover
this.addEventListener(
"mouseout",
() => {
this.style.transform = "translateY(0)";
this.style.boxShadow = "none";
},
{ once: true }
);
};
}
// Register the fancy button
customElements.define("fancy-button", FancyButton, { extends: "button" });
// 2. Validated Input - extending the native input
class ValidatedInput extends HTMLInputElement {
constructor() {
super();
this._messageElement = null;
// Log
logEvent("ValidatedInput constructed");
}
connectedCallback() {
// Find the message element (should be next to the input)
const inputId = this.id;
this._messageElement = document.getElementById(`${inputId}-message`);
// Set up validation on blur
this.addEventListener("blur", this._validate);
// Also validate on input, but with a delay
this.addEventListener("input", this._debouncedValidate);
logEvent("ValidatedInput connected to DOM");
}
disconnectedCallback() {
// Clean up event listeners
this.removeEventListener("blur", this._validate);
this.removeEventListener("input", this._debouncedValidate);
}
_validate = () => {
const isValid = this.checkValidity();
if (this._messageElement) {
if (!isValid) {
this._messageElement.textContent =
this.getAttribute("data-error-message") || "Invalid input";
this._messageElement.className = "message error";
this._messageElement.style.display = "block";
logEvent(`Validation failed: ${this.value}`);
} else if (this.value) {
this._messageElement.textContent = "Valid input";
this._messageElement.className = "message success";
this._messageElement.style.display = "block";
setTimeout(() => {
this._messageElement.style.display = "none";
}, 2000);
logEvent(`Validation passed: ${this.value}`);
} else {
this._messageElement.style.display = "none";
}
}
};
// Debounce function to prevent too many validations
_debounce(func, wait) {
let timeout;
return function () {
clearTimeout(timeout);
timeout = setTimeout(() => func.apply(this), wait);
};
}
_debouncedValidate = this._debounce(this._validate, 500);
}
// Register the validated input
customElements.define("validated-input", ValidatedInput, { extends: "input" });
// 3. Auto-Saving List - extending the UL element
class AutoList extends HTMLUListElement {
constructor() {
super();
this._storageKey = "";
// Log
logEvent("AutoList constructed");
}
connectedCallback() {
// Get storage key from attribute
this._storageKey = this.getAttribute("data-storage-key") || "auto-list-items";
// Add styling
this.classList.add("is-auto-list");
// Load saved items
this._loadItems();
// Handle item deletion
this.addEventListener("click", this._handleItemClick);
logEvent("AutoList connected to DOM");
}
disconnectedCallback() {
this.removeEventListener("click", this._handleItemClick);
}
_loadItems() {
// Get saved items from localStorage
let items = [];
try {
const savedItems = localStorage.getItem(this._storageKey);
if (savedItems) {
items = JSON.parse(savedItems);
}
} catch (e) {
console.error("Failed to load items from localStorage:", e);
}
// Clear existing items
this.innerHTML = "";
// Add items to the list
items.forEach((item) => {
this.addItem(item);
});
logEvent(`Loaded ${items.length} items from localStorage`);
}
_saveItems() {
// Get all items
const items = Array.from(this.children).map((li) =>
li.textContent.replace("×", "").trim()
);
// Save to localStorage
try {
localStorage.setItem(this._storageKey, JSON.stringify(items));
logEvent(`Saved ${items.length} items to localStorage`);
} catch (e) {
console.error("Failed to save items to localStorage:", e);
}
}
addItem(text) {
if (!text) return;
// Create list item
const li = document.createElement("li");
li.style.padding = "8px";
li.style.marginBottom = "5px";
li.style.backgroundColor = "rgba(125, 125, 125, 0.1)";
li.style.borderRadius = "4px";
li.style.display = "flex";
li.style.justifyContent = "space-between";
// Item text
li.textContent = text;
// Delete button
const deleteBtn = document.createElement("span");
deleteBtn.textContent = "×";
deleteBtn.style.cursor = "pointer";
deleteBtn.style.marginLeft = "10px";
deleteBtn.style.fontWeight = "bold";
deleteBtn.dataset.action = "delete";
li.appendChild(deleteBtn);
this.appendChild(li);
// Save the updated list
this._saveItems();
logEvent(`Added item: ${text}`);
}
_handleItemClick = (e) => {
if (e.target.dataset.action === "delete") {
// Get the list item
const li = e.target.parentElement;
const text = li.textContent.replace("×", "").trim();
// Remove the item
li.remove();
// Save the updated list
this._saveItems();
logEvent(`Removed item: ${text}`);
}
};
clearAll() {
this.innerHTML = "";
this._saveItems();
logEvent("Cleared all items");
}
}
// Register the auto-list
customElements.define("auto-list", AutoList, { extends: "ul" });
// Demo functionality for Auto-Saving List
document.getElementById("add-item-btn").addEventListener("click", () => {
const input = document.getElementById("new-item");
const text = input.value.trim();
if (text) {
const list = document.querySelector('[is="auto-list"]');
list.addItem(text);
input.value = "";
}
});
document.getElementById("new-item").addEventListener("keypress", (e) => {
if (e.key === "Enter") {
document.getElementById("add-item-btn").click();
}
});
document.getElementById("clear-list").addEventListener("click", () => {
const list = document.querySelector('[is="auto-list"]');
list.clearAll();
});
Benefits of Using Custom Elements#
-
Reusability: Encapsulate functionality into self-contained components that can be reused across different projects.
-
Encapsulation: Isolate styles and behavior, reducing the risk of conflicts in larger codebases.
-
Interoperability: To the browser, a custom element is an ordinary element, so it works in plain HTML, with CSS selectors, with
querySelectorandaddEventListener, and inside pages made with a framework. Frameworks differ in how they pass objects or listen to custom events to your element, so test the pairing you use. -
Maintainability: Simplify complex UIs by breaking them into manageable, modular components.
This larger example ties everything together with a real-world product card component that developers might actually use in an e-commerce project. It demonstrates shadow DOM encapsulation, attribute reactivity, custom events, and styling—all in a practical, usable component.
---html
<h1>Product Card Custom Element</h1>
<div class="demo-container">
<h2>Interactive Demo</h2>
<p>This demo showcases a practical custom element: a reusable product card component. Try modifying the properties or adding new cards!</p>
<div class="controls">
<h3>Customize Product Card</h3>
<div class="control-group">
<label for="product-title">Product Title:</label>
<input type="text" id="product-title" value="Wireless Headphones">
</div>
<div class="control-group">
<label for="product-price">Price:</label>
<input type="text" id="product-price" value="$129.99">
</div>
<div class="control-group">
<label for="product-image">Image URL:</label>
<select id="product-image">
<option value="headphones">Headphones</option>
<option value="smartwatch">Smartwatch</option>
<option value="camera">Camera</option>
<option value="speaker">Speaker</option>
<option value="laptop">Laptop</option>
</select>
</div>
<div class="control-group">
<label for="product-rating">Rating (1-5):</label>
<input type="number" id="product-rating" min="1" max="5" value="4">
</div>
<div class="control-group">
<label for="product-stock">In Stock:</label>
<select id="product-stock">
<option value="true">Yes</option>
<option value="false">No</option>
</select>
</div>
<button id="update-card">Update Card</button>
<button id="add-card">Add New Card</button>
<button id="reset-demo">Reset Demo</button>
</div>
<div class="products-grid" id="products-container">
<!-- Initial product cards will be added here -->
</div>
<h3>Usage Example</h3>
<div class="code-example">
<pre><code><product-card
title="Wireless Headphones"
price="$129.99"
image="headphones"
rating="4"
in-stock="true"
></product-card></code></pre>
</div>
</div>
---css
:root {
color-scheme: light dark;
}
body {
font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
max-width: 1000px;
margin: 0 auto;
padding: 2rem;
line-height: 1.6;
}
.demo-container {
border: 1px solid #ccc;
padding: 20px;
border-radius: 8px;
margin: 20px 0;
}
.products-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 20px;
margin-top: 20px;
}
.controls {
margin: 20px 0;
padding: 15px;
background-color: rgba(125, 125, 125, 0.1);
border-radius: 8px;
}
.control-group {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
select,
input {
padding: 8px;
border-radius: 4px;
border: 1px solid #ccc;
width: 100%;
max-width: 300px;
}
button {
padding: 8px 16px;
background-color: #4a90e2;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
margin-top: 10px;
}
button:hover {
background-color: #357ab8;
}
.code-example {
background-color: rgba(125, 125, 125, 0.1);
padding: 15px;
border-radius: 4px;
margin: 20px 0;
overflow-x: auto;
}
/* Dark mode adjustments */
@media (prefers-color-scheme: dark) {
.demo-container {
border-color: #555;
background-color: #333;
}
select,
input {
background-color: #444;
color: #eee;
border-color: #666;
}
button {
background-color: #5a9de2;
}
button:hover {
background-color: #458ad2;
}
}
---js
// Define demo product images
const PRODUCT_IMAGES = {
headphones: "https://placehold.co/300x200/4a90e2/ffffff?text=Headphones",
smartwatch: "https://placehold.co/300x200/e67e22/ffffff?text=Smartwatch",
camera: "https://placehold.co/300x200/27ae60/ffffff?text=Camera",
speaker: "https://placehold.co/300x200/8e44ad/ffffff?text=Speaker",
laptop: "https://placehold.co/300x200/e74c3c/ffffff?text=Laptop" };
// Define the product card custom element
class ProductCard extends HTMLElement {
// Define observed attributes
static get observedAttributes() {
return ["title", "price", "image", "rating", "in-stock"];
}
constructor() {
super();
// Create a shadow DOM
this.attachShadow({ mode: "open" });
// Initialize
this.render();
}
// Called when element is added to the DOM
connectedCallback() {
// Add event listeners
this.shadowRoot.
querySelector(".product-add-to-cart").
addEventListener("click", this.handleAddToCart.bind(this));
}
// Called when element is removed from the DOM
disconnectedCallback() {
// Clean up event listeners
this.shadowRoot.
querySelector(".product-add-to-cart").
removeEventListener("click", this.handleAddToCart.bind(this));
}
// Called when an observed attribute changes
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
this.render();
}
}
// Handle "Add to Cart" button click
handleAddToCart() {
if (this.isInStock()) {
// Create and dispatch a custom event
const event = new CustomEvent("add-to-cart", {
bubbles: true,
composed: true, // Ensures the event crosses shadow DOM boundary
detail: {
product: {
title: this.getAttribute("title"),
price: this.getAttribute("price"),
image: this.getAttribute("image") } } });
this.dispatchEvent(event);
// Visual feedback
const button = this.shadowRoot.querySelector(".product-add-to-cart");
const originalText = button.textContent;
button.textContent = "Added!";
button.classList.add("added");
setTimeout(() => {
button.textContent = originalText;
button.classList.remove("added");
}, 1500);
}
}
// Helper method to check if product is in stock
isInStock() {
return this.getAttribute("in-stock") === "true";
}
// Generate star rating HTML
getStarRating() {
const rating = parseFloat(this.getAttribute("rating")) || 0;
const fullStars = Math.floor(rating);
const halfStar = rating % 1 >= 0.5;
const emptyStars = 5 - fullStars - (halfStar ? 1 : 0);
return `
${'<span class="star full">★</span>'.repeat(fullStars)}
${halfStar ? '<span class="star half">★</span>' : ""}
${'<span class="star empty">☆</span>'.repeat(emptyStars)}
`;
}
// Render the component
render() {
// Get attribute values with defaults
const title = this.getAttribute("title") || "Product Title";
const price = this.getAttribute("price") || "$0.00";
const imageKey = this.getAttribute("image") || "headphones";
const imageUrl = PRODUCT_IMAGES[imageKey] || PRODUCT_IMAGES.headphones;
const inStock = this.isInStock();
// Create the shadow DOM content
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
--primary-color: #4a90e2;
--text-color: #333;
--background-color: white;
--border-color: #eee;
--out-of-stock-color: #e74c3c;
}
@media (prefers-color-scheme: dark) {
:host {
--text-color: #eee;
--background-color: #333;
--border-color: #555;
}
}
.product-card {
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
transition: transform 0.3s ease, box-shadow 0.3s ease;
background-color: var(--background-color);
color: var(--text-color);
}
.product-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
}
.product-image {
width: 100%;
height: 200px;
object-fit: cover;
}
.product-info {
padding: 15px;
}
.product-title {
margin: 0 0 10px 0;
font-size: 1.2rem;
}
.product-price {
font-size: 1.4rem;
font-weight: bold;
color: var(--primary-color);
margin: 10px 0;
}
.product-rating {
margin: 10px 0;
}
.star {
color: gold;
font-size: 1.2rem;
}
.star.empty {
color: #ccc;
}
.star.half {
position: relative;
display: inline-block;
}
.star.half:after {
content: '☆';
position: absolute;
left: 0;
color: #ccc;
width: 50%;
overflow: hidden;
}
.product-stock {
margin: 10px 0;
font-weight: bold;
}
.in-stock {
color: green;
}
.out-of-stock {
color: var(--out-of-stock-color);
}
.product-add-to-cart {
width: 100%;
padding: 10px;
background-color: var(--primary-color);
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
transition: background-color 0.3s ease;
}
.product-add-to-cart:hover:not(:disabled) {
background-color: #357ab8;
}
.product-add-to-cart:disabled {
background-color: #cccccc;
cursor: not-allowed;
}
.product-add-to-cart.added {
background-color: #27ae60;
}
</style>
<div class="product-card">
<img class="product-image" src="${imageUrl}" alt="${title}">
<div class="product-info">
<h3 class="product-title">${title}</h3>
<div class="product-price">${price}</div>
<div class="product-rating">
${this.getStarRating()}
</div>
<div class="product-stock ${
inStock ? "in-stock" : "out-of-stock"
}">
${inStock ? "In Stock" : "Out of Stock"}
</div>
<button class="product-add-to-cart" ${inStock ? "" : "disabled"}>
${inStock ? "Add to Cart" : "Out of Stock"}
</button>
</div>
</div>
`;
}}
// Register the custom element
customElements.define("product-card", ProductCard);
// Demo functionality
document.addEventListener("DOMContentLoaded", () => {
const container = document.getElementById("products-container");
// Initial demo products
const initialProducts = [
{
title: "Wireless Headphones",
price: "$129.99",
image: "headphones",
rating: "4",
inStock: "true" },
{
title: "SmartWatch Pro",
price: "$199.99",
image: "smartwatch",
rating: "4.5",
inStock: "true" },
{
title: "Digital Camera",
price: "$349.99",
image: "camera",
rating: "3.5",
inStock: "false" }];
// Create initial products
initialProducts.forEach(product => {
createProductCard(product);
});
// Update card button
document.getElementById("update-card").addEventListener("click", () => {
const firstCard = container.querySelector("product-card");
if (firstCard) {
updateProductCard(firstCard);
}
});
// Add new card button
document.getElementById("add-card").addEventListener("click", () => {
const productData = {
title: document.getElementById("product-title").value,
price: document.getElementById("product-price").value,
image: document.getElementById("product-image").value,
rating: document.getElementById("product-rating").value,
inStock: document.getElementById("product-stock").value };
createProductCard(productData);
});
// Reset demo button
document.getElementById("reset-demo").addEventListener("click", () => {
// Clear all cards
container.innerHTML = "";
// Reset form values
document.getElementById("product-title").value = "Wireless Headphones";
document.getElementById("product-price").value = "$129.99";
document.getElementById("product-image").value = "headphones";
document.getElementById("product-rating").value = "4";
document.getElementById("product-stock").value = "true";
// Recreate initial cards
initialProducts.forEach(product => {
createProductCard(product);
});
});
// Listen for add-to-cart events
container.addEventListener("add-to-cart", event => {
const product = event.detail.product;
console.log("Product added to cart:", product);
// Show a toast notification
showToast(`Added ${product.title} to cart!`);
});
// Helper function to create a product card
function createProductCard(data) {
const card = document.createElement("product-card");
card.setAttribute("title", data.title);
card.setAttribute("price", data.price);
card.setAttribute("image", data.image);
card.setAttribute("rating", data.rating);
card.setAttribute("in-stock", data.inStock);
container.appendChild(card);
}
// Helper function to update a product card
function updateProductCard(card) {
card.setAttribute("title", document.getElementById("product-title").value);
card.setAttribute("price", document.getElementById("product-price").value);
card.setAttribute("image", document.getElementById("product-image").value);
card.setAttribute("rating", document.getElementById("product-rating").value);
card.setAttribute("in-stock", document.getElementById("product-stock").value);
}
// Helper function to show a toast notification
function showToast(message) {
// Create toast element
const toast = document.createElement("div");
toast.textContent = message;
toast.style.position = "fixed";
toast.style.bottom = "20px";
toast.style.right = "20px";
toast.style.backgroundColor = "#4a90e2";
toast.style.color = "white";
toast.style.padding = "10px 20px";
toast.style.borderRadius = "4px";
toast.style.boxShadow = "0 4px 8px rgba(0,0,0,0.2)";
toast.style.zIndex = "9999";
toast.style.opacity = "0";
toast.style.transform = "translateY(20px)";
toast.style.transition = "opacity 0.3s ease, transform 0.3s ease";
// Add to document
document.body.appendChild(toast);
// Trigger animation
setTimeout(() => {
toast.style.opacity = "1";
toast.style.transform = "translateY(0)";
}, 10);
// Remove after delay
setTimeout(() => {
toast.style.opacity = "0";
toast.style.transform = "translateY(20px)";
setTimeout(() => {
document.body.removeChild(toast);
}, 300);
}, 3000);
}
});
Conclusion#
You can now define a tag, draw it when it is connected, clean up when it is disconnected, and react when an attribute changes. That covers the whole life of an element, and you made one live-update in the challenge.
One limit remains. An element that draws itself with this.innerHTML shares the page's CSS, so a page rule can restyle it by accident. Before that, the next post goes deeper on the callbacks and on how components talk to each other. Giving the element a private shadow root, and filling it from a template, comes after that.
Comments require JavaScript.