Modern Web Development with Pure HTML, CSS, and JavaScript
Modern web frameworks offer incredible speed and structure for complex applications, and they're definitely worth learning. However, this power often comes with a steep learning curve, complex tooling, and the need for constant maintenance to keep projects secure and up-to-date.
By choosing the vanilla path, we trade some short-term conveniences for long-term benefits like simplicity, transparency, and virtually zero maintenance overhead. This approach is increasingly viable thanks to modern browsers, which provide excellent support for web standards.
Part 1: Diving into Web Components
So, what are these 'Web Components' we'll be exploring? In essence, Web Components are a set of web platform APIs that allow you to create new custom, reusable, encapsulated HTML tags to use in your web pages and web apps.
They are based on three main technologies:
- Custom Elements: A set of JavaScript APIs that allow you to define custom elements and their behavior, which can then be used in your HTML like any standard element.
- Shadow DOM: A set of JavaScript APIs for attaching an encapsulated 'shadow' DOM tree to an element — which is rendered separately from the main document DOM — and controlling associated functionality. This keeps an element's features private, so they can be scripted and styled without the fear of colliding with other parts of the document.
- HTML Templates: The
<template>
and<slot>
elements enable you to write markup templates that are not rendered in the page. These can then be reused multiple times as the basis of a custom element's structure.
These definitions might sound a bit abstract. If you've tried learning about Web Components before, you might have found them a bit confusing. But don't worry! We'll break it down step-by-step, and you'll see it's not as complicated as it seems.
The Beginnings: A Simple Component
Let's start with the absolute basics: a custom element that displays a 'Greetings, Earthling!' message.
Create a file named greeting-message.js
:
class GreetingMessage extends HTMLElement {
connectedCallback() {
this.textContent = 'Greetings, Earthling!';
}
}
customElements.define('greeting-message', GreetingMessage);
You can use this on an HTML page like so (index.html
):
<!doctype html>
<html lang=en>
<head>
<meta charset=UTF-8>
<title>Simple Component</title>
</head>
<body>
<script src=greeting-message.js></script>
<p>A message from beyond:</p>
<greeting-message></greeting-message>
</body>
</html>
What's happening here? We've created a new HTML element by registering it as the <greeting-message>
tag and used it on our page. This results in a DOM structure where <greeting-message>
contains the text 'Greetings, Earthling!'.
Let's break down the JavaScript:
class GreetingMessage extends HTMLElement
: Every custom element is a class that extendsHTMLElement
. This gives our element all the standard properties and methods of an HTML element.connectedCallback()
: This is a lifecycle callback that fires when the element is added to the DOM. It's a good place to do initial setup, like setting content. Be aware: it can be called multiple times if the element is moved in the DOM.this.textContent = '...';
: Here,this
refers to our custom element instance. We're using the standardtextContent
property to set its content.customElements.define('greeting-message', GreetingMessage);
: This is crucial. It registers our custom element class with the browser and associates it with the tag name'greeting-message'
. After this line, the browser knows how to handle<greeting-message>
tags.
This rule helps distinguish custom elements from standard HTML elements and avoids naming conflicts. It's also a good practice to use a unique prefix for your set of components (e.g., 'my-greeting-message'
or 'x-greeting-message'
as seen in the original article) if you plan to distribute them, to further reduce collision risks in the global tag namespace.
Another important detail: custom elements cannot be self-closing. You must use <greeting-message></greeting-message>
, not <greeting-message />
.
Stepping Up: A More Sophisticated Component
The simple example is fine for a demo, but real-world components often need more:
- Adding child DOM elements for richer content.
- Accepting attributes and updating the DOM when they change.
- Styling the element, preferably in an isolated and scalable way.
- Defining all custom elements from a central point instead of scattering
<script>
tags.
Let's build a <user-profile-card>
component to illustrate these points. It will display a user's avatar, name, and a short bio.
Create components/user-profile-card.js
:
class UserProfileCard extends HTMLElement {
constructor() {
super(); // Always call super first in constructor
// Create a container for our card's content
this._container = document.createElement('div');
this._container.className = 'profile-card-container';
// We'll append this to `this` in connectedCallback
}
connectedCallback() {
// Ensure the container is only added once
if (!this.contains(this._container)) {
this.appendChild(this._container);
}
this.render(); // Initial render
}
static get observedAttributes() {
return ['name', 'avatar-src', 'bio', 'theme'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) {
this.render(); // Re-render if an observed attribute changes
}
}
render() {
const name = this.getAttribute('name') || 'Anonymous User';
const avatarSrc = this.getAttribute('avatar-src') || 'default-avatar.png';
const bio = this.getAttribute('bio') || 'No bio provided.';
const theme = this.getAttribute('theme') || 'light'; // Example: 'light' or 'dark'
this._container.innerHTML = `
<img src=${avatarSrc} alt=${name}'s avatar class=profile-avatar>
<h3 class=profile-name>${name}</h3>
<p class=profile-bio>${bio}</p>
`;
// Apply theme class to the custom element itself
this.className = `theme-${theme}`;
}
}
export const registerUserProfileCard = () => {
customElements.define('user-profile-card', UserProfileCard);
};
Key changes and concepts here:
constructor()
: We're using a constructor to create an internal<div>
container. Note that you cannot reliably access attributes or children here, as the element might not be fully parsed or attached to the DOM yet. DOM manipulations related to the element's own structure are typically done inconnectedCallback
.static get observedAttributes()
: This static getter must return an array of attribute names that the component wants to monitor for changes. If an attribute is not listed here,attributeChangedCallback
won't fire for it.attributeChangedCallback(name, oldValue, newValue)
: This lifecycle callback is invoked whenever one of theobservedAttributes
is added, removed, or changed. It's perfect for reacting to attribute updates.render()
: We've created a dedicated method to handle the UI rendering. This centralizes the logic for both initial display and updates. It safely reads attributes usinggetAttribute()
with fallbacks. Important: When usinginnerHTML
with dynamic data, always be mindful of Cross-Site Scripting (XSS) risks. We'll touch on sanitization later.export const registerUserProfileCard = () => {...}
: We export a registration function. This allows us to centralize all component definitions in our application's main JavaScript file.
Styling Your Component
We can style our component using a separate CSS file. Create components/user-profile-card.css
:
user-profile-card {
display: inline-block;
border: 1px solid #ccc;
border-radius: 8px;
padding: 16px;
margin: 10px;
font-family: Arial, sans-serif;
box-shadow: 2px 2px 5px rgba(0,0,0,0.1);
transition: background-color 0.3s, color 0.3s;
}
/* Theme styling */
user-profile-card.theme-light {
background-color: #f9f9f9;
color: #333;
}
user-profile-card.theme-dark {
background-color: #333;
color: #f9f9f9;
border-color: #555;
}
user-profile-card .profile-card-container {
text-align: center;
}
user-profile-card .profile-avatar {
width: 100px;
height: 100px;
border-radius: 50%;
object-fit: cover;
margin-bottom: 10px;
border: 2px solid #ddd;
}
user-profile-card.theme-dark .profile-avatar {
border-color: #666;
}
user-profile-card .profile-name {
margin: 10px 0 5px 0;
font-size: 1.2em;
}
user-profile-card .profile-bio {
font-size: 0.9em;
color: #666;
}
user-profile-card.theme-dark .profile-bio {
color: #ccc;
}
Notice how we can style the custom element user-profile-card
directly, and even target it based on its attributes (or classes set based on attributes, like .theme-dark
). Prefixing selectors with the component tag (user-profile-card .profile-name
) helps scope styles and prevent conflicts with the rest of the page.
Putting It All Together
Let's see this in action. Our main index.html
will now centralize JavaScript and CSS imports.
<!doctype html>
<html lang=en>
<head>
<meta charset=UTF-8>
<title>User Profile Cards</title>
<link rel=stylesheet href=index.css>
</head>
<body>
<script type=module src=index.js></script>
<h1>User Profiles</h1>
<user-profile-card
name=Alice Wonderland
avatar-src=https://i.pravatar.cc/150?u=alice
bio=Curiouser and curiouser!
theme=light>
</user-profile-card>
<user-profile-card
name=Bob The Builder
avatar-src=https://i.pravatar.cc/150?u=bob
bio=Can we fix it? Yes, we can!
theme=dark>
</user-profile-card>
</body>
</html>
Our main JavaScript file, index.js
:
import { registerUserProfileCard } from './components/user-profile-card.js';
const initializeApp = () => {
registerUserProfileCard();
// Register other components here if you have them
};
// Wait for the DOM to be fully loaded before initializing
document.addEventListener('DOMContentLoaded', initializeApp);
And a main CSS file, index.css
, to import component styles:
@import url(./components/user-profile-card.css);
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
padding: 20px;
background-color: #eef;
}
h1 {
color: #333;
}
Did you notice the import
and export
syntax? And the <script type="module" src="index.js">
in index.html
? This enables native ES Modules in the browser! No bundler like Webpack or Rollup is needed for this setup. Modern browsers handle it beautifully.
Nesting Components: Handling Children
Web Components can naturally contain other HTML elements or even other Web Components as children. This is default behavior. Let's create a <collapsible-panel>
component that can hide or show its content.
Create components/collapsible-panel.js
:
class CollapsiblePanel extends HTMLElement {
constructor() {
super();
this._isOpen = this.hasAttribute('open');
this._titleElement = document.createElement('div');
this._titleElement.className = 'panel-title';
this._titleElement.addEventListener('click', () => this.toggle());
this._contentElement = document.createElement('div');
this._contentElement.className = 'panel-content';
}
connectedCallback() {
// Move existing children into the content wrapper
// This needs to be done carefully if connectedCallback runs multiple times
if (!this.contains(this._titleElement)) {
// Set title text from attribute or default
this._titleElement.textContent = this.getAttribute('title-text') || 'Click to Expand';
this.prepend(this._titleElement);
// Append content container
this.appendChild(this._contentElement);
// Move slotted children
// Note: This simple move works for initial setup.
// For dynamic children, MutationObserver or slots (with Shadow DOM) are better.
while (this.firstChild && this.firstChild !== this._titleElement && this.firstChild !== this._contentElement) {
if(this.firstChild.nodeType === Node.ELEMENT_NODE || this.firstChild.nodeType === Node.TEXT_NODE) {
this._contentElement.appendChild(this.firstChild);
} else {
this.removeChild(this.firstChild); // Remove other node types like comments if any
}
}
}
this.updateVisibility();
}
toggle() {
this._isOpen = !this._isOpen;
this.updateVisibility();
}
updateVisibility() {
this._contentElement.style.display = this._isOpen ? 'block' : 'none';
this._titleElement.setAttribute('aria-expanded', this._isOpen.toString());
if (this._isOpen) {
this.setAttribute('open', '');
} else {
this.removeAttribute('open');
}
}
static get observedAttributes() {
return ['title-text', 'open'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'title-text' && this._titleElement) {
this._titleElement.textContent = newValue || 'Click to Expand';
}
if (name === 'open') {
const wantsOpen = this.hasAttribute('open');
if (this._isOpen !== wantsOpen) {
this._isOpen = wantsOpen;
this.updateVisibility();
}
}
}
}
export const registerCollapsiblePanel = () => {
customElements.define('collapsible-panel', CollapsiblePanel);
};
And some basic styling in components/collapsible-panel.css
:
collapsible-panel {
display: block;
border: 1px solid #ddd;
margin-bottom: 10px;
border-radius: 4px;
}
collapsible-panel .panel-title {
background-color: #f0f0f0;
padding: 10px;
cursor: pointer;
font-weight: bold;
user-select: none; /* Prevent text selection on click */
}
collapsible-panel .panel-title:hover {
background-color: #e0e0e0;
}
collapsible-panel .panel-content {
padding: 10px;
border-top: 1px solid #ddd;
}
/* Style for when it's open (optional, if JS handles display) */
collapsible-panel[open] .panel-title {
/* background-color: #d0d0d0; */ /* Example: change title bg when open */
}
Using it in index.html
(don't forget to import and register in index.js
and import CSS in index.css
):
<collapsible-panel title-text=Section 1: Click Me>
<p>This is some content inside the first collapsible panel.</p>
<p>It can be <i>any</i> HTML!</p>
</collapsible-panel>
<collapsible-panel title-text=Section 2: Initially Open open>
<user-profile-card name=Nested Component avatar-src=https://i.pravatar.cc/150?u=nested></user-profile-card>
</collapsible-panel>
In this CollapsiblePanel
, we manually create a title and content area. The original child elements provided by the user (like the <p>
tags) are moved into our internal _contentElement
. This is a common pattern for components that 'wrap' or manage user-provided content without Shadow DOM.
A note on managing children: Directly manipulating this.innerHTML
can be destructive if you're not careful, as it wipes out existing children. Methods like appendChild()
, insertBefore()
, or carefully moving nodes are safer. For more complex scenarios or reacting to dynamic changes in children, you might use a MutationObserver
or, as we'll see next, Shadow DOM with slots.
Advanced Techniques: Shadow DOM and HTML Templates
Now we're ready to explore the more advanced features of Web Components: Shadow DOM for encapsulation and HTML Templates for reusable markup structures. These are powerful tools for building truly self-contained and complex components.
Let's build a component. Modals are a great use case for Shadow DOM because you want their internal structure and styles to be isolated from the rest of the page.
Create components/modal-dialog.js
:
const modalTemplate = document.createElement('template');
modalTemplate.innerHTML = `
<link rel=stylesheet href=./modal-dialog.css>
<div class=modal-overlay part=overlay>
<div class=modal-content part=content>
<button class=modal-close-btn part=close-button aria-label=Close modal>×</button>
<div class=modal-header part=header>
<slot name=header><h2>Default Modal Title</h2></slot>
</div>
<div class=modal-body part=body>
<slot>Default modal content goes here.</slot>
</div>
<div class=modal-footer part=footer>
<slot name=footer><button class=default-close>Close</button></slot>
</div>
</div>
</div>
`;
// Note: In a real scenario with ES modules, for the CSS path,
// you might use: href=${new URL('./modal-dialog.css', import.meta.url)}
// or ensure the path is relative to the final HTML document.
// For simplicity here, we assume modal-dialog.css is relative to the HTML.
class ModalDialog extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.appendChild(modalTemplate.content.cloneNode(true));
this._isOpen = false;
}
connectedCallback() {
// Event listeners for closing the modal
this.shadowRoot.querySelector('.modal-close-btn').addEventListener('click', () => this.close());
this.shadowRoot.querySelector('.modal-overlay').addEventListener('click', (event) => {
if (event.target === this.shadowRoot.querySelector('.modal-overlay')) {
this.close();
}
});
// Handle default close button in footer slot if it exists
const defaultCloseButton = this.shadowRoot.querySelector('slot[name=footer] .default-close');
if (defaultCloseButton) {
defaultCloseButton.addEventListener('click', () => this.close());
}
// Initial state from attribute
if (this.hasAttribute('open')) {
this.open();
}
// Call updateVisibility initially to set the correct display state based on _isOpen
// which might have been set by the 'open' attribute check.
this.updateVisibility();
}
static get observedAttributes() {
return ['open'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'open') {
if (this.hasAttribute('open')) {
this.open();
} else {
this.close();
}
}
}
open() {
if (this._isOpen) return; // Already open
this._isOpen = true;
this.setAttribute('open', ''); // Reflect state as attribute
this.updateVisibility();
this.dispatchEvent(new CustomEvent('modal-open', { bubbles: true, composed: true }));
}
close() {
if (!this._isOpen) return; // Already closed
this._isOpen = false;
this.removeAttribute('open'); // Reflect state as attribute
this.updateVisibility();
this.dispatchEvent(new CustomEvent('modal-close', { bubbles: true, composed: true }));
}
updateVisibility() {
// This method now solely relies on :host([open]) for visibility control via CSS
// The JS logic for style.display is removed to prefer CSS-driven visibility.
// If you needed JS-driven display, you'd do:
// const overlay = this.shadowRoot.querySelector('.modal-overlay');
// if (overlay) {
// overlay.style.display = this._isOpen ? 'flex' : 'none';
// }
}
}
export const registerModalDialog = () => {
customElements.define('modal-dialog', ModalDialog);
};
There's a lot happening in modal-dialog.js
. Let's break it down:
const modalTemplate = document.createElement('template');
: We start by creating an HTML<template>
element. Templates are inert fragments of HTML. Their content isn't rendered and scripts don't run until they are explicitly cloned and inserted into the DOM. This is efficient for defining complex structures that will be reused.template.innerHTML = \`...\`;
: We populate the template with our modal's internal HTML structure.<link rel=stylesheet href=./modal-dialog.css>
: Inside the template, we link to a CSS file. Styles defined inmodal-dialog.css
will only apply to the content within this Shadow DOM. This is a key benefit of encapsulation! The path resolution for thishref
can sometimes be tricky. Usingnew URL('./modal-dialog.css', import.meta.url).pathname
is a robust way if your component and CSS are co-located and you're using ES modules. For simplicity in this example, we assume a path relative to the final HTML document or that your server setup handles it.<slot name=header>...</slot>
and<slot>...</slot>
: These are slots. They are placeholders where content provided by the user of the component (i.e., child elements of<modal-dialog>
) will be inserted. The unnamed<slot>
is the default slot. Named slots like<slot name=header>
allow users to target specific areas. For example,<h1 slot=header>My Title</h1>
would go into the 'header' slot.part=overlay
: Thepart
attribute is used to expose specific internal elements of a Shadow DOM for external styling using the::part()
pseudo-element. This offers a controlled way to allow users to style parts of your component from outside the Shadow DOM.
constructor() { super(); this.attachShadow({ mode: 'open' }); ... }
: In the constructor, we callthis.attachShadow({ mode: 'open' })
. This attaches a Shadow DOM tree to our custom element.mode: 'open'
means the Shadow DOM can be accessed from JavaScript outside the component viaelement.shadowRoot
.mode: 'closed'
would make it inaccessible (generally less useful). The constructor is the only place you should callattachShadow
.this.shadowRoot.appendChild(modalTemplate.content.cloneNode(true));
: We then clone the content of ourmodalTemplate
(usingcloneNode(true)
for a deep clone) and append it to theshadowRoot
. This makes the template's structure live inside our component's encapsulated DOM.- Event listeners like
this.shadowRoot.querySelector('.modal-close-btn').addEventListener(...)
are attached to elements within the Shadow DOM. Standard DOM querying methods work onthis.shadowRoot
. - The
open()
andclose()
methods manage the modal's visibility and dispatch custom events (modal-open
,modal-close
) that can be listened to by parent elements. Thecomposed: true
option for CustomEvents allows them to bubble out of the Shadow DOM.
Now, the styles for our modal in components/modal-dialog.css
. These styles are scoped to the Shadow DOM.
/* Styles for modal-dialog.css - Scoped to the Shadow DOM */
:host {
/* Styles for the custom element itself (the 'host' of the Shadow DOM) */
/* display: block; is often a good default for wrapper-type components */
/* Visibility is controlled by the [open] attribute */
}
:host([hidden]) {
display: none;
}
.modal-overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
opacity: 0;
visibility: hidden;
transition: opacity 0.3s ease, visibility 0s 0.3s linear; /* Delay visibility transition */
}
:host([open]) .modal-overlay {
opacity: 1;
visibility: visible;
transition: opacity 0.3s ease, visibility 0s 0s linear;
}
.modal-content {
background-color: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 5px 15px rgba(0,0,0,0.3);
min-width: 300px;
max-width: 80%;
position: relative;
transform: scale(0.9);
transition: transform 0.3s ease;
}
:host([open]) .modal-content {
transform: scale(1);
}
.modal-close-btn {
position: absolute;
top: 10px;
right: 10px;
background: transparent;
border: none;
font-size: 1.5rem;
cursor: pointer;
line-height: 1;
padding: 0.25rem 0.5rem;
}
.modal-header {
border-bottom: 1px solid #eee;
padding-bottom: 10px;
margin-bottom: 10px;
}
.modal-header ::slotted(h2) { /* Style elements slotted into 'header' */
margin: 0;
font-size: 1.5rem;
}
.modal-body {
margin-bottom: 15px;
}
.modal-footer {
border-top: 1px solid #eee;
padding-top: 10px;
text-align: right;
}
.modal-footer ::slotted(button) { /* Style buttons slotted into 'footer' */
margin-left: 10px;
padding: 8px 15px;
border-radius: 4px;
cursor: pointer;
}
.modal-footer .default-close {
background-color: #ccc;
border: 1px solid #bbb;
padding: 8px 15px; /* Ensure consistent padding */
border-radius: 4px; /* Ensure consistent border-radius */
cursor: pointer; /* Ensure cursor is pointer */
}
Key CSS concepts for Shadow DOM:
:host
: This pseudo-class selects the host element itself (our<modal-dialog>
). You can use it to style the component from within its Shadow DOM, e.g.,:host { display: block; }
or:host([open]) { ... }
to style based on attributes.- Regular selectors like
.modal-overlay
apply to elements inside the Shadow DOM and won't affect anything outside, nor will external styles (except inheritable CSS properties likecolor
orfont-family
, or CSS Custom Properties) affect them. ::slotted(selector)
: This pseudo-element allows you to style elements that are passed in via<slot>
. For example,::slotted(h2)
would style any<h2>
element that's slotted into the component.
Using the modal in index.html
(don't forget to register the component in index.js
and import its CSS if you have a global index.css
):
<button id=openModalBtn>Open Modal</button>
<modal-dialog id=myModal>
<h2 slot=header>Custom Modal Title</h2>
<p>This is the main content of the modal. You can put <b>anything</b> here!</p>
<user-profile-card name=Modal User avatar-src=https://i.pravatar.cc/100?u=modal bio=I live in a modal!></user-profile-card>
<div slot=footer>
<button id=customCloseBtn>Save Changes</button>
<button id=justCloseBtn>Cancel</button>
</div>
</modal-dialog>
<script>
// In a real app, this would be in index.js or similar, after DOMContentLoaded
// and after components are registered.
document.addEventListener('DOMContentLoaded', () => {
// Ensure user-profile-card is registered if used here
// import { registerUserProfileCard } from './components/user-profile-card.js';
// registerUserProfileCard();
// import { registerModalDialog } from './components/modal-dialog.js';
// registerModalDialog();
const modal = document.getElementById('myModal');
const openBtn = document.getElementById('openModalBtn');
// Buttons inside the slot are in the Light DOM, so query them from document
const customCloseBtn = document.getElementById('customCloseBtn');
const justCloseBtn = document.getElementById('justCloseBtn');
if (openBtn) openBtn.addEventListener('click', () => modal.open());
if (customCloseBtn) customCloseBtn.addEventListener('click', () => {
alert('Changes saved (not really)!');
modal.close();
});
if (justCloseBtn) justCloseBtn.addEventListener('click', () => modal.close());
modal.addEventListener('modal-open', () => console.log('Modal opened!'));
modal.addEventListener('modal-close', () => console.log('Modal closed!'));
});
</script>
To Shadow or Not to Shadow?
Shadow DOM is powerful, but it's not always the right choice. It comes with trade-offs:
Potential Downsides of Shadow DOM:
- Performance: Creating many Shadow DOM instances can have a performance cost, though browsers are optimizing this.
- Accessibility (ARIA): While ARIA works across shadow boundaries, relationships (like
aria-labelledby
) can be trickier to manage if IDs are involved across boundaries. Careful design is needed. - SEO: Googlebot can crawl and index content within Shadow DOM, but other crawlers might struggle.
- Styling Complexity: While encapsulation is a benefit, styling from the outside becomes more deliberate (CSS custom properties,
::part
). Global styles don't penetrate easily. - FOUC (Flash Of Unstyled Content): If styles are loaded via
<link>
inside the Shadow DOM, there can be a brief moment of unstyled content. Constructable Stylesheets can mitigate this but add complexity.
When Shadow DOM Shines:
- True Encapsulation: When you need to guarantee that your component's styles and DOM structure won't interfere with or be affected by the surrounding page (e.g., for third-party widgets).
- Complex Internal Structure with Slots: When your component has a sophisticated internal layout and needs to accept user content into specific areas (like our modal example).
- Protecting Internal Logic: If you want to hide implementation details of your component's DOM.
If you're building components for internal use within a known codebase and don't strictly need this level of encapsulation, you might find it simpler to omit Shadow DOM. But for shareable, robust components, it's an invaluable tool.
Making Components Talk: Data Transfer
So far, we've mostly passed simple string attributes. Real applications often need to pass complex data (objects, arrays) between components or communicate actions. Let's build a simple To-Do List application to demonstrate three main ways components can interact.
Our To-Do app will have:
<todo-form>
: To add new tasks.<todo-list-display>
: To show the list of tasks and allow interaction.<todo-summary>
: To show counts of tasks.<todo-app-container>
: A parent component to manage state and orchestrate the others.
1. Bubbling Up: Custom Events
Events are the standard way for child components to communicate information or actions up to their parents. Our <todo-form>
will dispatch an event when a new task is submitted.
Create components/todo-form.js
:
class TodoForm extends HTMLElement {
connectedCallback() {
if (this.querySelector('form')) return; // Prevent re-rendering if already set up
this.innerHTML = `
<form>
<input type=text name=taskText placeholder=Enter a new task required>
<button type=submit>Add Task</button>
</form>
`;
this.querySelector('form').addEventListener('submit', (event) => {
event.preventDefault();
const formData = new FormData(event.target);
const taskText = formData.get('taskText').trim();
if (taskText) {
this.dispatchEvent(new CustomEvent('newTask', {
bubbles: true, // Allows event to bubble up the DOM tree
composed: true, // Allows event to cross Shadow DOM boundaries (if any)
detail: { text: taskText }
}));
event.target.reset(); // Clear the form
}
});
}
}
export const registerTodoForm = () => customElements.define('todo-form', TodoForm);
When the form is submitted, we create a CustomEvent
named 'newTask'
. The task data is passed in the event.detail
object. bubbles: true
is important so parent elements can listen for it. composed: true
is good practice if this component might ever be used inside a Shadow DOM and the event needs to escape it.
2. Passing Down: Properties
To pass complex data (like an array of to-do items) from a parent to a child, JavaScript properties are often better than attributes (which are always strings). Our <todo-list-display>
will receive its tasks via a property and also dispatch events for interactions.
Create components/todo-list-display.js
:
class TodoListDisplay extends HTMLElement {
constructor() {
super();
this._tasks = [];
this._listContainer = document.createElement('ul');
}
connectedCallback() {
if (!this.contains(this._listContainer)) {
this.appendChild(this._listContainer);
}
this.render();
// Event delegation for task interaction buttons
this._listContainer.addEventListener('click', (event) => {
const target = event.target;
const taskItem = target.closest('li');
if (!taskItem) return;
const index = parseInt(taskItem.dataset.index, 10);
if (target.classList.contains('toggle-btn')) {
this.dispatchEvent(new CustomEvent('toggleTask', {
detail: { index },
bubbles: true,
composed: true
}));
}
if (target.classList.contains('delete-btn')) {
this.dispatchEvent(new CustomEvent('deleteTask', {
detail: { index },
bubbles: true,
composed: true
}));
}
});
}
// Property setter for tasks
set tasks(newTasks) {
this._tasks = Array.isArray(newTasks) ? newTasks : [];
this.render();
}
get tasks() {
return this._tasks;
}
render() {
this._listContainer.innerHTML = ''; // Clear previous items
if (this._tasks.length === 0) {
const li = document.createElement('li');
li.textContent = 'No tasks yet!';
this._listContainer.appendChild(li);
return;
}
this._tasks.forEach((task, index) => {
const li = document.createElement('li');
li.dataset.index = index; // Store index for event handling
const span = document.createElement('span');
span.textContent = task.text; // Safer: using textContent
if (task.completed) {
span.classList.add('completed');
}
const toggleBtn = document.createElement('button');
toggleBtn.classList.add('toggle-btn');
toggleBtn.textContent = task.completed ? 'Undo' : 'Complete';
const deleteBtn = document.createElement('button');
deleteBtn.classList.add('delete-btn');
deleteBtn.textContent = 'Delete';
li.appendChild(span);
li.appendChild(toggleBtn);
li.appendChild(deleteBtn);
this._listContainer.appendChild(li);
});
}
}
export const registerTodoListDisplay = () => customElements.define('todo-list-display', TodoListDisplay);
The set tasks(newTasks)
setter allows the parent component to assign an array directly to the tasks
property (e.g., todoListElement.tasks = myArray;
). When the property is set, it triggers a re-render. This component also dispatches toggleTask
and deleteTask
events.
3. Direct Interaction: Methods
Sometimes, a parent might need to trigger an action or pass data to a child by calling one of its methods directly. Our <todo-summary>
component will have an updateSummary(tasks)
method.
Create components/todo-summary.js
:
class TodoSummary extends HTMLElement {
connectedCallback() {
if (!this.querySelector('p')) {
this.innerHTML = '<p>Loading summary...</p>';
}
}
updateSummary(tasks) {
if (!Array.isArray(tasks)) return;
const total = tasks.length;
const completed = tasks.filter(task => task.completed).length;
const pending = total - completed;
if (total === 0) {
this.innerHTML = '<p>No tasks on the list yet.</p>';
} else {
// Using textContent for safety, though these are numbers
const p = this.querySelector('p') || document.createElement('p');
p.textContent = `Total: ${total}, Completed: ${completed}, Pending: ${pending}`;
if (!this.querySelector('p')) this.appendChild(p);
}
}
}
export const registerTodoSummary = () => customElements.define('todo-summary', TodoSummary);
The parent component can get a reference to this element and call todoSummaryElement.updateSummary(tasksArray)
. This is often suitable for stateless components or when you need to imperatively trigger an update.
The To-Do App Container
Finally, the <todo-app-container>
ties it all together.
Create components/todo-app-container.js
:
class TodoAppContainer extends HTMLElement {
constructor() {
super();
this._tasks = [
{ text: 'Learn Web Components', completed: true },
{ text: 'Build a cool demo', completed: false }
];
}
connectedCallback() {
// Check if already rendered to prevent duplication if moved in DOM
if (this.querySelector('h1')) return;
this.innerHTML = `
<h1>My To-Do List</h1>
<todo-form></todo-form>
<h2>Tasks:</h2>
<todo-list-display></todo-list-display>
<h2>Summary:</h2>
<todo-summary></todo-summary>
`;
this._todoForm = this.querySelector('todo-form');
this._todoListDisplay = this.querySelector('todo-list-display');
this._todoSummary = this.querySelector('todo-summary');
// Listen for 'newTask' event from todo-form
this._todoForm.addEventListener('newTask', (event) => {
this.addTask(event.detail.text);
});
// Listen for events from todo-list-display
this._todoListDisplay.addEventListener('toggleTask', (event) => {
this.toggleTask(event.detail.index);
});
this._todoListDisplay.addEventListener('deleteTask', (event) => {
this.deleteTask(event.detail.index);
});
this.render(); // Initial render of list and summary
}
addTask(text) {
this._tasks.push({ text, completed: false });
this.render();
}
toggleTask(index) {
if (this._tasks[index]) {
this._tasks[index].completed = !this._tasks[index].completed;
this.render();
}
}
deleteTask(index) {
if (this._tasks[index]) {
this._tasks.splice(index, 1);
this.render();
}
}
render() {
// Pass tasks to todo-list-display via property
// Pass a copy to prevent direct mutation of the child's internal state if it were to modify the array
if (this._todoListDisplay) this._todoListDisplay.tasks = [...this._tasks];
// Update todo-summary via method call
if (this._todoSummary) this._todoSummary.updateSummary([...this._tasks]);
}
}
export const registerTodoAppContainer = () => customElements.define('todo-app-container', TodoAppContainer);
The container manages the _tasks
array. It listens for events from <todo-form>
and <todo-list-display>
, updates its state, and then re-renders its children by setting the tasks
property on <todo-list-display>
and calling the updateSummary()
method on <todo-summary>
.
To use this app, your index.html
would simply contain . Your
index.js
would import and register all four components (registerTodoForm
, registerTodoListDisplay
, registerTodoSummary
, registerTodoAppContainer
) and call these registration functions, typically within a DOMContentLoaded
listener. You'd also add some CSS for styling (e.g., in a global index.css
or component-specific CSS files, add styles like todo-list-display .completed { text-decoration: line-through; color: grey; }
and styles for buttons).
The Power of Vanilla
And there you have it – a comprehensive look at building Web Components using only vanilla HTML, CSS, and JavaScript. We've covered everything from simple elements to complex, encapsulated components with Shadow DOM, templates, and various data communication patterns.
Remember, all this code runs natively in modern browsers without any frameworks, libraries, or build tools. This means less overhead, greater transparency, and excellent long-term maintainability. Your components won't break because a dependency had a major version update.