Web Components get written off about once a year and keep shipping anyway. GitHub, YouTube and Salesforce build on them. The reason is unglamorous: they are the only component model the browser understands natively, so they work inside React, Vue, Angular and a plain HTML page without a build step.
I do not build ToolForte with them (it is React). Where I meet them is on the testing side: a supplier's front end uses custom elements, a Playwright selector stops finding a button, and someone has to explain why. That is the angle of this post. What Shadow DOM actually isolates, what still crosses the boundary, and the handful of things that trip teams up once components reach production. Plain JavaScript, no library.
A custom element is a class with a hyphen in its name
You write a class that extends HTMLElement and register it under a tag name. From then on the browser treats your tag like any built-in element.
`javascript
class UserCard extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() { const name = this.getAttribute('name') || 'Unknown'; const role = this.getAttribute('role') || ''; this.shadowRoot.innerHTML = `
customElements.define('user-card', UserCard);
`
Then works anywhere in the page.
Two rules. The tag name must contain a hyphen (user-card yes, usercard no); that is how the browser keeps custom names from colliding with future built-in elements. And the constructor may not read attributes or children, because the element may not have any yet when it runs. Read them in connectedCallback, as above.
One warning about the example. It drops attribute values straight into innerHTML. Fine for a demo, a cross-site scripting hole the moment name comes from user data. In real code, build the nodes and set textContent, or escape first.

Shadow DOM blocks selectors and IDs, not variables or events
this.attachShadow({ mode: 'open' }) gives the element a second DOM tree that the outer page cannot style or query. Everything you render inside it stays inside. This is what people mean by encapsulation, and the browser enforces it, which is more than any naming convention ever did.
What the boundary blocks:
- Page CSS cannot reach elements inside the shadow root, and the component's CSS cannot reach out.
- IDs inside the shadow root do not clash with IDs in the document.
document.querySelectordoes not see inside. This is why that Playwright selector broke.
What still crosses:
- CSS custom properties. Set
--btn-bgon the page and the component can read it. This is the intended theming channel. - Inherited properties such as
font-family,colorandline-height. Your component picks up the page's typography unless you reset it. - Events, with two catches.
event.targetis rewritten to the host element once the event leaves the shadow root, and a custom event only leaves at all if you dispatch it withbubbles: true, composed: true.
The mode: 'closed' option adds no security; it only hides element.shadowRoot from scripts, including your own test automation. Use open mode unless you have a reason you can write down.
`this.attachShadow({ mode: 'open' })` gives the element a second DOM tree that the outer page cannot style or query.
Slots let the page supply content while the component owns the layout
A holds markup the browser parses but does not render. You clone it into the shadow root, and elements inside it mark where the consumer's content goes.
`html
`
In the component: this.shadowRoot.append(template.content.cloneNode(true)). On the page:
`html
Info icon here
This is an informational message.
`
The unnamed slot takes whatever has no slot attribute; named slots take the rest. It is the same idea as children in React or named slots in Vue, running in the browser itself.
Slotted content is styled by the page, not by the component, because it still lives in the light DOM. From inside the component you can reach it with ::slotted(span), but only the direct child of the slot, nothing deeper. If you need to style deep inside slotted content you are fighting the design; move that markup into the component.
Four lifecycle callbacks, and you will use two of them
connectedCallback() runs when the element lands in the document. Render here, attach listeners here. It can run more than once: move the element in the DOM and it fires again, so guard anything that must happen only once.
disconnectedCallback() runs on removal. Remove listeners, clear timers, disconnect observers, or they keep the element alive in memory.
attributeChangedCallback(name, oldValue, newValue) runs when an observed attribute changes. You have to opt in per attribute:
`javascript
static get observedAttributes() {
return ['name', 'status', 'count'];
}
attributeChangedCallback(name, oldValue, newValue) {
if (oldValue === newValue) return;
this.render();
}
`
Attributes not in the list never trigger the callback. That is deliberate; otherwise every class or style change on every element would fire it.
adoptedCallback() runs when the element moves to another document. I have never needed it.
The pattern that keeps this readable is one render() method called from both connectedCallback and attributeChangedCallback. And when something does not show up, open DevTools and expand the #shadow-root (open) node under the element. That tree is what the component actually rendered, whatever the page source says.

Theme through custom properties and ::part, nothing else
The boundary raises an obvious question: how does the page change how the component looks? Custom properties, because they are the one CSS feature that crosses on purpose.
`javascript
// Inside the component
this.shadowRoot.innerHTML = `
`;
`
The page sets the variables on the host:
`css
custom-button {
--btn-bg: #10b981;
--btn-color: white;
--btn-radius: 20px;
}
`
Three more selectors matter. :host styles the element itself from inside the shadow root. :host([variant="outline"]) styles it based on an attribute, which is how you do variants. And part="label" on an internal element lets the page style it with custom-button::part(label), an escape hatch for the cases a variable cannot cover.
Skip :host-context(). It only ever shipped in Chromium; Firefox and Safari do not have it, and the spec has been trying to drop it for years.
Treat the list of custom properties and parts as the component's public API and document it like one. If the style block is inlined per instance, run it through the CSS Minifier first; every instance carries its own copy.
What bites once components reach production
Server rendering. Shadow DOM used to require JavaScript, which meant a flash of unstyled content and nothing for crawlers. Declarative Shadow DOM fixes that: inside the host element is parsed straight into a shadow root. It has been in Chrome since 111, Safari since 16.4 and Firefox since 123, so since early 2024 every current browser has it. Posts that still call it a Chromium-only proposal are out of date.
Accessibility. A is a generic element to a screen reader. Nothing about it says button. You add role, tabindex="0" and Enter and Space handling yourself, or set the role through ElementInternals (this.attachInternals().role = 'button') and still write the keyboard handling. Wrapping a real
Styles per instance. Every shadow root computes its own styles. Two hundred table rows each with their own inline
Test automation. Playwright's CSS and text selectors pierce open shadow roots without configuration; XPath does not. Cypress needs includeShadowDom: true or an explicit .shadow() call. A closed shadow root defeats all of it, which is the practical reason to keep mode open. When a tester tells you the element does not exist, the element exists; the selector stopped at the boundary.
Forms. A custom input does not submit with the form until you set static formAssociated = true and call ElementInternals.setFormValue(). Without that, FormData never sees it and your backend validation gets an empty field.
**Server rendering.** Shadow DOM used to require JavaScript, which meant a flash of unstyled content and nothing for crawlers.
FAQ
Can I use web components with React or Vue?
Yes. They are plain DOM elements, so every framework can render them. React was the awkward one for years (custom events and boolean attributes did not map cleanly); React 19 fixed that. Vue, Angular and Svelte have handled them well for a long time.
Should I use web components instead of a framework?
For a whole application, I would not. State management, routing and tooling are where React or Vue earn their keep. Web components win for a shared design system that has to work across teams on different stacks, and for widgets embedded in pages you do not control. Plenty of teams use both.
Are they faster than framework components?
Not in a way you will measure. There is no virtual DOM diff, but there is also no batching of updates unless you write it. The reason to pick them is interoperability, not speed.
Is Shadow DOM required for a web component?
No. A custom element without a shadow root is still a custom element; its styles just live in the page like everything else. For logic-only elements, or components that should inherit the site's CSS anyway, skip it. For UI components that must look the same on every page they land on, use it.
HTTP Security Headers Checklist: Six Headers and How to Set Them
The six HTTP security headers that stop XSS, clickjacking and HTTPS downgrades, with copy-and-paste configs for Vercel, Nginx, Apache and Cloudflare, and how I check them.
API, MCP and Workflows Together: One Balance, Three Doors
A worked setup where a saved workflow is the recipe, a backend runs it over the REST API, and an assistant runs it over MCP with this week's data. Why one credit balance across all three is the point, and how to keep it from surprising you.
Native CSS Nesting: What Changes When You Drop SCSS
Native CSS nesting works in every current browser. The syntax, the two places it differs from SCSS, a specificity trap SCSS never had, and when keeping SCSS is the right call.
CSS ::before and ::after: Decoration Out of the HTML, Meaning Kept In It
How the two pseudo-elements work, the underline, badge, overlay and tooltip-arrow patterns, attr() and counters, and the one accessibility question to ask before shipping any of them.
