// html · Web Platform Advent #5
Web Components: Custom Elements, Shadow DOM and templates
Build reusable HTML elements with the native Web Components APIs — define a Custom Element, encapsulate styles with the Shadow DOM, clone a <template>, and pass data with attributes.
Web Components are a set of native browser APIs that let you build your own reusable HTML elements — no framework required. A component you ship works the same in plain HTML, React, Vue or Svelte, because it is just an element the browser understands. Three APIs make it work: Custom Elements, the Shadow DOM, and the <template> tag.
Custom Elements
A Custom Element is a class extending HTMLElement, registered with a tag name that must contain a hyphen (so it can never collide with a future built-in tag):
class GreetingCard extends HTMLElement {
connectedCallback() {
const p = document.createElement('p');
p.textContent = 'Hello from a custom element!';
this.append(p);
}
}
customElements.define('greeting-card', GreetingCard); Once defined, you use it like any other tag:
<greeting-card></greeting-card> The class gives you lifecycle callbacks. The most useful are connectedCallback (the element was added to the page), disconnectedCallback (it was removed), and attributeChangedCallback (a watched attribute changed).
Reacting to attributes
To pass data in, read attributes — exactly like src on an image. Declare which ones to watch with a static observedAttributes getter, then respond in attributeChangedCallback. Building the node with textContent keeps user-supplied values safe:
class GreetingCard extends HTMLElement {
static get observedAttributes() {
return ['name'];
}
attributeChangedCallback(attr, oldVal, newVal) {
if (attr === 'name') this.render();
}
connectedCallback() {
this.render();
}
render() {
const name = this.getAttribute('name') || 'friend';
this.replaceChildren();
const p = document.createElement('p');
p.textContent = 'Hello, ' + name + '!';
this.append(p);
}
}
customElements.define('greeting-card', GreetingCard); Now <greeting-card name="Ada"></greeting-card> renders "Hello, Ada!", and changing the attribute live re-renders it.
The Shadow DOM: real encapsulation
The Shadow DOM gives an element a private subtree whose styles and markup are isolated from the rest of the page. A <style> inside it can never leak out, and outside CSS can never reach in by accident. Build it with the DOM API and a <slot> for projected content:
class FancyButton extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent =
'button { background:#2563eb; color:#fff; border:0;' +
' border-radius:8px; padding:.6rem 1rem; }';
const button = document.createElement('button');
button.append(document.createElement('slot'));
shadow.append(style, button);
}
}
customElements.define('fancy-button', FancyButton); The <slot> is a placeholder: whatever you put between the tags — <fancy-button>Save</fancy-button> — is projected into that spot, so the component wraps your content without replacing it.
Templates: clone once, reuse cheaply
The <template> element holds inert markup that is parsed but not rendered until you clone it. It is the efficient way to stamp out repeated structure:
<template id="row-tpl">
<li><span class="label"></span></li>
</template>
<script>
const tpl = document.getElementById('row-tpl');
const node = tpl.content.cloneNode(true);
node.querySelector('.label').textContent = 'Item 1';
document.querySelector('ul').append(node);
</script> The three pieces together
| API | Gives you |
|---|---|
| Custom Elements | Your own tags with lifecycle callbacks |
| Shadow DOM | Style and markup encapsulation, slots |
<template> | Reusable, cloneable inert markup |
You rarely need all three at once. A simple wrapper might use only a Custom Element; a styled, self-contained widget combines a Custom Element with the Shadow DOM and a slot. Because the result is plain DOM, it drops into any stack and outlives whatever framework was popular the year you wrote it.