JavaScript Web Components Basics: Custom Elements, Shadow DOM, and Slots
Web Components are the browser's built-in way to define reusable, framework-agnostic custom HTML elements. A <user-card> element you write in plain JS works in React, Vue, Angular, vanilla HTML, or no app framework at all. No build step required, no peer dependencies, no virtual DOM.
This lesson covers the three pieces that make up Web Components — Custom Elements, Shadow DOM, and <template>/<slot> — and the patterns for shipping components that interop with everything.
The three building blocks #
- Custom Elements — a JS API to define new HTML tags with custom behavior.
- Shadow DOM — a way to give an element its own isolated subtree of DOM and CSS.
<template>and<slot>— declarative HTML for the content the element renders.
Each can be used independently. Combined, they're "Web Components."
A minimal custom element #
class HelloWorld extends HTMLElement {
connectedCallback() {
this.textContent = 'Hello, ' + (this.getAttribute('name') || 'world');
}
}
customElements.define('hello-world', HelloWorld);
<hello-world name="Ada"></hello-world>
<hello-world></hello-world>
Rendered output:
Hello, Ada
Hello, world
Key rules:
- The tag name must contain a hyphen (
hello-world,user-card). Prevents collisions with future native HTML elements. - The class must extend
HTMLElement(or another HTMLElement subclass likeHTMLButtonElementfor autonomous-vs-customized elements — niche). customElements.define(name, ClassRef)registers it globally for the page.
Lifecycle callbacks #
Four lifecycle methods the browser calls automatically:
| Method | Fires when |
|---|---|
constructor() |
The element is created (NOT inserted yet). Don't access DOM attributes here. |
connectedCallback() |
The element is inserted into the DOM. |
disconnectedCallback() |
The element is removed from the DOM. |
attributeChangedCallback(name, oldVal, newVal) |
An observed attribute changed. |
To observe attribute changes, also define static observedAttributes:
class UserCard extends HTMLElement {
static observedAttributes = ['name', 'role'];
attributeChangedCallback(name, oldVal, newVal) {
if (oldVal === newVal) return;
this.render();
}
connectedCallback() {
this.render();
}
render() {
this.textContent = `${this.getAttribute('name')} (${this.getAttribute('role')})`;
}
}
customElements.define('user-card', UserCard);
The connectedCallback is the right place for initial setup. disconnectedCallback is where you clean up event listeners, observers, timers — anything that would leak if the element gets removed.
Properties vs attributes #
Like native elements (<input> has both a value attribute AND a value property), custom elements typically support both:
class UserCard extends HTMLElement {
get user() { return this._user; }
set user(v) {
this._user = v;
this.render();
}
connectedCallback() { this.render(); }
render() { this.textContent = this.user?.name ?? ''; }
}
const card = document.querySelector('user-card');
card.user = { name: 'Ada', age: 36 }; // works with objects, not just strings
Attributes are strings; properties can be anything. Frameworks like React (in v19+) and modern Lit set properties when the value isn't a primitive.
Shadow DOM #
The encapsulation primitive. Attaches a private subtree of DOM to an element — CSS inside doesn't leak out; CSS outside doesn't leak in.
class Card extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = `
<style>
.card { padding: 16px; border: 1px solid #ccc; border-radius: 8px; }
h2 { margin: 0; }
</style>
<div class="card">
<h2><slot name="title"></slot></h2>
<p><slot></slot></p>
</div>
`;
}
}
customElements.define('user-card', Card);
<user-card>
<span slot="title">Ada Lovelace</span>
Mathematician and writer.
</user-card>
Three shadow-DOM concepts:
attachShadow({ mode: 'open' })creates the shadow root. Mode'closed'makes it inaccessible from outside (rarely useful).<slot>is where content from the light DOM (the children you put between the tags) appears. Multiple named slots + one default slot.- CSS in the shadow root doesn't escape. Your
h2style only affects theh2inside this<user-card>.
:host for the element itself #
Inside the shadow root, the custom element itself is :host:
:host {
display: block;
background: white;
}
:host([active]) {
background: yellow;
}
:host(.highlighted) {
outline: 2px solid blue;
}
Useful for default styling and reacting to attributes/classes from outside.
Crossing the boundary: CSS custom properties #
Shadow DOM blocks most styles, but CSS custom properties (variables) pierce through:
<style>
user-card { --bg: lavender; }
</style>
<!-- inside shadow root -->
<style>
.card { background: var(--bg, white); }
</style>
This is the standard pattern for theming Web Components — expose --* variables, consumers set them.
::part for selective styling #
Authors can selectively expose internal elements with part="...":
<!-- shadow root -->
<button part="button">Click me</button>
/* outside */
my-widget::part(button) {
background: hotpink;
}
More explicit than CSS variables — and works with hover states, focus rings, etc.
Templates #
For static markup, <template> lets you define HTML that doesn't render until used:
<template id="card-template">
<style>...</style>
<div class="card">
<h2><slot name="title"></slot></h2>
</div>
</template>
class Card extends HTMLElement {
constructor() {
super();
const tpl = document.getElementById('card-template');
this.attachShadow({ mode: 'open' }).append(tpl.content.cloneNode(true));
}
}
Faster than parsing innerHTML on every constructor call. Useful when you have many instances. Modern setups often skip the <template> element and write the markup as a JS template literal — simpler if you have a build step.
Without Shadow DOM #
You can use Custom Elements without Shadow DOM — just custom behavior on a normal element. Sometimes that's enough:
class FlashOnClick extends HTMLElement {
connectedCallback() {
this.addEventListener('click', () => {
this.classList.add('flash');
setTimeout(() => this.classList.remove('flash'), 500);
});
}
}
customElements.define('flash-on-click', FlashOnClick);
Light-DOM Custom Elements integrate easily with existing CSS — and they're what's needed for accessibility and framework integration in many cases.
Use Shadow DOM when you need style isolation (a widget library shipping CSS that must not collide). Skip it when the component is part of an app you control.
Lit and other helpers #
Writing Web Components by hand involves boilerplate — observed-attribute lists, attribute-to-property reflection, render scheduling. The Lit library wraps Web Components with a clean reactive API:
import { LitElement, html, css } from 'lit';
class UserCard extends LitElement {
static properties = { name: {}, role: {} };
static styles = css`
.card { padding: 16px; border: 1px solid #ccc; border-radius: 8px; }
`;
render() {
return html`<div class="card">${this.name} (${this.role})</div>`;
}
}
customElements.define('user-card', UserCard);
5 KB gzipped. Used widely (Google, Adobe). For production Web Components, Lit is the modern default.
Alternatives: Stencil, FAST, Hybrids. All compile down to standard Custom Elements.
Interop with frameworks #
Web Components work everywhere because they're just HTML elements:
- Vanilla HTML: just use the tag.
- React 19+: native support for Custom Elements, properties, and events.
- Vue / Angular / Svelte / Solid: all support Custom Elements natively, with some minor configuration for property-vs-attribute binding.
This is the killer use case for Web Components: design system libraries that ship once, work everywhere. You don't write a React-specific Button and a Vue-specific Button — you ship <my-button> and every framework consumes it.
When to use Web Components #
Yes:
- Cross-framework design systems
- Embeddable widgets (chat widgets, video players, payment forms)
- Long-lived libraries that should outlive any one framework
- CMS-embeddable components
Not the right tool:
- A single-framework app where you're already invested in React/Vue/Svelte's component model
- Anything that needs deep SSR integration (Web Components SSR is improving but still rough in 2026)
- Components that need framework-specific features (Vue's reactivity, React's hooks, Svelte's stores)
A summary #
- Custom Elements — define new HTML tags with custom JS behavior. Tag name must have a hyphen.
- Lifecycle callbacks:
constructor,connectedCallback,disconnectedCallback,attributeChangedCallback. - Shadow DOM — private DOM + CSS scope per element. Use
:host, slots, and CSS custom properties. <template>and<slot>for declarative markup and content projection.- Lit is the practical helper library — strips the boilerplate, ~5 KB.
- Works in every framework because they're just HTML elements.
- Best for: design systems, embeddable widgets, framework-agnostic libraries.
What's next #
Lesson 11.4 covers Service Workers — the persistent background worker that powers offline PWAs, caching strategies, and push notifications.
Try it yourself #
The smallest useful Web Component — a click counter — in 12 lines:
class ClickCounter extends HTMLElement {
count = 0;
connectedCallback() {
this.innerHTML = '<button>0</button>';
this.querySelector('button').addEventListener('click', () => {
this.querySelector('button').textContent = ++this.count;
});
}
}
customElements.define('click-counter', ClickCounter);browser_sandboxThe button shows 1, then 2.That’s a fully working component in 12 lines — no React, no Vue, no build step.
<click-counter></click-counter> works anywhere. Drop it in a Markdown blog post, a Vue app, an old WordPress site — same code, same behavior.Once you add Shadow DOM for style isolation and slots for content projection, you have a reusable design-system component.
The whole pitch of Web Components in 12 lines: same code, every framework, every page.
Up next in JavaScript
More from this topic
Enjoyed this article?
Get new JavaScript tutorials delivered. No spam — just code-first articles when they ship.


