JavaScript Web Components: Custom Elements, Shadow DOM, and Slots
Web Components let you build reusable browser-native UI elements with vanilla JavaScript and standard web APIs. This article explains how custom elements, Shadow DOM, and slots work together, when to use them, and how to avoid the most common mistakes.
Quick answer: Web Components are a set of browser features for creating custom HTML elements with their own behavior, styling, and content insertion points. They are ideal when you want reusable UI with strong encapsulation and no framework dependency.
Difficulty: Intermediate
You'll understand this better if you know: basic HTML elements, DOM manipulation, and how JavaScript classes work.
1. What Is Web Components?
Web Components are browser standards that let you create your own HTML tags and package their behavior, structure, and styling into reusable units. Instead of relying on a framework component model, you define native components that work directly in the browser.
- Custom Elements let you define new tags such as <user-card>.
- Shadow DOM creates an encapsulated DOM subtree with isolated styles.
- HTML templates store reusable markup that can be cloned efficiently.
- Slots let you pass light DOM content into specific places inside the component.
These features are independent, but they are often used together to create complete components.
2. Why Web Components Matter
Web Components solve a common problem: many apps need reusable UI pieces that behave consistently across pages, teams, or projects. They help you avoid copy-pasting markup and styles while keeping component logic in one place.
They are especially useful when you want the component to work in plain HTML, in a static site, or alongside different frameworks. Since they are web standards, the same component can often be reused across multiple codebases.
Web Components are not always the best choice for every project. If your app is small or the UI is simple, native HTML plus a little JavaScript may be enough. If you need a large ecosystem of prebuilt widgets and app-level patterns, a framework may still be more productive.
3. Basic Syntax or Core Idea
Define a custom element
A custom element is usually created by extending HTMLElement and registering the class with customElements.define().
class UserCard extends HTMLElement {
connectedCallback() {
this.innerHTML = `<p>Hello from a custom element</p>`;
}
}
customElements.define('user-card', UserCard);This code creates a new HTML tag named <user-card>. When the element appears in the document, connectedCallback() runs and fills it with content.
Use the custom element in HTML
After registration, you can use the element like any other tag.
<user-card></user-card>The browser treats it as a real element, not a plain div with a special class name.
Attach Shadow DOM
Shadow DOM gives the component its own private DOM tree and style boundary. This keeps component styles from leaking out and outside styles from accidentally affecting the component.
class UserCard extends HTMLElement {
connectedCallback() {
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `<style>p { color: rebeccapurple; }</style><p>Shadow content</p>`;
}
}The component now renders inside its own shadow tree, with styles scoped to that tree.
4. Step-by-Step Examples
Example 1: A simple greeting component
This example shows the smallest useful custom element. It renders text based on an attribute.
class GreetingTag extends HTMLElement {
connectedCallback() {
const name = this.getAttribute('name') ?? 'friend';
this.textContent = `Hello, ${name}!`;
}
}
customElements.define('greeting-tag', GreetingTag);Use it like this: <greeting-tag name="Ava"></greeting-tag>. The component reads its attribute and updates its own text.
Example 2: Rendering with Shadow DOM
This example adds encapsulation so the component can own its styles.
class BadgeLabel extends HTMLElement {
constructor() {
super();
this.shadowRoot ??= this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.shadowRoot.innerHTML = `style
span { background: #222; color: white; padding: 0.25rem 0.5rem; border-radius: 999px; }
</style>
<span>New</span>`;
}
}
customElements.define('badge-label', BadgeLabel);Now the badge styles are isolated from the rest of the page, which reduces CSS conflicts.
Example 3: Reacting to attribute changes
Many components need to update when attributes change. Use observedAttributes and attributeChangedCallback().
class PriceTag extends HTMLElement {
static get observedAttributes() {
return ['value'];
}
connectedCallback() {
this.render();
}
attributeChangedCallback(name, oldValue, newValue) {
if (name === 'value' && oldValue !== newValue) {
this.render();
}
}
render() {
this.textContent = `${Number(this.getAttribute('value') ?? 0).toFixed(2)}`;
}
}
customElements.define('price-tag', PriceTag);This component updates whenever its value attribute changes, so it stays in sync with the DOM.
Example 4: Passing content with slots
Slots let consumers place custom content inside a component without breaking encapsulation.
class PanelBox extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `<style>
.box { border: 1px solid #ccc; padding: 1rem; border-radius: 0.5rem; }
header { font-weight: 600; margin-bottom: 0.5rem; }
</style>
<div class="box">
<header><slot name="title">Default title</slot></header>
<slot>Default body</slot>
</div>`;
}
}
customElements.define('panel-box', PanelBox);Consumers can now write title and body content in the light DOM, and the component decides where each piece appears.
5. Practical Use Cases
- Reusable design system elements such as buttons, alerts, cards, and badges.
- Embedding a product widget, like a pricing box or review panel, on multiple pages.
- Sharing a UI element across teams without coupling it to a specific framework.
- Building widgets for CMS-driven sites where authors place custom tags in content.
- Creating self-contained controls that need isolated styling, such as a date picker shell or media player container.
6. Common Mistakes
Mistake 1: Defining the custom element too late
If the browser sees the element before it is registered, it may render as an unknown tag until the definition loads. This often happens when the script runs after the page content without upgrading existing elements in time.
Problem: The element exists in the HTML, but the browser does not yet know how to construct it because customElements.define() has not run.
<user-card></user-card>
class UserCard extends HTMLElement {
connectedCallback() {
this.textContent = 'Loaded';
}
}Fix: Register the element before you expect it to appear, or use a deferred module script so the definition loads consistently.
class UserCard extends HTMLElement {
connectedCallback() {
this.textContent = 'Loaded';
}
}
customElements.define('user-card', UserCard);
<user-card></user-card>The corrected version works because the browser can upgrade the element as soon as it encounters the registered tag.
Mistake 2: Calling attachShadow() more than once
A shadow root can be attached only once per element. Reattaching it causes a runtime error and breaks initialization logic.
Problem: Calling attachShadow() repeatedly throws because the element already has a shadow tree.
class InfoBox extends HTMLElement {
connectedCallback() {
this.attachShadow({ mode: 'open' });
this.attachShadow({ mode: 'open' });
}
}Fix: Create the shadow root once, usually in the constructor, and reuse the stored reference afterward.
class InfoBox extends HTMLElement {
constructor() {
super();
this.shadowRootRef = this.attachShadow({ mode: 'open' });
}
}The fixed version avoids the shadow-root error by keeping the component initialization one-time only.
Mistake 3: Forgetting that attributes are strings
Attributes look like typed values, but the DOM stores them as strings. If you use them in arithmetic without conversion, you can get unexpected results.
Problem: The count attribute is a string, so adding 1 can concatenate instead of incrementing.
class CounterBadge extends HTMLElement {
connectedCallback() {
const count = this.getAttribute('count');
this.textContent = count + 1;
}
}Fix: Convert the attribute to a number before using it as one.
class CounterBadge extends HTMLElement {
connectedCallback() {
const count = Number(this.getAttribute('count') ?? 0);
this.textContent = String(count + 1);
}
}The fixed version works because the component explicitly converts the attribute before using arithmetic.
7. Best Practices
Practice 1: Use the constructor for one-time setup
Attach the shadow root and prepare permanent state in the constructor, then use lifecycle callbacks for DOM-dependent work.
class ToastMessage extends HTMLElement {
constructor() {
super();
this.shadowRootRef = this.attachShadow({ mode: 'open' });
}
}This pattern makes initialization predictable and avoids accidental re-creation during reconnection.
Practice 2: Reflect only the attributes that matter
Not every internal state value should become an attribute. Expose only the parts that users of the component need to control from HTML.
class AlertBox extends HTMLElement {
static get observedAttributes() {
return ['type'];
}
}Keeping the public API small makes the component easier to document and less fragile.
Practice 3: Prefer slots over string concatenation for user content
Slots let callers pass markup safely and keep the component flexible. They are usually better than manually injecting HTML strings for content that belongs to the caller.
class InfoCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
}Using slots keeps content ownership clear and reduces the risk of breaking markup structure.
8. Limitations and Edge Cases
- Custom element names must contain a hyphen, such as my-button; button is not allowed.
- You can only register a custom element name once. Defining the same name twice throws an error.
- Shadow DOM creates style encapsulation, but inherited properties and CSS custom properties can still cross the boundary in predictable ways.
- Some browser features such as searching, accessibility tree behavior, and form association may require extra care when you build complex components.
- Not every browser quirk behaves the same inside Shadow DOM, so test keyboard focus, events, and styling carefully.
One common issue is assuming that Shadow DOM makes a component completely invisible to the outside world. Events still bubble, and consumers can interact with the component through its public API, attributes, and custom events.
9. Practical Mini Project
Let’s build a small reusable notification card that can show a title, body, and action area. It uses Shadow DOM for styling and slots for flexible content.
class NoticeCard extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `<style>
.card { border: 1px solid #d0d7de; border-radius: 0.75rem; padding: 1rem; font-family: system-ui, sans-serif; }
.title { font-size: 1.1rem; font-weight: 700; margin: 0 0 0.5rem; }
.body { margin: 0 0 0.75rem; }
.actions { display: flex; gap: 0.5rem; }
</style>
<div class="card">
<div class="title"><slot name="title">Notice</slot></div>
<div class="body"><slot>Default message</slot></div>
<div class="actions"><slot name="actions"></slot></div>
</div>`;
}
}
customElements.define('notice-card', NoticeCard);You can use it like this:
<notice-card>
<span slot="title">Server status</span>
The deployment finished successfully.
<button slot="actions">View details</button>
</notice-card>This project shows how Web Components combine encapsulated styling with flexible consumer content. The same component can be dropped into many pages without duplicating markup.
10. Key Points
- Web Components are browser standards for reusable custom HTML elements.
- Custom Elements define behavior, Shadow DOM isolates structure and styles, and slots accept external content.
- Attributes are strings, so convert values when you need numbers or booleans.
- Register elements once and initialize shadow roots only once.
- Slots are the preferred way to expose flexible content areas.
11. Practice Exercise
Create a reusable <temperature-chip> component with these requirements:
- It accepts a value attribute.
- It displays the value followed by °C if the value is 20 or higher, otherwise it shows a cooler label.
- It uses Shadow DOM so its styles do not leak out.
- It updates when the value attribute changes.
Expected output: a small chip such as 18°C cool or 24°C warm.
Hint: Use observedAttributes, convert the attribute to a number, and rerender from a shared method.
Solution:
class TemperatureChip extends HTMLElement {
static get observedAttributes() {
return ['value'];
}
constructor() {
super();
this.shadowRootRef = this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
}
attributeChangedCallback() {
this.render();
}
render() {
const temp = Number(this.getAttribute('value') ?? 0);
const label = temp >= 20 ? 'warm' : 'cool';
this.shadowRootRef.innerHTML = `<style>
.chip { display: inline-block; padding: 0.35rem 0.7rem; border-radius: 999px; background: #f3f4f6; font-family: system-ui, sans-serif; }
</style>
<span class="chip">${temp}°C ${label}</span>`;
}
}
customElements.define('temperature-chip', TemperatureChip);This solution meets all four requirements: it creates a custom tag, keeps styles isolated, reacts to changes, and converts the attribute to a number before using it.
12. Final Summary
Web Components are a browser-native way to build reusable UI with standard JavaScript, HTML, and CSS. The core pieces are custom elements for behavior, Shadow DOM for encapsulation, and slots for flexible content projection.
They are a strong choice when you want components that work outside a specific framework, especially for design systems, cross-project widgets, and long-lived UI primitives. The main things to remember are to register elements correctly, manage shadow roots carefully, and treat attributes as string inputs that often need conversion.
If you want to keep learning, the best next step is to study custom element lifecycle callbacks in more depth and then build a small component library of your own.