Web Components — Custom Elements, Shadow DOM & Templates
A practical guide to Web Components: creating reusable custom elements, encapsulating styles with Shadow DOM, and composing with HTML templates.
Overview
Web Components are a set of browser-native APIs for creating reusable, encapsulated HTML elements. They work in any framework — or without one — and provide true style and DOM encapsulation via Shadow DOM. Here is a hands-on guide to the three core technologies: Custom Elements, Shadow DOM, and HTML Templates, with practical examples you can use today.
When to Use
- You need reusable UI elements shared across different projects or frameworks
- You want style encapsulation without CSS-in-JS or BEM naming conventions
- You’re building a design system that must work in React, Vue, Angular, or vanilla JS
- You need to extend native HTML elements with custom behavior
- You want framework-independent components for long-term maintainability
Web Components also shine inside island-style architectures, where small interactive units hydrate independently — the islands architecture pattern pairs naturally with custom elements as the unit of encapsulation.
When NOT to Use
- The whole app lives in one framework and ships as a single bundle — framework components are simpler, with better state management and TypeScript integration
- You need rich accessibility for free — a
<select>or<dialog>reimplemented as a custom element loses native keyboard and screen-reader behavior you then have to rebuild - The component must render before JavaScript runs — autonomous custom elements need JS to upgrade, unless you ship declarative Shadow DOM (server-rendered)
- SEO-critical content lives inside the shadow root — search engines handle shadow DOM inconsistently, so keep indexable copy in the light DOM or use slots
The honest trade-off: Web Components give you portability and longevity (the API is the platform, it won’t be deprecated next year), at the cost of doing more plumbing yourself. If your team already shares a React design system, wrapping it in custom elements adds a boundary without buying interoperability.
The Three Technologies
| Technology | Purpose | Standard |
|---|---|---|
| Custom Elements | Define new HTML tags with JavaScript | Custom Elements v1 |
| Shadow DOM | Encapsulate DOM and styles inside a component | Shadow DOM v1 |
| HTML Templates | Declare reusable markup fragments | HTML Template Element |
Custom Elements
Autonomous Custom Elements
Create entirely new HTML tags.
class UserCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
}
render() {
const name = this.getAttribute('name') || 'Anonymous';
const role = this.getAttribute('role') || 'User';
this.shadowRoot.innerHTML = `
<style>
:host { display: block; padding: 1rem; border: 1px solid #e5e7eb; border-radius: 0.5rem; }
.name { font-weight: 600; color: #111827; }
.role { color: #6b7280; font-size: 0.875rem; }
</style>
<div class="name">${name}</div>
<div class="role">${role}</div>
`;
}
static get observedAttributes() {
return ['name', 'role'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue !== newValue) this.render();
}
}
customElements.define('user-card', UserCard);
<!-- Usage -->
<user-card name="Alice Chen" role="Senior Engineer"></user-card>
<user-card name="Bob Smith" role="Product Manager"></user-card>
Customized Built-in Elements
Extend existing HTML elements with new behavior.
class ConfirmButton extends HTMLButtonElement {
constructor() {
super();
this.addEventListener('click', (e) => {
if (!confirm(this.getAttribute('confirm-message') || 'Are you sure?')) {
e.preventDefault();
}
});
}
}
customElements.define('confirm-button', ConfirmButton, { extends: 'button' });
<!-- Usage via is="" attribute -->
<button is="confirm-button" confirm-message="Delete this file permanently?">
Delete
</button>
Attributes vs Properties
Attributes are strings in markup; properties are JavaScript values on the element instance. Consumers will use both, so a well-behaved component keeps them in sync — the pattern is called reflecting:
class ToggleSwitch extends HTMLElement {
static get observedAttributes() {
return ['checked'];
}
// Property API — accepts real JS values
get checked() {
return this.hasAttribute('checked');
}
set checked(value) {
if (value) this.setAttribute('checked', '');
else this.removeAttribute('checked');
}
attributeChangedCallback() {
this.shadowRoot.querySelector('input').checked = this.checked;
this.shadowRoot.host.setAttribute('aria-checked', String(this.checked));
}
}
Reflect state to attributes only when it’s meaningful in markup (it selects well in CSS and shows up in DevTools). Rich data — objects, callbacks, lists — belongs in properties only; stuffing JSON into attributes is a debugging trap.
Shadow DOM
Encapsulation
Shadow DOM isolates a component’s DOM and CSS from the rest of the page.
class StyledCounter extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>
/* Scoped to this component only */
button {
background: #3b82f6;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 0.25rem;
cursor: pointer;
}
button:hover { background: #2563eb; }
span { margin-left: 0.5rem; font-weight: 600; }
</style>
<button id="inc">+</button>
<span id="count">0</span>
`;
this.count = 0;
shadow.getElementById('inc').addEventListener('click', () => {
this.count++;
shadow.getElementById('count').textContent = this.count;
});
}
}
customElements.define('styled-counter', StyledCounter);
Slots
Slots let users inject content into a component’s shadow DOM.
class AlertBox extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' }).innerHTML = `
<style>
:host { display: block; padding: 1rem; border-radius: 0.5rem; }
:host([type="error"]) { background: #fef2f2; border: 1px solid #fca5a5; }
:host([type="warning"]) { background: #fffbeb; border: 1px solid #fcd34d; }
:host([type="success"]) { background: #f0fdf4; border: 1px solid #86efac; }
::slotted(h3) { margin: 0 0 0.5rem; font-size: 1rem; }
::slotted(p) { margin: 0; color: #4b5563; }
</style>
<slot name="title"></slot>
<slot></slot>
`;
}
}
customElements.define('alert-box', AlertBox);
<!-- Usage with named slots -->
<alert-box type="error">
<h3 slot="title">Connection Failed</h3>
<p>Unable to reach the server. Please check your network and try again.</p>
</alert-box>
Declarative Shadow DOM
Attach a shadow root without JavaScript by putting a <template shadowrootmode> inside the element’s HTML. The browser upgrades it into a real shadow root before the element’s class even runs — which means the markup is already there for SSR, no-JS fallbacks, and faster first paint:
<alert-box type="error">
<template shadowrootmode="open">
<style>
:host { display: block; padding: 1rem; }
::slotted(h3) { margin: 0 0 0.5rem; }
</style>
<slot name="title"></slot>
<slot></slot>
</template>
<h3 slot="title">Connection Failed</h3>
<p>Unable to reach the server.</p>
</alert-box>
Declarative Shadow DOM is supported in Chrome/Edge 111+, Safari 16.4+, and Firefox 123+. In your class, check this.shadowRoot before calling attachShadow — the root already exists when the element was declaratively defined.
HTML Templates
Templates declare reusable markup that is not rendered until cloned.
<template id="user-row-template">
<tr>
<td class="name"></td>
<td class="email"></td>
<td><button class="delete">Remove</button></td>
</tr>
</template>
function createUserRow(user) {
const template = document.getElementById('user-row-template');
const clone = template.content.cloneNode(true);
clone.querySelector('.name').textContent = user.name;
clone.querySelector('.email').textContent = user.email;
clone.querySelector('.delete').addEventListener('click', () => removeUser(user.id));
return clone;
}
// Append to table
document.querySelector('#users tbody').appendChild(createUserRow({
name: 'Alice Chen',
email: 'alice@example.com'
}));
Communicating Between Components
Attributes pass data down; custom events pass it up. The detail most people miss: an event dispatched inside a shadow root stops at the shadow boundary unless you set composed: true.
class UserCard extends HTMLElement {
select() {
this.dispatchEvent(new CustomEvent('user-selected', {
detail: { userId: this.getAttribute('user-id') },
bubbles: true, // let it travel up the tree
composed: true // REQUIRED to escape the shadow root
}));
}
}
// Any ancestor — outside the shadow DOM — can listen:
document.addEventListener('user-selected', (e) => {
console.log('Selected user:', e.detail.userId);
});
The convention is DOM events for parent-to-child-ish communication and attributes/properties for the rest. Avoid a shared global event bus for component internals — it leaks the component’s API surface across the whole app.
Form-Associated Custom Elements
A plain custom element can’t participate in a <form> — it won’t submit a value or validate. ElementInternals fixes that. Opt in with static formAssociated = true, then use attachInternals() to set the value and validity the form sees:
class RatingInput extends HTMLElement {
static formAssociated = true;
constructor() {
super();
this.internals = this.attachInternals();
this.attachShadow({ mode: 'open' }).innerHTML = `
<div id="stars">★★★★★</div>
`;
this.value = 0;
}
connectedCallback() {
const stars = [...this.shadowRoot.querySelector('#stars').childNodes];
this.shadowRoot.getElementById('stars').addEventListener('click', (e) => {
this.value = Math.ceil(e.offsetX / (e.target.offsetWidth / 5));
this.internals.setFormValue(String(this.value));
this.internals.setValidity(
this.value > 0 ? {} : { customError: true },
'Pick a rating'
);
});
}
}
customElements.define('rating-input', RatingInput);
<form>
<rating-input name="rating" required></rating-input>
<button type="submit">Send</button>
</form>
Now <rating-input> behaves like a native control: its value travels with the form submission, form.reportValidity() shows your message, and form.reset() calls the formResetCallback() lifecycle hook if you implement it.
Theming with CSS Custom Properties
Shadow DOM blocks external styles — but custom properties pierce the boundary. That’s the intended theming API: expose a small set of variables and let consumers override them from the outside.
// inside the component's shadow styles
`
:host {
display: block;
padding: var(--card-padding, 1rem);
border: 1px solid var(--card-border, #e5e7eb);
border-radius: 0.5rem;
}
.title { color: var(--card-title-color, #111827); }
`
/* consumer page — applies through the shadow boundary */
user-card {
--card-title-color: #1d4ed8;
}
.dark-section user-card {
--card-title-color: #e5e7eb;
--card-border: #374151;
}
Publish the variable names as part of the component’s API, the same way you’d document props. Resist the urge to expose ::part() for everything — variables scale better for design-token systems.
Component Lifecycle
| Callback | When It Fires | Common Use |
|---|---|---|
constructor() | Element created | Initialize state, attach shadow root |
connectedCallback() | Inserted into DOM | Render, fetch data, add event listeners |
disconnectedCallback() | Removed from DOM | Clean up timers, event listeners, subscriptions |
attributeChangedCallback() | Observed attribute changes | Re-render, validate, update internal state |
adoptedCallback() | Moved to new document | Rarely used; cleanup and re-initialize |
Two traps around the lifecycle: connectedCallback fires every time the element is (re)inserted — moving a node in the DOM triggers it — so don’t assume it runs once; and attributeChangedCallback can fire before connectedCallback for attributes present in the initial markup, so guard rendering code against a not-yet-connected element.
Framework Integration
Web Components work in any framework.
// React
import 'my-ui-library';
function App() {
return (
<div>
<user-card name="Alice" role="Engineer" />
<alert-box type="success">
<h3 slot="title">Saved</h3>
<p>Your changes have been saved.</p>
</alert-box>
</div>
);
}
<!-- Vue -->
<template>
<user-card :name="user.name" :role="user.role" />
</template>
<script>
import 'my-ui-library';
export default { props: ['user'] };
</script>
Two integration details bite in practice. React < 19 passes everything to custom elements as attributes — objects, arrays, and function props get stringified — and doesn’t bind onXyz handlers to CustomEvents. React 19 fixed both behaviors; on older versions you either set properties via a ref or wrap the element in a thin React component. The second issue is slots: frameworks that render children into your element (React, Vue) work fine because the slotted nodes are light DOM, but the wrapper must preserve the slot="name" attribute on them.
SSR and Hydration
Server-rendering custom elements is the story’s weakest chapter. The element’s shadow DOM exists only after customElements.define runs — there’s no client-side “hydration” of shadow roots the way React hydrates a DOM tree. The two workable paths:
- Declarative Shadow DOM: your SSR pipeline serializes each element with a
<template shadowrootmode>child (Lit SSR,@lit-labs/ssr, and Eleventy’s@11ty/is-landdo this). The browser upgrades the markup into real shadow roots before scripts run, so nothing flashes and nothing “hydrates” — the element just wakes up already rendered. - Client-only islands: render a placeholder server-side and let the element upgrade on the client. Simple, but you get a flash of unupgraded markup and lost crawlability.
Framework SSR (Next.js, Nuxt) doesn’t emit declarative shadow DOM for custom elements today, so components inside framework pages follow path 2 unless you post-process the HTML. If SSR correctness matters, render the components’ markup yourself or pick a library (Lit) with first-class DSD support.
Accessibility Considerations
Shadow DOM changes the accessibility rules in two places that are easy to miss. First, ARIA references can’t cross the shadow boundary — an aria-labelledby inside the shadow root can’t point to an element in the light DOM, and vice versa. If a label must live outside, mirror it with aria-label on the host or use ElementInternals’ ariaLabel/role properties (the form-associated example shows how internals is the bridge for semantics too).
Second, focus doesn’t traverse the boundary by default. attachShadow({ mode: 'open', delegatesFocus: true }) forwards focus into the shadow tree so keyboard users can reach the interactive parts; without it, tabbing skips straight past your component. Test tab order manually — it’s the fastest way to catch a component that’s focus-invisible.
Everything else is the usual discipline: give the host a meaningful role if it behaves like a widget, keep visible labels instead of title tooltips, and check contrast for slotted content your styles don’t control. The WCAG guide covers the baseline criteria your components still have to meet — shadow DOM doesn’t exempt anyone.
Testing Web Components
Unit-test custom elements like any other class — but render them in a real browser, because customElements.define and attachShadow don’t exist in jsdom’s default setup. Web Test Runner runs the tests in an actual browser:
import { fixture, expect } from '@open-wc/testing';
import '../user-card.js';
describe('user-card', () => {
it('renders the name attribute', async () => {
const el = await fixture('<user-card name="Ada"></user-card>');
const name = el.shadowRoot.querySelector('.name').textContent;
expect(name).to.equal('Ada');
});
it('re-renders when an observed attribute changes', async () => {
const el = await fixture('<user-card name="Ada"></user-card>');
el.setAttribute('name', 'Grace');
expect(el.shadowRoot.querySelector('.name').textContent).to.equal('Grace');
});
});
For E2E, Playwright locators pierce open shadow roots automatically — page.getByRole('button') finds buttons inside your component without any special syntax, which is one of the nicest surprises when migrating tests from framework components.
All the components from this guide — user-card, alert-box, and a form-associated rating-input, plus a demo page and runnable @open-wc tests — live in the companion repo. Clone it and serve the folder statically; there’s no build step.
Browser Support and Polyfills
Everything in this guide is baseline-available in all evergreen browsers. The sharp edges are: customized built-in elements (is=""), which Safari refuses to implement — treat them as progressive enhancement, never as your primary API — and declarative Shadow DOM, which reached full support only in 2024 (Firefox 123+).
For real legacy support the @webcomponents/polyfills package covers Custom Elements, Shadow DOM, and templates back to IE11-era browsers, but shipping the polyfill costs ~60KB and is rarely worth it today — check your actual browser matrix first.
Common Mistakes
- Forgetting to handle attribute changes — observed attributes must trigger re-render
- Using
innerHTMLon the host element — always use Shadow DOM for component markup - Not cleaning up in
disconnectedCallback()— memory leaks from orphaned event listeners - Assuming
constructorruns after DOM insertion — it runs at creation; DOM may not exist yet - Missing polyfills for older browsers — Edge 18 and IE11 need the webcomponentsjs polyfill
Troubleshooting
attributeChangedCallbacknever fires: the attribute isn’t inobservedAttributes, or you’re setting a property instead of an attribute. Only listed attributes trigger the callback, andel.prop = xnever does — useel.setAttribute('prop', x)or implement a setter that reflects to an attribute.- Events inside the component never reach the parent: the listener sits outside the shadow boundary and the event wasn’t dispatched with
composed: true.bubblesalone isn’t enough — check the event flags in the dispatch call. ::slotted()styles don’t apply:::slotted()only matches the element assigned directly to the slot, not its descendants —::slotted(h3)works,::slotted(h3 span)never does. Style deeper nodes with CSS custom properties instead.- Component renders twice or loses state on re-render: the render method rebuilds
innerHTML, which destroys child listeners and focus. Diff and patch only what changed, or keep a light render-once + update-nodes pattern like the examples above. - Page styles leak into the component (or don’t): inheritance crosses the shadow boundary for inherited CSS properties (font, color) but not for element selectors. If a global
button { ... }styles your shadow button, you didn’t attach a shadow root; if a CSS variable doesn’t apply, it’s being set on the wrong ancestor. <form>ignores the custom element: you forgotstatic formAssociated = true— without it,attachInternals()throws and the element submits nothing.
Key Takeaways
- Attributes in,
composedevents out — that’s the whole public API surface of a well-built custom element. - Shadow DOM is what makes the component portable: style encapsulation is the feature, not an annoyance to bypass.
- Use CSS custom properties as the theming contract between the component and the page; document them like props.
- Skip customized built-in elements for anything user-facing — Safari won’t implement
is="", so autonomous elements plus declarative Shadow DOM cover more ground. - Test in a real browser (Web Test Runner or Playwright); jsdom’s shadow DOM support has too many gaps to be trustworthy.
Further Reading
- MDN — Web Components — the reference for Custom Elements, Shadow DOM, and templates.
- web.dev — Custom Elements v1 and Shadow DOM v1 — practical walkthroughs of both APIs.
- HTML Standard — Custom Elements — the WHATWG spec, for lifecycle details and edge cases.
- Open Web Components — guides and tooling recommendations for building WC libraries.
Frequently Asked Questions
Do Web Components replace React/Vue/Angular?
No — they complement them. Use Web Components for design-system elements shared across frameworks or for embeddable widgets; keep framework components for app-specific, state-heavy UI.
Can Web Components use external CSS frameworks?
Global styles don't enter the shadow root. Load the framework's CSS inside each shadow root (costly), or expose a styling API with CSS custom properties and ::part() — the second option is what design systems do.
How do I test Web Components?
Unit-test in a real browser with Web Test Runner and @open-wc/testing. For E2E, Playwright locators pierce open shadow roots, so getByRole and getByText just work.
Do search engines index content inside the shadow DOM?
Google renders JavaScript and flattens open shadow roots, but relying on that for indexable content is risky — keep SEO-critical markup in the light DOM or ship declarative Shadow DOM server-side.
What's the difference between an attribute and a property on a custom element?
Attributes are strings in the HTML (name="Ada"); properties are JS values on the instance (el.user = {...}). Rich data should arrive via properties; attributes are for configuration that belongs in markup.
Should I use closed shadow roots to protect internals?
No — mode: 'closed' hides element.shadowRoot, which breaks testing tools and accessibility workarounds without adding real security. Open roots are the ecosystem default.
Related Resources
WCAG 2.2 Accessibility: A Developer Guide
A practical guide to WCAG 2.2 compliance: the four POUR principles, ARIA, keyboard navigation, accessible forms and modals, tested with real code examples.
GuideProgressive Web Apps (PWA) — Complete Guide
A thorough guide to building Progressive Web Apps: service workers, offline support, Web App Manifest, push notifications, and installability.
PatternIslands Architecture: Partial Hydration for Faster Pages
Server-render the page as static HTML and hydrate only the interactive islands. Covers Astro client directives, partial hydration, shared state, and when islands beat a SPA.
GuideComplete Guide to Mobile Responsive Design
Build layouts that work on every screen. Mobile-first breakpoints, Grid, Flexbox, container queries, fluid type and responsive images.
PatternProgressive Enhancement
Build a functional HTML baseline and add CSS and JavaScript layers on top. Covers feature detection, graceful degradation, and no-JS fallbacks.
GuideComplete Guide to React Performance Optimization
Optimize React apps for speed. Covers memoization, virtualization, code splitting, bundle analysis, React Profiler, concurrent features, and Core Web Vitals.