HTML Web Components: Custom Elements & Shadow DOM Guide
Web components are the browser’s own answer to reusable UI: a set of standard technologies that let you define your own HTML elements, hide their internals behind real encapsulation, and reuse them on any page without a framework. If you have ever copied the same card markup, modal, or toggle switch across pages and then fixed the same bug in five places, web components are the native fix for that problem. This guide covers the two pieces that do the heavy lifting — custom elements and shadow DOM — with the template and slot mechanism that ties them together, complete copy-paste examples, and the mistakes that catch almost everyone the first time.
One framing note before we start. This guide goes beyond the <template> and <slot> elements themselves. If you want the template-and-slot foundations first, read the site’s earlier guide to mastering template and slot in HTML, and come back here: everything below assumes you know what a template is and that a slot is a placeholder for content supplied by the page. Here, templates and slots appear only as the supporting cast. The stars are custom elements, which give you new tags, and shadow DOM, which gives those tags private internals.
TL;DR / Quick Takeaways
- Web components are three standard technologies used together: custom elements, shadow DOM, and templates with slots. No framework or build step is required.
- A custom element is a JavaScript class registered with
customElements.define(). Its name must contain a hyphen, which is how the browser tells custom tags apart from future standard tags.- There are two kinds: autonomous custom elements, which extend
HTMLElementand stand alone, and customized built-in elements, which extend a standard element such as a paragraph. Safari does not plan to support customized built-in elements, so autonomous elements are the safe default.- Shadow DOM attaches a hidden, separate DOM tree to an element. Styles inside it do not leak out, and page styles do not leak in, except through deliberate channels: CSS custom properties, the
::part()selector, and slots.- A shadow root is either open or closed. Open is the right choice in almost every case: closed hides the internals from your own debugging tools and test code as well as from everyone else, and it is easy to work around anyway.
- The four lifecycle callbacks —
connectedCallback,disconnectedCallback,adoptedCallback, andattributeChangedCallback— are where a component reacts to being added, removed, moved, or reconfigured.- Never read attributes or add light-DOM children in the constructor. Do setup in the constructor, and do work that depends on attributes or children in
connectedCallback.- The standard pattern is: a
<template>holding the component’s markup and styles, cloned into an open shadow root inside the constructor, with slots for page-supplied content and attributes for configuration.
What Web Components Actually Are
1. Three standards, not one technology
The phrase “web components” does not name a single API. It names a combination of three standard technologies that the browser already implements. Custom elements let you define new HTML tags with JavaScript behaviour. Shadow DOM gives those tags an encapsulated internal tree with scoped styles. Templates and slots give you a way to write reusable markup once and fill in per-use content. Any one of them is useful alone — you can use a template without a custom element, and you can define a custom element without shadow DOM — but the combination is what makes a genuinely reusable component: a single tag that carries its own markup, styles, and behaviour, and cannot be broken by the page around it.
That combination is also what separates web components from a JavaScript framework’s component model. A framework component exists inside that framework’s runtime. A web component exists in the browser itself, so the same <profile-card> tag works in a plain HTML page, inside a React app, inside a Vue app, or in a server-rendered page with a small script. The trade is that the browser gives you less hand-holding: there is no built-in state management, no templating language, and no reactivity system. You get the primitives, and you build the rest.
2. Why encapsulation is the whole point
Without encapsulation, a reusable piece of markup is fragile. A page’s global stylesheet can restyle your component’s internals by accident, because ordinary CSS selectors match everything in the document. A page’s JavaScript can reach in and change your component’s nodes. Two copies of the same widget from different authors can clash on class names. Shadow DOM fixes this at the platform level: the shadow tree is a separate tree, and ordinary selectors from the page do not cross into it, while styles defined inside it do not leak out. That is why the MDN guidance puts it bluntly: without the encapsulation shadow DOM provides, custom elements would be impossibly fragile, because it would be too easy for a page to accidentally break a component’s behaviour or layout.
Encapsulation is not isolation in every sense, and the distinction matters. Shadow DOM scopes CSS and hides DOM structure, but it is not a security boundary and it is not JavaScript isolation. Scripts in the page can still call your component’s public methods and read its attributes, and events can cross the boundary when they are composed. Think of it as a firm fence with gates you control, not a vault. The gates are the subject of a later section: CSS custom properties, shadow parts, and slots.
3. What web components are not
Three misconceptions come up constantly, so let us clear them now. First, web components are not a replacement for a framework. If your application needs routing, complex state, and a large team working on shared screens, a framework still earns its place. Web components are best for leaf-level, reusable pieces: cards, toggles, tabs, dialogs, tooltips, and design-system primitives that should work everywhere. Second, web components are not only for design systems at big companies. A single author on a small site gets the same benefit: write the widget once, use it as a tag everywhere. Third, web components are not new or experimental. The core APIs are supported in all modern browsers, and browsers themselves have used shadow DOM internally for years — the controls inside a <video> element, for example, live in the browser’s own shadow DOM, which is why you see only the <video> tag in the document while the buttons exist somewhere inside it.
Custom Elements: Your Own HTML Tags
1. Autonomous elements and customized built-in elements
There are two kinds of custom element, and the choice between them is one of the few decisions in this guide with a clear default answer. Autonomous custom elements inherit from the base HTMLElement class and stand alone as their own tag, such as <profile-card>. Customized built-in elements inherit from a standard element, such as HTMLParagraphElement, and extend the behaviour of specific instances of that element, used in markup with the is attribute, such as <p is="word-count">. The catch is support: Safari does not plan to support customized built-in elements. That single fact makes autonomous elements the safe default for anything that must work everywhere, and this guide uses autonomous elements throughout. If you only ever target Chromium-based browsers, customized built-ins are available, but you are giving up Safari for no benefit in most cases.
2. The hyphen rule, and why it exists
Every custom element name must contain a hyphen, written in lowercase kebab-case: profile-card, toggle-switch, user-avatar. This is not a style preference; the browser enforces it, and customElements.define() will throw for a name without a hyphen. The reason is forward compatibility. Standard HTML element names never contain hyphens, so the hyphen guarantees that a custom name can never collide with a future standard tag. It also lets the HTML parser recognise a custom tag the moment it sees one. Two related rules follow from it. First, a custom element name must be globally unique across the page when registered on the global registry, because registration is by name. Second, once a name is defined, it cannot be redefined: calling define() twice with the same name throws. During development, that second rule is the usual cause of a confusing error after a hot reload, and the fix is to guard registration or to reload the page cleanly.
3. Defining an element with customElements.define()
Creating a custom element takes three steps, always in the same order. First, write a JavaScript class that extends HTMLElement (for an autonomous element) and implements the behaviour. Second, register it with customElements.define('profile-card', ProfileCard), which associates the tag name with the class. Third, use it in HTML exactly like a built-in tag, or create it with document.createElement('profile-card'). Registration should happen as early as practical, but there is a forgiving detail worth knowing: if the browser parses <profile-card> in the markup before the definition has run, it does not error. The element sits in the page as an unresolved, unupgraded element, and the moment define() runs, the browser upgrades it — constructing it and running its callbacks. That upgrade behaviour is what makes progressive enhancement possible: server-rendered custom tags work even before the script arrives, and they come alive when it does.
4. The four lifecycle callbacks
A custom element class can define four lifecycle callbacks, and these are where almost all component logic lives. connectedCallback runs each time the element is added to the document — including after being moved — and it is where you do work that needs the element to be in the page: reading attributes, fetching data, starting timers, and adding event listeners. disconnectedCallback runs each time the element is removed, and it is where you clean up: removing listeners, cancelling timers, and closing connections, so that removed components do not leak. adoptedCallback runs when the element is moved into a different document, which is rare in ordinary pages but happens with iframes and adoptNode. attributeChangedCallback runs whenever an observed attribute changes, and it only fires for attributes listed in the static observedAttributes getter. If an attribute is not listed there, changes to it will never trigger the callback, which is a common source of “my component ignores the attribute” bugs.
5. Attributes are the component’s public settings
Attributes are how a page configures a custom element declaratively, and they deserve to be treated as a small public API. The pattern is: list the attributes you care about in observedAttributes, reflect them into properties where it helps, and react in attributeChangedCallback. A frequent mistake is reading attributes in the constructor. At construction time, the parser may not have processed the element’s attributes yet, so values read there can be missing or stale. The reliable place is connectedCallback, which runs after the element is in the document with its attributes in place, and attributeChangedCallback for every later change. The same caution applies to children: do not add light-DOM children in the constructor, and do not assume children exist there. Read children and slots in connectedCallback, after the element is connected.
Shadow DOM: Real Encapsulation
1. Open and closed shadow roots
You attach a shadow root with element.attachShadow(), which takes an options object with a single option, mode, set to either 'open' or 'closed'. An open shadow root can be reached from page JavaScript through the element’s shadowRoot property, which is what you want for debugging, testing, and for any code that legitimately needs to inspect the component. A closed shadow root makes shadowRoot return null from the outside, so the internals are hidden even from your own page code. Browsers use closed roots for their built-in elements, which is why you cannot reach into a <video> element’s controls. For your own components, open is the right choice in almost every case. Closed mode does not provide real security, because it is fairly easy to work around, and it costs you your own debugging tools, your test suite’s ability to inspect the component, and any legitimate integration that needs to look inside. Choose open unless you have a specific, articulated reason to hide internals from your own code.
2. Attaching a shadow root, and what lives inside it
The shadow root is the root node of the shadow tree, and the element it is attached to is called the shadow host. Everything inside the shadow tree behaves like ordinary DOM: you can append children, set attributes, style nodes individually, or add a <style> element that styles the whole shadow tree. The difference is scope. None of the code or styles inside the shadow tree can affect anything outside it, and ordinary page styles and scripts do not reach in uninvited. A practical consequence is that you can use simple, generic class names inside a component — .title, .button, .card — without fear that the page’s .title rule will restyle your internals, or that your .card rule will restyle the page. That freedom alone removes a whole category of naming gymnastics, such as long prefixed class names, that teams otherwise invent to avoid collisions.
3. Slots: letting the page supply content
A component that cannot accept page content is a closed box, and most real components need to be open boxes in one specific way: they need to display content the page author provides. Slots are that mechanism. A <slot> element inside the shadow tree marks a place where the host’s light-DOM children are rendered. If the page writes <profile-card><span slot="name">Ayesha</span></profile-card>, and the shadow tree contains <slot name="name">, the span is rendered at that slot’s position, while remaining in the light DOM — it is projected, not moved. Named slots take children with a matching slot attribute; the default slot, written as a bare <slot>, takes children with no slot attribute. Fallback content inside the slot element itself is shown when the page supplies nothing for that slot, which is how you provide sensible defaults. Slots are also the reason the component pattern in this guide puts a <template> in the document: the template holds the shadow markup including its slots, and the component clones it into each instance’s shadow root.
4. Styling across the boundary, deliberately
Encapsulation blocks ordinary selectors, but a component that cannot be styled at all is unusable in a design system, so the platform provides three deliberate channels. First, CSS custom properties (variables) pierce the shadow boundary: a page can set --card-background: #f5f5f5 on the host, and the shadow tree can read var(--card-background) in its own styles. This is the standard theming channel, and it is the one to reach for first. Second, the ::part() selector lets the page style specific internal elements that the component author has explicitly exposed with the part attribute, such as <button part="action"> styled from outside as profile-card::part(action). Exposure is opt-in per element, which keeps the component’s internals private by default. Third, the :host selector lets the shadow tree’s own styles target the host element itself, including :host([checked]) for styling based on the host’s attributes, which is how the toggle example below styles its on-state. What does not cross the boundary is everything else: a page rule for profile-card p will not match paragraphs inside the shadow tree, and a rule inside the shadow tree for p will not touch the page’s paragraphs.
5. Sharing styles with adoptedStyleSheets, and declarative shadow DOM
Two newer facilities round out the styling story. adoptedStyleSheets lets you create a CSSStyleSheet once in JavaScript and share it across many shadow roots by assigning it to each root’s adoptedStyleSheets array, instead of duplicating a <style> element in every instance. That matters for components used hundreds of times on a page, where duplicated style elements add up. Separately, declarative shadow DOM lets you write the shadow tree directly in HTML, using a <template> with a shadowrootmode attribute, so the shadow content exists before any JavaScript runs. That is the server-rendering story for web components: the server can send the component’s internals as HTML, the browser attaches the shadow root during parsing, and the component is visually complete before the script arrives, with the script then upgrading behaviour on top. Both facilities are supported in all modern browsers, and declarative shadow DOM in particular is worth knowing about even if you do not use it yet, because it removes the “blank until JavaScript loads” objection to component-based markup.
Putting It Together: The Standard Component Pattern
1. Template, shadow root, class: the shape of every component
Nearly every well-built autonomous component follows the same shape, and learning it once means every component after the first is variation rather than invention. A <template> in the document holds the component’s internal markup and its <style> block. The class constructor calls this.attachShadow({ mode: 'open' }) and clones the template’s content into the shadow root. connectedCallback reads attributes and children and wires up behaviour. attributeChangedCallback keeps the rendering in sync when attributes change. disconnectedCallback removes listeners. Slots in the template mark where page content goes, and the part attribute marks which internals the page may style. If you can write that shape from memory, you can build most leaf-level components in an afternoon, because the remaining work is the specific markup and behaviour of each widget, not the plumbing.
2. When to skip shadow DOM
Shadow DOM is the default for components, but it is not mandatory, and there are honest cases for skipping it. If a component is essentially a behaviour attached to page markup — a character counter on a textarea, for example — a custom element without a shadow root, working directly on its light-DOM children, is simpler and lets page styles apply naturally. If a component must be deeply styled by the page in ways you cannot predict, and the theming channels above are not enough, light DOM keeps all styling in one place. And if you are enhancing existing server-rendered markup rather than owning the markup, a shadow root would hide that markup from rendering, which defeats the purpose. The rule of thumb is: use shadow DOM when the component owns its markup and should be protected from the page; skip it when the component enhances markup the page owns. Both are legitimate custom elements.
3. Accessibility is part of the component, not an afterthought
A custom element has no built-in semantics. A <div> with a click handler is not a button to a screen reader, and neither is a <toggle-switch> unless you say so. The component author owns the semantics: set the right role on the host, manage aria-checked or aria-expanded as state changes, make the host focusable with tabindex when it is interactive, and handle keyboard interaction (Space and Enter for something button-like, arrow keys for something list-like). The toggle example below shows this concretely: it carries role="switch", keeps aria-checked in sync with its checked attribute, and responds to Space and Enter. Test with a keyboard alone before shipping: if you cannot operate the component without a mouse, it is not finished. Also remember that content projected through slots keeps its own semantics — a heading slotted into a card is still a heading — so encourage page authors to slot real semantic markup rather than styled divs.
Complete Runnable Examples
Example 1: A profile card with shadow DOM and slots
This is a complete HTML document. Save it as a file and open it in a browser. It defines <profile-card>, a card that shows an avatar circle, a name, a role, and a bio supplied by the page through slots. The component’s styles live inside its shadow root, so the page’s own styles cannot restyle the card’s internals, and the card’s styles cannot leak onto the page. Change the page’s CSS for p and watch the card ignore it: that is encapsulation doing its job. The fallback content inside each slot appears if the page supplies nothing for that slot.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Web Components: Profile Card Example</title>
<style>
p { color: #b00020; font-family: Georgia, serif; }
</style>
</head>
<body>
<h1>Web Components: Profile Card Example</h1>
<p>This page paragraph is red and serif, but the card below ignores it.</p>
<template id="profile-card-template">
<style>
:host { display: block; max-width: 320px; font-family: system-ui, sans-serif; }
.card { border: 1px solid #ddd; border-radius: 12px; padding: 16px; background: var(--card-background, #ffffff); }
.top { display: flex; gap: 12px; align-items: center; }
.avatar { width: 48px; height: 48px; border-radius: 50%; background: #6c5ce7; color: #fff; display: flex; align-items: center; justify-content: center; font-size: 20px; font-weight: 700; }
.name { font-size: 18px; font-weight: 700; margin: 0; }
.role { font-size: 14px; color: #555; margin: 2px 0 0 0; }
.bio { font-size: 14px; line-height: 1.5; color: #333; }
</style>
<div class="card">
<div class="top">
<div class="avatar"><slot name="initials">?</slot></div>
<div>
<p class="name"><slot name="name">Unnamed</slot></p>
<p class="role"><slot name="role">No role given</slot></p>
</div>
</div>
<div class="bio"><slot>No bio supplied.</slot></div>
</div>
</template>
<profile-card>
<span slot="initials">AH</span>
<span slot="name">Ahmed Haseeb</span>
<span slot="role">Web Developer</span>
<span>Builds fast, semantic websites and writes about HTML.</span>
</profile-card>
<script>
class ProfileCard extends HTMLElement {
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
const template = document.getElementById('profile-card-template');
shadow.appendChild(template.content.cloneNode(true));
}
}
customElements.define('profile-card', ProfileCard);
</script>
</body>
</html>
Example 2: An accessible toggle switch with attributes and lifecycle callbacks
This second document is also complete. It defines <toggle-switch>, a switch that toggles when clicked or when Space or Enter is pressed while it is focused. Its checked attribute is the single source of truth: attributeChangedCallback keeps the visual state and aria-checked in sync whenever the attribute changes, whether the change came from a click, from page JavaScript, or from the markup itself. The host is focusable and carries role="switch", so it is operable by keyboard and announced correctly by screen readers. The styling for the on-state uses :host([checked]) inside the shadow tree, which is the standard way for shadow styles to react to the host’s attributes.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Web Components: Toggle Switch Example</title>
</head>
<body>
<h1>Web Components: Toggle Switch Example</h1>
<p>Click the switch, or focus it with Tab and press Space.</p>
<template id="toggle-switch-template">
<style>
:host { display: inline-block; outline: none; }
:host(:focus-visible) .track { outline: 2px solid #005fcc; outline-offset: 2px; }
.track { width: 56px; height: 30px; border-radius: 15px; background: #ccc; position: relative; cursor: pointer; transition: background 0.2s; }
.thumb { width: 24px; height: 24px; border-radius: 50%; background: #fff; position: absolute; top: 3px; left: 3px; transition: left 0.2s; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); }
:host([checked]) .track { background: #2ecc71; }
:host([checked]) .thumb { left: 29px; }
</style>
<div class="track"><div class="thumb"></div></div>
</template>
<toggle-switch id="demo-switch"></toggle-switch>
<p id="demo-state">State: off</p>
<script>
class ToggleSwitch extends HTMLElement {
static get observedAttributes() { return ['checked']; }
constructor() {
super();
const shadow = this.attachShadow({ mode: 'open' });
const template = document.getElementById('toggle-switch-template');
shadow.appendChild(template.content.cloneNode(true));
this._track = shadow.querySelector('.track');
this._onClick = () => { this.toggle(); };
this._onKeyDown = (event) => {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
this.toggle();
}
};
}
connectedCallback() {
this.setAttribute('role', 'switch');
this.setAttribute('tabindex', '0');
this._syncState();
this._track.addEventListener('click', this._onClick);
this.addEventListener('keydown', this._onKeyDown);
}
disconnectedCallback() {
this._track.removeEventListener('click', this._onClick);
this.removeEventListener('keydown', this._onKeyDown);
}
attributeChangedCallback() {
this._syncState();
}
_syncState() {
const on = this.hasAttribute('checked');
this.setAttribute('aria-checked', on ? 'true' : 'false');
}
toggle() {
if (this.hasAttribute('checked')) {
this.removeAttribute('checked');
} else {
this.setAttribute('checked', '');
}
this.dispatchEvent(new CustomEvent('change', { bubbles: true, composed: true }));
}
}
customElements.define('toggle-switch', ToggleSwitch);
const demo = document.getElementById('demo-switch');
const state = document.getElementById('demo-state');
demo.addEventListener('change', () => {
state.textContent = 'State: ' + (demo.hasAttribute('checked') ? 'on' : 'off');
});
</script>
</body>
</html>
Note one detail in the second example that is easy to miss: the change event is dispatched with composed: true. Events fired inside a shadow tree are retargeted at the host by default, and most do not escape the shadow tree at all unless they are composed. Setting composed: true lets the page listen for the component’s change event on the element itself, which is what the demo page does. If your component’s events seem to vanish, a missing composed: true is the first thing to check.
Common Web Components Mistakes
1. Forgetting the hyphen in the element name
The single most common first error is calling customElements.define('profilecard', ProfileCard) and getting an exception, because the name has no hyphen. The fix is always the same: rename to kebab-case with a hyphen, such as profile-card, everywhere the tag is used. Because the rule is enforced by the browser rather than by convention, it fails loudly rather than silently, which is a mercy: you find it immediately instead of shipping a component that never upgrades.
2. Reading attributes or children in the constructor
The constructor runs before the parser has necessarily processed the element’s attributes and children, so values read there can be missing. Symptoms include a component that renders correctly when created with document.createElement and configured in JavaScript, but renders empty when written in HTML markup. The fix is to move attribute reads and child inspection into connectedCallback, and to handle later changes in attributeChangedCallback. Keep the constructor to what is always safe: attaching the shadow root, cloning the template, and setting up bound handler references.
3. Choosing closed shadow DOM by default
Closed mode sounds more encapsulated, and that is exactly why beginners pick it. In practice it hides the component from your own debugging tools, from your test suite, and from legitimate integrations, while providing no real security, since closed roots are straightforward to work around. The default should be open. Reserve closed for the rare case where you have a specific reason to hide internals from your own page code as well as from everyone else, and be aware of what you are giving up when you do.
4. Defining the same name twice
Calling customElements.define() twice with the same name throws, and this bites during development when a script is re-executed without a page reload, or when two bundles each register the same component. In production, the fix is structural: register each name exactly once, in one place. During development, the quick diagnostic is to check whether the error appears only after a reload-free update; if so, the registration code is running twice, and the page needs a clean reload or a registration guard.
5. Expecting page CSS to style shadow internals
If a page rule targets something inside your shadow tree and nothing happens, that is encapsulation working as designed, not a bug. The remedies, in order of preference, are: expose a CSS custom property for theming, expose the specific internal element with a part attribute and style it with ::part(), or redesign so the styled content is slotted from the page, where page styles apply normally. What you should not do is reach for !important wars or abandon the shadow root at the first styling disagreement; the boundary is the feature, and the three channels above exist precisely so you do not have to break it.
6. Forgetting that custom elements have no semantics
A custom tag announces nothing to assistive technology unless you give it a role and states. A toggle that only changes colour is invisible to a screen reader user; a tab list that only responds to clicks is unusable by keyboard. Assign roles, keep ARIA states in sync in attributeChangedCallback (as the toggle example does with aria-checked), make interactive hosts focusable, and handle the keyboard. This is not extra credit. For a component library, it is the difference between a component and a liability.
Browser Support and When to Use Web Components
1. Support in modern browsers
Custom elements, shadow DOM, templates, and slots are supported in all modern browsers: Chrome, Edge, Firefox, and Safari. The one support gap to remember is customized built-in elements, which Safari does not plan to support — one more reason autonomous elements are the default in this guide. Declarative shadow DOM and adoptedStyleSheets are also supported across modern browsers now, so the server-rendering and shared-styles techniques described above are safe to use without polyfills for a general audience. If you must support older browsers, the standard advice is progressive enhancement: server-render usable markup, and let the component upgrade it where supported, rather than shipping a blank page to browsers that cannot run the script.
2. When native components beat a framework
Reach for web components when the unit of reuse is a leaf-level widget that should work in many contexts: a design system’s buttons, cards, tabs, and toggles; an embeddable widget other sites will drop into their pages; or a small site’s repeated pieces, where a framework would be heavier than the problem. Reach for a framework when the problem is application-shaped: routing, shared state across many screens, and large-team coordination. The two are not enemies. A common, successful architecture is a framework application whose leaf components are web components, so the design system survives the next framework migration. That longevity is the quiet superpower of building on standards: the component you write today still works when the framework around it is replaced.
3. Performance notes worth knowing
Two performance facts are worth carrying into real use. First, cloning a template into each shadow root is cheap, but duplicating a large <style> element in every instance is not free; for components used hundreds of times, adoptedStyleSheets shares one stylesheet across instances instead. Second, shadow DOM does not magically make a page faster. It scopes styles, which can reduce the work of style recalculation on large pages, but a poorly written component — one that re-renders its whole tree on every keystroke, for example — will be slow with or without encapsulation. Measure before optimising, and optimise the component’s update logic before blaming the platform.
Frequently Asked Questions
1. Do web components work in all browsers?
The core APIs — custom elements, shadow DOM, templates, and slots — work in all modern browsers: Chrome, Edge, Firefox, and Safari. The exception is customized built-in elements, which Safari does not plan to support, so use autonomous custom elements for anything that must work everywhere. Declarative shadow DOM and adoptedStyleSheets are also supported across modern browsers now. For older browsers, the safe approach is progressive enhancement: render usable markup on the server and let it upgrade where supported.
2. Do I need a framework to use web components?
No. Web components are browser standards, not a library, so they work in a plain HTML page with a script tag and no build step. Frameworks and web components solve different problems: frameworks help with application structure, routing, and state, while web components provide reusable, encapsulated elements. You can use web components alone for widgets, or use them as the leaf components inside a framework application.
3. What is the difference between shadow DOM and an iframe?
Both provide encapsulation, but at very different weights. An iframe is a separate document with its own window, its own JavaScript context, and a heavy communication cost across its boundary. Shadow DOM is a separate tree inside the same document: same window, same JavaScript context, cheap communication, and styles scoped by the platform. If you need a fully separate document — for example, embedding untrusted third-party content — an iframe is the right tool. If you need a reusable widget on your own page, shadow DOM is lighter and simpler.
4. Can I style a component from the outside?
Yes, through three deliberate channels. CSS custom properties set on the host pierce the boundary and can be read inside the shadow tree, which is the standard theming mechanism. Elements the author exposes with the part attribute can be styled with ::part() from outside. And content slotted from the page keeps the page’s styles, because slotted content remains in the light DOM. What you cannot do is write an ordinary selector that reaches into the shadow tree uninvited — that is the encapsulation doing its job.
5. Why does my custom element need a hyphen in its name?
Because the HTML standard reserves hyphen-free names for itself. Requiring a hyphen guarantees that a custom name can never collide with a future standard element, and it lets the parser recognise custom tags immediately. The browser enforces the rule: customElements.define() throws for a name without a hyphen. Use lowercase kebab-case, such as profile-card or toggle-switch, and the same name everywhere the tag appears.
6. Should I use open or closed shadow DOM?
Open, in almost every case. An open root is reachable through the element’s shadowRoot property, which keeps debugging, testing, and legitimate integration working. A closed root returns null from outside, hiding internals from your own code as well as everyone else’s, while providing no real security, since closed roots are easy to work around. Browsers use closed roots for built-in elements such as video controls. Your own components should default to open unless you have a specific reason to do otherwise.
Conclusion
Web components are the browser’s standard answer to a problem every site eventually faces: the same piece of UI, copied everywhere, breaking everywhere. Custom elements give you new tags with real behaviour. Shadow DOM gives those tags private internals that page styles and scripts cannot break by accident. Templates and slots give you reusable markup with per-use content. Together, they let you write a widget once and use it as a tag on any page, in any framework, or in no framework at all.
The practical next step is small. Take one repeated piece of markup on your own site — a card, a toggle, a badge — and rebuild it as an autonomous custom element with an open shadow root, following the pattern in this guide: template cloned in the constructor, attributes read in connectedCallback, changes handled in attributeChangedCallback, cleanup in disconnectedCallback, slots for page content, and a role plus keyboard handling if it is interactive. Use the profile card example as the starting point and the toggle example as the reference for state and accessibility. Once that first component is working, the second one is mostly repetition, and your markup gets simpler with every widget you convert. For the official reference on everything covered here, see the MDN documentation linked below, and for the template-and-slot foundations this guide builds on, read the companion guide to mastering template and slot in HTML.
Further reading: MDN: Using custom elements — the official guide to defining and using custom elements. MDN: Using shadow DOM — the official guide to shadow roots, slots, and scoped styling.
Related Articles You’ll Love
- HTML Headings Hierarchy: H1 to H6, SEO Rules, and Common Mistakes — heading structure works with your custom elements to tell search engines and screen readers what a page is about.
- HTML Dialog Element: Ultimate Easy Native Modals Guide 2026 — another native browser API that replaces JavaScript, this time for modals, and a natural companion to component-based markup.
- JavaScript Popover: Ultimate Easy Native Popups Guide 2026 — native popups, menus, and tooltips, which pair well with custom elements for interactive widgets.
- HTML Search Element: Easy Native Search Forms Guide 2026 — the semantic search landmark, and how native elements keep pages meaningful without frameworks.
- HTML Meta Tags: Complete SEO List Every Page Needs 2026 — the head section every page needs, including the pages your web components will live in.