{"schemaVersion":1,"id":"https://lucioduran.com/blog/web-components-shadow-dom-custom-elements-2026","url":"https://lucioduran.com/blog/web-components-shadow-dom-custom-elements-2026","title":"Web Components in 2026: Declarative Shadow DOM, CSS Parts, and Framework Interoperability","description":"Declarative Shadow DOM, CSS parts, Lit 4.0, and universal custom element support. An assessment of Web Component maturity in 2026, covering SSR, framework interop, and remaining friction points.","language":"en","datePublished":"2025-10-13","dateModified":null,"contentFormat":"text/markdown","content":"Frontend\n\n# Web Components in 2026: Declarative Shadow DOM, CSS Parts, and Framework Interoperability\n\n2025-10-138 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/web-components-shadow-dom-custom-elements-2026>)\n\n## Declarative Shadow DOM: the SSR breakthrough\n\nThe biggest blocker for Web Components in production was server-side rendering. Shadow DOM was a purely client-side API, you had to call ` attachShadow() ` in JavaScript to create one. That meant no Shadow DOM until JavaScript loaded, parsed, and executed. For SSR-heavy applications, this was a dealbreaker.\n\nDeclarative Shadow DOM (DSD) fixes this by allowing Shadow DOM to be expressed in HTML:\n\n```html\n<my-card>\n <template shadowrootmode=\"open\">\n <style>\n :host {\n display: block;\n border: 1px solid var(--card-border, #e2e0dc);\n border-radius: 8px;\n padding: 1rem;\n }\n .header { font-weight: 600; }\n .content { margin-top: 0.5rem; }\n </style>\n <div class=\"header\">\n <slot name=\"title\"></slot>\n </div>\n <div class=\"content\">\n <slot></slot>\n </div>\n </template>\n <span slot=\"title\">Server-rendered card</span>\n <p>This shadow DOM exists before any JS runs.</p>\n</my-card>\n```\n\nThe browser parses the ` <template shadowrootmode=\"open\"> ` and immediately attaches a shadow root, no JavaScript needed. The component renders with full style encapsulation on first paint. When your component class eventually loads, ` connectedCallback ` fires and the shadow root already exists:\n\n```javascript\nclass MyCard extends HTMLElement {\n constructor() {\n super();\n // Don't call attachShadow() if DSD already created one\n if (!this.shadowRoot) {\n this.attachShadow({ mode: 'open' });\n this.shadowRoot.innerHTML = `/* fallback template */`;\n }\n }\n\n connectedCallback() {\n // Shadow root may already have content from DSD\n this.#upgradeSlots();\n this.#attachEventListeners();\n }\n\n #upgradeSlots() {\n const titleSlot = this.shadowRoot.querySelector('slot[name=\"title\"]');\n titleSlot?.addEventListener('slotchange', () => {\n this.#updateAriaLabel();\n });\n }\n}\n\ncustomElements.define('my-card', MyCard);\n```\n\nThe critical pattern: always check ` if (!this.shadowRoot) ` before calling ` attachShadow() `. DSD components arrive with a shadow root already populated. Calling ` attachShadow() ` again throws an error.\n\n### Server-rendering DSD\n\nOn the server side (Node.js, Deno, or edge runtime), generate DSD output by rendering your template as HTML:\n\n```javascript\n// Server-side rendering function\nfunction renderMyCard({ title, content, theme = 'light' }) {\n return `\n <my-card data-theme=\"${theme}\">\n <template shadowrootmode=\"open\">\n <style>\n :host { display: block; border-radius: 8px; padding: 1rem; }\n :host([data-theme=\"dark\"]) { background: #1a1816; color: #faf9f5; }\n :host([data-theme=\"light\"]) { background: #faf9f5; color: #1a1816; }\n ::slotted([slot=\"title\"]) { font-size: 1.25rem; font-weight: 600; }\n </style>\n <slot name=\"title\"></slot>\n <slot></slot>\n </template>\n <span slot=\"title\">${escapeHtml(title)}</span>\n <div>${escapeHtml(content)}</div>\n </my-card>\n `;\n}\n```\n\nThis works with any SSR framework, Next.js, Astro, SvelteKit. The HTML arrives fully rendered. No FOUC (flash of unstyled content), no layout shift, no waiting for JavaScript.\n\n## CSS Parts: theming done right\n\nBefore CSS parts, customizing Web Component styles from outside was painful. You had two options: CSS custom properties (limited to values, not selectors) or piercing the shadow boundary with ` ::shadow ` (deprecated and removed).\n\nCSS ` ::part() ` lets a component expose named parts of its shadow DOM for external styling:\n\n```javascript\nclass DataTable extends HTMLElement {\n constructor() {\n super();\n this.attachShadow({ mode: 'open' });\n this.shadowRoot.innerHTML = `\n <style>\n table { width: 100%; border-collapse: collapse; }\n th { text-align: left; }\n tr:hover { background: var(--table-hover, rgba(0,0,0,0.04)); }\n </style>\n <table part=\"table\">\n <thead part=\"header\">\n <tr part=\"header-row\">\n <th part=\"header-cell\">Name</th>\n <th part=\"header-cell\">Value</th>\n </tr>\n </thead>\n <tbody part=\"body\">\n <!-- rows rendered dynamically -->\n </tbody>\n </table>\n `;\n }\n\n set data(rows) {\n const tbody = this.shadowRoot.querySelector('tbody');\n tbody.innerHTML = rows.map(row => `\n <tr part=\"row\">\n <td part=\"cell\">${row.name}</td>\n <td part=\"cell\">${row.value}</td>\n </tr>\n `).join('');\n }\n}\n```\n\nNow consumers can style the internals without touching the component code:\n\n```css\n/* Consumer styles, outside the shadow DOM */\ndata-table::part(header) {\n background: #d97757;\n color: white;\n}\n\ndata-table::part(header-cell) {\n padding: 0.75rem 1rem;\n font-weight: 600;\n text-transform: uppercase;\n font-size: 0.75rem;\n letter-spacing: 0.05em;\n}\n\ndata-table::part(row):hover {\n background: rgba(217, 119, 87, 0.08);\n}\n\ndata-table::part(cell) {\n padding: 0.5rem 1rem;\n border-bottom: 1px solid #e2e0dc;\n}\n```\n\nThe beauty of parts: the component author controls what's exposable, and the consumer controls how it looks. The internal DOM structure can change freely without breaking consumer styles, as long as the part names stay stable.\n\n### Compound parts with ` exportparts `\n\nWhen Web Components nest other Web Components, parts don't bubble up by default. Use ` exportparts ` to forward inner parts:\n\n```html\n<user-profile>\n <template shadowrootmode=\"open\">\n <data-table exportparts=\"header: profile-header, cell: profile-cell\">\n </data-table>\n </template>\n</user-profile>\n\n<style>\n /* Now accessible from outside user-profile */\n user-profile::part(profile-header) {\n background: navy;\n }\n</style>\n```\n\n## Form-associated custom elements\n\nThis was a long-standing gap. Custom elements that wrapped form inputs were invisible to native ` <form> ` behavior, no submission, no validation, no ` FormData ` integration. The ` ElementInternals ` API fixes this completely:\n\n```javascript\nclass StarRating extends HTMLElement {\n static formAssociated = true;\n\n #internals;\n #value = 0;\n\n constructor() {\n super();\n this.#internals = this.attachInternals();\n this.attachShadow({ mode: 'open', delegatesFocus: true });\n\n this.shadowRoot.innerHTML = `\n <style>\n :host { display: inline-flex; gap: 2px; }\n button {\n background: none; border: none; cursor: pointer;\n font-size: 1.5rem; padding: 0; color: #ccc;\n transition: color 0.15s;\n }\n button.active { color: #d97757; }\n button:focus-visible { outline: 2px solid #d97757; border-radius: 2px; }\n :host(:invalid) button.active { color: #e74c3c; }\n </style>\n ${[1,2,3,4,5].map(n => `\n <button type=\"button\" data-value=\"${n}\" aria-label=\"${n} star${n>1?'s':''}\">\n &#9733;\n </button>\n `).join('')}\n `;\n\n this.shadowRoot.addEventListener('click', (e) => {\n const btn = e.target.closest('button');\n if (!btn) return;\n this.value = parseInt(btn.dataset.value);\n });\n }\n\n get value() { return this.#value; }\n\n set value(v) {\n this.#value = v;\n this.#internals.setFormValue(v.toString());\n\n // Native form validation\n if (this.required && v === 0) {\n this.#internals.setValidity(\n { valueMissing: true },\n 'Please select a rating',\n this.shadowRoot.querySelector('button')\n );\n } else {\n this.#internals.setValidity({});\n }\n\n // Update visual state\n this.shadowRoot.querySelectorAll('button').forEach((btn, i) => {\n btn.classList.toggle('active', i < v);\n });\n }\n\n get required() { return this.hasAttribute('required'); }\n\n // Form lifecycle callbacks\n formResetCallback() { this.value = 0; }\n formStateRestoreCallback(state) { this.value = parseInt(state) || 0; }\n}\n\ncustomElements.define('star-rating', StarRating);\n```\n\nUsage in a form, fully native:\n\n```html\n<form id=\"review-form\">\n <label>Rating:</label>\n <star-rating name=\"rating\" required></star-rating>\n\n <label>Comment:</label>\n <textarea name=\"comment\"></textarea>\n\n <button type=\"submit\">Submit</button>\n</form>\n\n<script>\n document.getElementById('review-form').addEventListener('submit', (e) => {\n e.preventDefault();\n const data = new FormData(e.target);\n console.log(data.get('rating')); // \"4\"\n console.log(data.get('comment')); // \"Great product\"\n });\n</script>\n```\n\nThe ` star-rating ` component participates in form submission, validation, and reset, just like a native ` <input> `. The ` :invalid ` pseudo-class even works through the shadow boundary.\n\n## Lit 4.0: the framework that Web Components needed\n\n[Lit](<https://lit.dev/>) 4.0 (released late 2025) polished the developer experience to the point where it feels competitive with React or Vue for component authoring. Key additions:\n\n```javascript\nimport { LitElement, html, css } from 'lit';\nimport { property, state, query } from 'lit/decorators.js';\n\nclass UserCard extends LitElement {\n static styles = css`\n :host {\n display: block;\n container-type: inline-size;\n }\n\n .card {\n display: grid;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid var(--border-color, #e2e0dc);\n border-radius: 8px;\n }\n\n @container (min-width: 400px) {\n .card { grid-template-columns: auto 1fr; }\n }\n `;\n\n @property({ type: String }) name = '';\n @property({ type: String }) role = '';\n @property({ type: String, attribute: 'avatar-url' }) avatarUrl = '';\n\n @state() private _expanded = false;\n\n @query('.details') _detailsEl;\n\n render() {\n return html`\n <div class=\"card\" part=\"card\">\n <img src=${this.avatarUrl} alt=${this.name} part=\"avatar\" />\n <div>\n <h3 part=\"name\">${this.name}</h3>\n <p part=\"role\">${this.role}</p>\n <button @click=${this.#toggle}>\n ${this._expanded ? 'Less' : 'More'}\n </button>\n ${this._expanded ? html`\n <div class=\"details\" part=\"details\">\n <slot></slot>\n </div>\n ` : ''}\n </div>\n </div>\n `;\n }\n\n #toggle() {\n this._expanded = !this._expanded;\n this.dispatchEvent(new CustomEvent('toggle', {\n detail: { expanded: this._expanded },\n bubbles: true, composed: true\n }));\n }\n}\n\ncustomElements.define('user-card', UserCard);\n```\n\nLit's reactive properties, efficient DOM diffing, and SSR support (` @lit-labs/ssr `) make it the de facto standard for Web Component authoring. The decorators API is clean, the template literal syntax avoids build-step dependencies, and the output is a standard custom element that works in any framework.\n\n## The framework interop reality\n\nReact 19+ finally handles custom elements properly. Properties pass through (not just string attributes), events work, refs work. The years of ` react-web-component-wrapper ` hacks are over:\n\n```jsx\n// React 19+, just works\nfunction App() {\n const handleToggle = (e) => {\n console.log('Expanded:', e.detail.expanded);\n };\n\n return (\n <user-card\n name=\"Lucio Durán\"\n role=\"Software Engineer\"\n avatar-url=\"/avatar.jpg\"\n onToggle={handleToggle}\n >\n <p>Buenos Aires, Argentina</p>\n </user-card>\n );\n}\n```\n\nVue and Svelte have had good Web Component interop for years. Angular supports them natively through ` CUSTOM_ELEMENTS_SCHEMA `. The one remaining pain point is SSR hydration, if your framework server-renders HTML and a Web Component expects to hydrate its DSD output, the timing can be tricky. Lit's SSR handles this; other frameworks may need manual coordination.\n\n## When to actually use them\n\nAfter examining design systems built with both React-only and Web Component approaches, here is an honest assessment:\n\n**Use Web Components when**: you have multiple framework teams consuming the same component library, you need framework-agnostic widgets (embed scripts, micro-frontends), or you're building for longevity and want to avoid framework churn.\n\n**Keep using your framework's components when**: you're a single-framework shop, you need deep integration with framework-specific features (React Server Components, Svelte stores), or your team doesn't want to learn a second component model.\n\nThe sweet spot that emerges is design system primitives as Web Components (buttons, inputs, cards, modals) consumed by framework-specific application code. The primitives rarely change and benefit from framework independence. The application logic changes constantly and benefits from framework ergonomics.\n\nThat's not a revolutionary take, and it shouldn't be. Web Components are a tool. They solve a specific problem, encapsulated, reusable, interoperable UI primitives, and in 2026, they solve it well. The hype cycle is over. The boring productivity phase has begun. That's exactly where you want a platform technology to be.\n\n## Frequently asked questions\n\n### Are Web Components ready for production in 2026?\n\nYes. All major browsers support custom elements v1, Shadow DOM, Declarative Shadow DOM, CSS parts, and the ElementInternals API. The ecosystem (Lit 4.0, Stencil, FAST) is mature. The main remaining friction is SSR hydration in non-Lit frameworks.\n\n### Can I use Web Components with React?\n\nYes, React 19+ has full custom element support including property passing (not just attributes), event handling, and ref forwarding. The interop story is finally solid after years of workarounds.\n\n### Do Web Components replace React/Vue/Svelte?\n\nNo. Web Components are a platform primitive for encapsulated, reusable UI elements. Frameworks provide state management, routing, and application architecture. The best use case for Web Components is design system components consumed across different framework teams.\n\n## Tips\n\n1. **Tip:** Use ` ::part() ` for theming, not CSS custom properties for everything. Parts give consumers targeted styling control without exposing your internal DOM structure.\n2. **Tip:** Always set ` formAssociated = true ` and implement ` ElementInternals ` if your component wraps a form input. Without it, your component is invisible to ` <form> ` submission and validation.\n3. **Tip:** Test with Declarative Shadow DOM SSR by running ` getInnerHTML({includeShadowRoots: true}) ` and verifying the output hydrates correctly. Many components break because they assume ` connectedCallback ` runs before shadow DOM exists.\n\n- web-components\n- shadow-dom\n- custom-elements\n- lit\n- css-parts\n- declarative-shadow-dom\n- frontend\n- interop\n\n[Back to Blog](<https://lucioduran.com/blog>)","headings":[{"level":1,"text":"Web Components in 2026: Declarative Shadow DOM, CSS Parts, and Framework Interoperability","url":"https://lucioduran.com/blog/web-components-shadow-dom-custom-elements-2026#title"},{"level":2,"text":"Declarative Shadow DOM: the SSR breakthrough","url":"https://lucioduran.com/blog/web-components-shadow-dom-custom-elements-2026#declarative-shadow-dom-the-ssr-breakthrough"},{"level":3,"text":"Server-rendering DSD","url":"https://lucioduran.com/blog/web-components-shadow-dom-custom-elements-2026#server-rendering-dsd"},{"level":2,"text":"CSS Parts: theming done right","url":"https://lucioduran.com/blog/web-components-shadow-dom-custom-elements-2026#css-parts-theming-done-right"},{"level":3,"text":"Compound parts with exportparts","url":"https://lucioduran.com/blog/web-components-shadow-dom-custom-elements-2026#compound-parts-with-exportparts"},{"level":2,"text":"Form-associated custom elements","url":"https://lucioduran.com/blog/web-components-shadow-dom-custom-elements-2026#form-associated-custom-elements"},{"level":2,"text":"Lit 4.0: the framework that Web Components needed","url":"https://lucioduran.com/blog/web-components-shadow-dom-custom-elements-2026#lit-40-the-framework-that-web-components-needed"},{"level":2,"text":"The framework interop reality","url":"https://lucioduran.com/blog/web-components-shadow-dom-custom-elements-2026#the-framework-interop-reality"},{"level":2,"text":"When to actually use them","url":"https://lucioduran.com/blog/web-components-shadow-dom-custom-elements-2026#when-to-actually-use-them"},{"level":2,"text":"Frequently asked questions","url":"https://lucioduran.com/blog/web-components-shadow-dom-custom-elements-2026#questions"},{"level":3,"text":"Are Web Components ready for production in 2026?","url":"https://lucioduran.com/blog/web-components-shadow-dom-custom-elements-2026#question-1"},{"level":3,"text":"Can I use Web Components with React?","url":"https://lucioduran.com/blog/web-components-shadow-dom-custom-elements-2026#question-2"},{"level":3,"text":"Do Web Components replace React/Vue/Svelte?","url":"https://lucioduran.com/blog/web-components-shadow-dom-custom-elements-2026#question-3"},{"level":2,"text":"Tips","url":"https://lucioduran.com/blog/web-components-shadow-dom-custom-elements-2026#article-tips"}],"sections":[{"heading":"Web Components in 2026: Declarative Shadow DOM, CSS Parts, and Framework Interoperability","level":1,"url":"https://lucioduran.com/blog/web-components-shadow-dom-custom-elements-2026","content":"Frontend"},{"heading":"Web Components in 2026: Declarative Shadow DOM, CSS Parts, and Framework Interoperability","level":1,"url":"https://lucioduran.com/blog/web-components-shadow-dom-custom-elements-2026#title","content":"2025-10-138 min read\n\nLucio Durán\nEngineering Manager & AI Solutions Architect\n\nAlso available in: [Español](<https://lucioduran.com/es/blog/web-components-shadow-dom-custom-elements-2026>)"},{"heading":"Declarative Shadow DOM: the SSR breakthrough","level":2,"url":"https://lucioduran.com/blog/web-components-shadow-dom-custom-elements-2026#declarative-shadow-dom-the-ssr-breakthrough","content":"The biggest blocker for Web Components in production was server-side rendering. Shadow DOM was a purely client-side API, you had to call ` attachShadow() ` in JavaScript to create one. That meant no Shadow DOM until JavaScript loaded, parsed, and executed. For SSR-heavy applications, this was a dealbreaker.\n\nDeclarative Shadow DOM (DSD) fixes this by allowing Shadow DOM to be expressed in HTML:\n\n```html\n<my-card>\n <template shadowrootmode=\"open\">\n <style>\n :host {\n display: block;\n border: 1px solid var(--card-border, #e2e0dc);\n border-radius: 8px;\n padding: 1rem;\n }\n .header { font-weight: 600; }\n .content { margin-top: 0.5rem; }\n </style>\n <div class=\"header\">\n <slot name=\"title\"></slot>\n </div>\n <div class=\"content\">\n <slot></slot>\n </div>\n </template>\n <span slot=\"title\">Server-rendered card</span>\n <p>This shadow DOM exists before any JS runs.</p>\n</my-card>\n```\n\nThe browser parses the ` <template shadowrootmode=\"open\"> ` and immediately attaches a shadow root, no JavaScript needed. The component renders with full style encapsulation on first paint. When your component class eventually loads, ` connectedCallback ` fires and the shadow root already exists:\n\n```javascript\nclass MyCard extends HTMLElement {\n constructor() {\n super();\n // Don't call attachShadow() if DSD already created one\n if (!this.shadowRoot) {\n this.attachShadow({ mode: 'open' });\n this.shadowRoot.innerHTML = `/* fallback template */`;\n }\n }\n\n connectedCallback() {\n // Shadow root may already have content from DSD\n this.#upgradeSlots();\n this.#attachEventListeners();\n }\n\n #upgradeSlots() {\n const titleSlot = this.shadowRoot.querySelector('slot[name=\"title\"]');\n titleSlot?.addEventListener('slotchange', () => {\n this.#updateAriaLabel();\n });\n }\n}\n\ncustomElements.define('my-card', MyCard);\n```\n\nThe critical pattern: always check ` if (!this.shadowRoot) ` before calling ` attachShadow() `. DSD components arrive with a shadow root already populated. Calling ` attachShadow() ` again throws an error."},{"heading":"Server-rendering DSD","level":3,"url":"https://lucioduran.com/blog/web-components-shadow-dom-custom-elements-2026#server-rendering-dsd","content":"On the server side (Node.js, Deno, or edge runtime), generate DSD output by rendering your template as HTML:\n\n```javascript\n// Server-side rendering function\nfunction renderMyCard({ title, content, theme = 'light' }) {\n return `\n <my-card data-theme=\"${theme}\">\n <template shadowrootmode=\"open\">\n <style>\n :host { display: block; border-radius: 8px; padding: 1rem; }\n :host([data-theme=\"dark\"]) { background: #1a1816; color: #faf9f5; }\n :host([data-theme=\"light\"]) { background: #faf9f5; color: #1a1816; }\n ::slotted([slot=\"title\"]) { font-size: 1.25rem; font-weight: 600; }\n </style>\n <slot name=\"title\"></slot>\n <slot></slot>\n </template>\n <span slot=\"title\">${escapeHtml(title)}</span>\n <div>${escapeHtml(content)}</div>\n </my-card>\n `;\n}\n```\n\nThis works with any SSR framework, Next.js, Astro, SvelteKit. The HTML arrives fully rendered. No FOUC (flash of unstyled content), no layout shift, no waiting for JavaScript."},{"heading":"CSS Parts: theming done right","level":2,"url":"https://lucioduran.com/blog/web-components-shadow-dom-custom-elements-2026#css-parts-theming-done-right","content":"Before CSS parts, customizing Web Component styles from outside was painful. You had two options: CSS custom properties (limited to values, not selectors) or piercing the shadow boundary with ` ::shadow ` (deprecated and removed).\n\nCSS ` ::part() ` lets a component expose named parts of its shadow DOM for external styling:\n\n```javascript\nclass DataTable extends HTMLElement {\n constructor() {\n super();\n this.attachShadow({ mode: 'open' });\n this.shadowRoot.innerHTML = `\n <style>\n table { width: 100%; border-collapse: collapse; }\n th { text-align: left; }\n tr:hover { background: var(--table-hover, rgba(0,0,0,0.04)); }\n </style>\n <table part=\"table\">\n <thead part=\"header\">\n <tr part=\"header-row\">\n <th part=\"header-cell\">Name</th>\n <th part=\"header-cell\">Value</th>\n </tr>\n </thead>\n <tbody part=\"body\">\n <!-- rows rendered dynamically -->\n </tbody>\n </table>\n `;\n }\n\n set data(rows) {\n const tbody = this.shadowRoot.querySelector('tbody');\n tbody.innerHTML = rows.map(row => `\n <tr part=\"row\">\n <td part=\"cell\">${row.name}</td>\n <td part=\"cell\">${row.value}</td>\n </tr>\n `).join('');\n }\n}\n```\n\nNow consumers can style the internals without touching the component code:\n\n```css\n/* Consumer styles, outside the shadow DOM */\ndata-table::part(header) {\n background: #d97757;\n color: white;\n}\n\ndata-table::part(header-cell) {\n padding: 0.75rem 1rem;\n font-weight: 600;\n text-transform: uppercase;\n font-size: 0.75rem;\n letter-spacing: 0.05em;\n}\n\ndata-table::part(row):hover {\n background: rgba(217, 119, 87, 0.08);\n}\n\ndata-table::part(cell) {\n padding: 0.5rem 1rem;\n border-bottom: 1px solid #e2e0dc;\n}\n```\n\nThe beauty of parts: the component author controls what's exposable, and the consumer controls how it looks. The internal DOM structure can change freely without breaking consumer styles, as long as the part names stay stable."},{"heading":"Compound parts with exportparts","level":3,"url":"https://lucioduran.com/blog/web-components-shadow-dom-custom-elements-2026#compound-parts-with-exportparts","content":"When Web Components nest other Web Components, parts don't bubble up by default. Use ` exportparts ` to forward inner parts:\n\n```html\n<user-profile>\n <template shadowrootmode=\"open\">\n <data-table exportparts=\"header: profile-header, cell: profile-cell\">\n </data-table>\n </template>\n</user-profile>\n\n<style>\n /* Now accessible from outside user-profile */\n user-profile::part(profile-header) {\n background: navy;\n }\n</style>\n```"},{"heading":"Form-associated custom elements","level":2,"url":"https://lucioduran.com/blog/web-components-shadow-dom-custom-elements-2026#form-associated-custom-elements","content":"This was a long-standing gap. Custom elements that wrapped form inputs were invisible to native ` <form> ` behavior, no submission, no validation, no ` FormData ` integration. The ` ElementInternals ` API fixes this completely:\n\n```javascript\nclass StarRating extends HTMLElement {\n static formAssociated = true;\n\n #internals;\n #value = 0;\n\n constructor() {\n super();\n this.#internals = this.attachInternals();\n this.attachShadow({ mode: 'open', delegatesFocus: true });\n\n this.shadowRoot.innerHTML = `\n <style>\n :host { display: inline-flex; gap: 2px; }\n button {\n background: none; border: none; cursor: pointer;\n font-size: 1.5rem; padding: 0; color: #ccc;\n transition: color 0.15s;\n }\n button.active { color: #d97757; }\n button:focus-visible { outline: 2px solid #d97757; border-radius: 2px; }\n :host(:invalid) button.active { color: #e74c3c; }\n </style>\n ${[1,2,3,4,5].map(n => `\n <button type=\"button\" data-value=\"${n}\" aria-label=\"${n} star${n>1?'s':''}\">\n &#9733;\n </button>\n `).join('')}\n `;\n\n this.shadowRoot.addEventListener('click', (e) => {\n const btn = e.target.closest('button');\n if (!btn) return;\n this.value = parseInt(btn.dataset.value);\n });\n }\n\n get value() { return this.#value; }\n\n set value(v) {\n this.#value = v;\n this.#internals.setFormValue(v.toString());\n\n // Native form validation\n if (this.required && v === 0) {\n this.#internals.setValidity(\n { valueMissing: true },\n 'Please select a rating',\n this.shadowRoot.querySelector('button')\n );\n } else {\n this.#internals.setValidity({});\n }\n\n // Update visual state\n this.shadowRoot.querySelectorAll('button').forEach((btn, i) => {\n btn.classList.toggle('active', i < v);\n });\n }\n\n get required() { return this.hasAttribute('required'); }\n\n // Form lifecycle callbacks\n formResetCallback() { this.value = 0; }\n formStateRestoreCallback(state) { this.value = parseInt(state) || 0; }\n}\n\ncustomElements.define('star-rating', StarRating);\n```\n\nUsage in a form, fully native:\n\n```html\n<form id=\"review-form\">\n <label>Rating:</label>\n <star-rating name=\"rating\" required></star-rating>\n\n <label>Comment:</label>\n <textarea name=\"comment\"></textarea>\n\n <button type=\"submit\">Submit</button>\n</form>\n\n<script>\n document.getElementById('review-form').addEventListener('submit', (e) => {\n e.preventDefault();\n const data = new FormData(e.target);\n console.log(data.get('rating')); // \"4\"\n console.log(data.get('comment')); // \"Great product\"\n });\n</script>\n```\n\nThe ` star-rating ` component participates in form submission, validation, and reset, just like a native ` <input> `. The ` :invalid ` pseudo-class even works through the shadow boundary."},{"heading":"Lit 4.0: the framework that Web Components needed","level":2,"url":"https://lucioduran.com/blog/web-components-shadow-dom-custom-elements-2026#lit-40-the-framework-that-web-components-needed","content":"[Lit](<https://lit.dev/>) 4.0 (released late 2025) polished the developer experience to the point where it feels competitive with React or Vue for component authoring. Key additions:\n\n```javascript\nimport { LitElement, html, css } from 'lit';\nimport { property, state, query } from 'lit/decorators.js';\n\nclass UserCard extends LitElement {\n static styles = css`\n :host {\n display: block;\n container-type: inline-size;\n }\n\n .card {\n display: grid;\n gap: 1rem;\n padding: 1rem;\n border: 1px solid var(--border-color, #e2e0dc);\n border-radius: 8px;\n }\n\n @container (min-width: 400px) {\n .card { grid-template-columns: auto 1fr; }\n }\n `;\n\n @property({ type: String }) name = '';\n @property({ type: String }) role = '';\n @property({ type: String, attribute: 'avatar-url' }) avatarUrl = '';\n\n @state() private _expanded = false;\n\n @query('.details') _detailsEl;\n\n render() {\n return html`\n <div class=\"card\" part=\"card\">\n <img src=${this.avatarUrl} alt=${this.name} part=\"avatar\" />\n <div>\n <h3 part=\"name\">${this.name}</h3>\n <p part=\"role\">${this.role}</p>\n <button @click=${this.#toggle}>\n ${this._expanded ? 'Less' : 'More'}\n </button>\n ${this._expanded ? html`\n <div class=\"details\" part=\"details\">\n <slot></slot>\n </div>\n ` : ''}\n </div>\n </div>\n `;\n }\n\n #toggle() {\n this._expanded = !this._expanded;\n this.dispatchEvent(new CustomEvent('toggle', {\n detail: { expanded: this._expanded },\n bubbles: true, composed: true\n }));\n }\n}\n\ncustomElements.define('user-card', UserCard);\n```\n\nLit's reactive properties, efficient DOM diffing, and SSR support (` @lit-labs/ssr `) make it the de facto standard for Web Component authoring. The decorators API is clean, the template literal syntax avoids build-step dependencies, and the output is a standard custom element that works in any framework."},{"heading":"The framework interop reality","level":2,"url":"https://lucioduran.com/blog/web-components-shadow-dom-custom-elements-2026#the-framework-interop-reality","content":"React 19+ finally handles custom elements properly. Properties pass through (not just string attributes), events work, refs work. The years of ` react-web-component-wrapper ` hacks are over:\n\n```jsx\n// React 19+, just works\nfunction App() {\n const handleToggle = (e) => {\n console.log('Expanded:', e.detail.expanded);\n };\n\n return (\n <user-card\n name=\"Lucio Durán\"\n role=\"Software Engineer\"\n avatar-url=\"/avatar.jpg\"\n onToggle={handleToggle}\n >\n <p>Buenos Aires, Argentina</p>\n </user-card>\n );\n}\n```\n\nVue and Svelte have had good Web Component interop for years. Angular supports them natively through ` CUSTOM_ELEMENTS_SCHEMA `. The one remaining pain point is SSR hydration, if your framework server-renders HTML and a Web Component expects to hydrate its DSD output, the timing can be tricky. Lit's SSR handles this; other frameworks may need manual coordination."},{"heading":"When to actually use them","level":2,"url":"https://lucioduran.com/blog/web-components-shadow-dom-custom-elements-2026#when-to-actually-use-them","content":"After examining design systems built with both React-only and Web Component approaches, here is an honest assessment:\n\n**Use Web Components when**: you have multiple framework teams consuming the same component library, you need framework-agnostic widgets (embed scripts, micro-frontends), or you're building for longevity and want to avoid framework churn.\n\n**Keep using your framework's components when**: you're a single-framework shop, you need deep integration with framework-specific features (React Server Components, Svelte stores), or your team doesn't want to learn a second component model.\n\nThe sweet spot that emerges is design system primitives as Web Components (buttons, inputs, cards, modals) consumed by framework-specific application code. The primitives rarely change and benefit from framework independence. The application logic changes constantly and benefits from framework ergonomics.\n\nThat's not a revolutionary take, and it shouldn't be. Web Components are a tool. They solve a specific problem, encapsulated, reusable, interoperable UI primitives, and in 2026, they solve it well. The hype cycle is over. The boring productivity phase has begun. That's exactly where you want a platform technology to be."},{"heading":"Frequently asked questions","level":2,"url":"https://lucioduran.com/blog/web-components-shadow-dom-custom-elements-2026#questions","content":""},{"heading":"Are Web Components ready for production in 2026?","level":3,"url":"https://lucioduran.com/blog/web-components-shadow-dom-custom-elements-2026#question-1","content":"Yes. All major browsers support custom elements v1, Shadow DOM, Declarative Shadow DOM, CSS parts, and the ElementInternals API. The ecosystem (Lit 4.0, Stencil, FAST) is mature. The main remaining friction is SSR hydration in non-Lit frameworks."},{"heading":"Can I use Web Components with React?","level":3,"url":"https://lucioduran.com/blog/web-components-shadow-dom-custom-elements-2026#question-2","content":"Yes, React 19+ has full custom element support including property passing (not just attributes), event handling, and ref forwarding. The interop story is finally solid after years of workarounds."},{"heading":"Do Web Components replace React/Vue/Svelte?","level":3,"url":"https://lucioduran.com/blog/web-components-shadow-dom-custom-elements-2026#question-3","content":"No. Web Components are a platform primitive for encapsulated, reusable UI elements. Frameworks provide state management, routing, and application architecture. The best use case for Web Components is design system components consumed across different framework teams."},{"heading":"Tips","level":2,"url":"https://lucioduran.com/blog/web-components-shadow-dom-custom-elements-2026#article-tips","content":"1. **Tip:** Use ` ::part() ` for theming, not CSS custom properties for everything. Parts give consumers targeted styling control without exposing your internal DOM structure.\n2. **Tip:** Always set ` formAssociated = true ` and implement ` ElementInternals ` if your component wraps a form input. Without it, your component is invisible to ` <form> ` submission and validation.\n3. **Tip:** Test with Declarative Shadow DOM SSR by running ` getInnerHTML({includeShadowRoots: true}) ` and verifying the output hydrates correctly. Many components break because they assume ` connectedCallback ` runs before shadow DOM exists.\n\n- web-components\n- shadow-dom\n- custom-elements\n- lit\n- css-parts\n- declarative-shadow-dom\n- frontend\n- interop\n\n[Back to Blog](<https://lucioduran.com/blog>)"}],"links":[{"text":"Español","url":"https://lucioduran.com/es/blog/web-components-shadow-dom-custom-elements-2026"},{"text":"Lit","url":"https://lit.dev/"},{"text":"Back to Blog","url":"https://lucioduran.com/blog"}],"structuredData":[{"@context":"https://schema.org","@graph":[{"@type":"BlogPosting","@id":"https://lucioduran.com/blog/web-components-shadow-dom-custom-elements-2026#article","mainEntityOfPage":{"@type":"WebPage","@id":"https://lucioduran.com/blog/web-components-shadow-dom-custom-elements-2026"},"headline":"Web Components in 2026: Declarative Shadow DOM, CSS Parts, and Framework Interoperability","description":"Declarative Shadow DOM, CSS parts, Lit 4.0, and universal custom element support. An assessment of Web Component maturity in 2026, covering SSR, framework interop, and remaining friction points.","datePublished":"2025-10-13","author":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"publisher":{"@type":"Person","@id":"https://lucioduran.com/#person","name":"Lucio Durán","url":"https://lucioduran.com"},"image":"https://lucioduran.com/favicons/og-default.png","url":"https://lucioduran.com/blog/web-components-shadow-dom-custom-elements-2026","inLanguage":"en","isPartOf":{"@id":"https://lucioduran.com/blog#blog"},"articleSection":"Frontend","keywords":"web-components, shadow-dom, custom-elements, lit, css-parts, declarative-shadow-dom, frontend, interop","wordCount":1433,"timeRequired":"PT8M","workTranslation":[{"@type":"BlogPosting","inLanguage":"es","url":"https://lucioduran.com/es/blog/web-components-shadow-dom-custom-elements-2026"}]},{"@type":"BreadcrumbList","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"https://lucioduran.com/"},{"@type":"ListItem","position":2,"name":"Blog","item":"https://lucioduran.com/blog"},{"@type":"ListItem","position":3,"name":"Web Components in 2026: Declarative Shadow DOM, CSS Parts, and Framework Interoperability","item":"https://lucioduran.com/blog/web-components-shadow-dom-custom-elements-2026"}]},{"@type":"FAQPage","mainEntity":[{"@type":"Question","name":"Are Web Components ready for production in 2026?","acceptedAnswer":{"@type":"Answer","text":"Yes. All major browsers support custom elements v1, Shadow DOM, Declarative Shadow DOM, CSS parts, and the ElementInternals API. The ecosystem (Lit 4.0, Stencil, FAST) is mature. The main remaining friction is SSR hydration in non-Lit frameworks."}},{"@type":"Question","name":"Can I use Web Components with React?","acceptedAnswer":{"@type":"Answer","text":"Yes, React 19+ has full custom element support including property passing (not just attributes), event handling, and ref forwarding. The interop story is finally solid after years of workarounds."}},{"@type":"Question","name":"Do Web Components replace React/Vue/Svelte?","acceptedAnswer":{"@type":"Answer","text":"No. Web Components are a platform primitive for encapsulated, reusable UI elements. Frameworks provide state management, routing, and application architecture. The best use case for Web Components is design system components consumed across different framework teams."}}]}]}],"contentHash":"1044b0c91785d318ac1acfd337c34b474c88da0dd413ab614d76848b134c0242","provenance":{"method":"Deterministic serialization of the server-rendered main element","source":"https://lucioduran.com/blog/web-components-shadow-dom-custom-elements-2026","hashAlgorithm":"sha256","hashScope":"JSON document fields before contentHash and provenance","sourceHTMLBytes":67166,"contentBytes":13988}}
