{"id":9446,"date":"2026-04-23T11:20:23","date_gmt":"2026-04-23T16:20:23","guid":{"rendered":"https:\/\/frontendmasters.com\/blog\/?p=9446"},"modified":"2026-04-23T11:20:25","modified_gmt":"2026-04-23T16:20:25","slug":"constructable-stylesheets-and-adoptedstylesheets-one-parse-every-shadow-root","status":"publish","type":"post","link":"https:\/\/frontendmasters.com\/blog\/constructable-stylesheets-and-adoptedstylesheets-one-parse-every-shadow-root\/","title":{"rendered":"Constructable Stylesheets and adoptedStyleSheets: One Parse, Every Shadow Root"},"content":{"rendered":"\n<p>Building Web Components with <a href=\"https:\/\/lit.dev\/\">Lit<\/a> means navigating a gauntlet of gotchas. <\/p>\n\n\n\n<p>If you aren&#8217;t using Constructable Stylesheets, you\u2019re likely fighting a losing battle against the browser&#8217;s memory overhead and redundant style tags. They&#8217;re the browser-native way to efficiently share styles across Shadow Roots. Here&#8217;s how they work end-to-end.<\/p>\n\n\n\n<p>Two terms to get straight before we dive in:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Constructable Stylesheets<\/strong> are <code>CSSStyleSheet<\/code> objects you create directly in JavaScript, without a <code>&lt;style&gt;<\/code> tag or a <code>&lt;link&gt;<\/code> element.<\/li>\n\n\n\n<li><strong><code>adoptedStyleSheets<\/code><\/strong> is the browser API that attaches those objects to a shadow root or to the document itself.<\/li>\n<\/ul>\n\n\n\n<figure class=\"wp-block-image size-large\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"657\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/04\/577077489-b777b41d-2d0a-4820-b95a-48ebfd770bdf.png?resize=1024%2C657&#038;ssl=1\" alt=\"One parse illustration\" class=\"wp-image-9461\" srcset=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/04\/577077489-b777b41d-2d0a-4820-b95a-48ebfd770bdf.png?resize=1024%2C657&amp;ssl=1 1024w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/04\/577077489-b777b41d-2d0a-4820-b95a-48ebfd770bdf.png?resize=300%2C193&amp;ssl=1 300w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/04\/577077489-b777b41d-2d0a-4820-b95a-48ebfd770bdf.png?resize=768%2C493&amp;ssl=1 768w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/04\/577077489-b777b41d-2d0a-4820-b95a-48ebfd770bdf.png?resize=1536%2C986&amp;ssl=1 1536w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/04\/577077489-b777b41d-2d0a-4820-b95a-48ebfd770bdf.png?w=1558&amp;ssl=1 1558w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/figure>\n\n\n\n<p>The core benefit is simple and concrete: the browser parses a stylesheet once, then shares that single parsed object across every instance of your component. Mount 200 <code>&lt;ag-button&gt;<\/code> elements (that&#8217;s <a href=\"https:\/\/www.agnosticui.com\/components\/button.html\">the button element<\/a> in my design system library <a href=\"https:\/\/www.agnosticui.com\/\">AgnosticUI<\/a>) and the CSS is parsed exactly <em>once<\/em>. Compare that to the old approach of injecting a <code>&lt;style&gt;<\/code> tag into each shadow root, which triggered a full CSS parse per instance.<\/p>\n\n\n\n<p>For a component library with ~55 components and shared style modules, that difference compounds. This article covers how the raw API works, how <a href=\"https:\/\/lit.dev\/\">Lit<\/a> exploits it on your behalf, and what that looks like in practice. We&#8217;ll use <a href=\"https:\/\/www.agnosticui.com\/\">AgnosticUI v2<\/a> as a concrete reference throughout, but the patterns apply whether you&#8217;re maintaining a full design system or just building a few custom elements for your own app. We&#8217;ll also get into where the platform still has rough edges (SSR serialization, <code>@layer<\/code> interplay, CSS Module Script bundler support), but those are the footnotes, not the headline.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What the Raw API Looks Like<\/h2>\n\n\n\n<p>Before looking at what Lit does with these, it helps to see the raw browser API directly.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-1\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-comment\">\/\/ Create a stylesheet object \u2014 no DOM, no &lt;style&gt; tag<\/span>\n<span class=\"hljs-keyword\">const<\/span> sheet = <span class=\"hljs-keyword\">new<\/span> CSSStyleSheet();\n\n<span class=\"hljs-comment\">\/\/ Populate it (synchronous)<\/span>\nsheet.replaceSync(<span class=\"hljs-string\">`\n  button { background: hotpink; cursor: pointer; }\n`<\/span>);\n\n<span class=\"hljs-comment\">\/\/ Or populate it asynchronously (accepts @import, external resources)<\/span>\n<span class=\"hljs-keyword\">await<\/span> sheet.replace(<span class=\"hljs-string\">`@import url('\/tokens.css'); button { ... }`<\/span>);\n\n<span class=\"hljs-comment\">\/\/ Attach it to a shadow root<\/span>\n<span class=\"hljs-keyword\">this<\/span>.shadowRoot.adoptedStyleSheets = &#91;sheet];\n\n<span class=\"hljs-comment\">\/\/ Or attach it to the document itself<\/span>\n<span class=\"hljs-built_in\">document<\/span>.adoptedStyleSheets = &#91;sheet];<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-1\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Three things are worth understanding here. First, there&#8217;s no parsing on adoption: the stylesheet is parsed once when you call <code>replaceSync<\/code> or <code>replace<\/code>, and adopting it into a shadow root is a reference assignment, not a re-parse. Second, the reference is shared: you can assign the same <code>CSSStyleSheet<\/code> object to multiple shadow roots, and they all share one parsed rule tree, so a mutation via <code>sheet.replaceSync(...)<\/code> propagates to every adopter immediately. Third, document scope works too: <code>document.adoptedStyleSheets<\/code> is valid, meaning you can inject global styles without a <code>&lt;link&gt;<\/code> or <code>&lt;style&gt;<\/code> tag. This is ideal for instant dynamic theming, flicker-free style updates, and syncing branding across micro-frontends.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Inspecting Constructable Stylesheets in DevTools<\/h3>\n\n\n\n<figure class=\"wp-block-image size-large\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"720\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/04\/577078804-e2fe32c8-5dec-4c62-a766-e3ace41d4e28-1024x720.png?resize=1024%2C720&#038;ssl=1\" alt=\"AgnosticUI Example of Inspecting Constructable Stylesheets in DevTools\" class=\"wp-image-9463\" srcset=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/04\/577078804-e2fe32c8-5dec-4c62-a766-e3ace41d4e28.png?resize=1024%2C720&amp;ssl=1 1024w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/04\/577078804-e2fe32c8-5dec-4c62-a766-e3ace41d4e28.png?resize=300%2C211&amp;ssl=1 300w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/04\/577078804-e2fe32c8-5dec-4c62-a766-e3ace41d4e28.png?resize=768%2C540&amp;ssl=1 768w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/04\/577078804-e2fe32c8-5dec-4c62-a766-e3ace41d4e28.png?w=1288&amp;ssl=1 1288w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/figure>\n\n\n\n<p>Chrome DevTools has supported inspecting and editing constructable stylesheets since Chrome 85. Here&#8217;s where to find them:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Open DevTools and select the <strong>Elements<\/strong> panel.<\/li>\n\n\n\n<li>Click on a custom element (e.g., <code>&lt;ag-button&gt;<\/code>). Expand its shadow root in the DOM tree.<\/li>\n\n\n\n<li>In the <strong>Styles<\/strong> pane on the right, rules from adopted stylesheets appear alongside rules from regular stylesheets. They have no file URL link; instead, they show a <code>constructed stylesheet<\/code> source label (or appear as an editable rule block with no filename).<\/li>\n\n\n\n<li>For the document-level <code>adoptedStyleSheets<\/code>, select <code>&lt;html&gt;<\/code> or <code>&lt;body&gt;<\/code> and look in the Styles pane the same way.<\/li>\n\n\n\n<li>In the <strong>Sources<\/strong> panel, constructed stylesheets appear listed under the page&#8217;s origin without a file path. You can click them to view the full CSS text and set breakpoints on style mutations.<\/li>\n<\/ol>\n\n\n\n<h2 class=\"wp-block-heading\">What Lit Does with <code>static styles<\/code><\/h2>\n\n\n\n<p>The raw API we looked at earlier is pretty low-level, but thankfully, Lit handles all of it for you. When you write this in a Lit component:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-2\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-keyword\">import<\/span> { LitElement, css } <span class=\"hljs-keyword\">from<\/span> <span class=\"hljs-string\">'lit'<\/span>;\n\n<span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-class\"><span class=\"hljs-keyword\">class<\/span> <span class=\"hljs-title\">AgButton<\/span> <span class=\"hljs-keyword\">extends<\/span> <span class=\"hljs-title\">LitElement<\/span> <\/span>{\n  <span class=\"hljs-keyword\">static<\/span> styles = css`<span class=\"css\">\n    <span class=\"hljs-selector-tag\">button<\/span> { <span class=\"hljs-attribute\">background<\/span>: <span class=\"hljs-built_in\">var<\/span>(--ag-primary); <span class=\"hljs-attribute\">color<\/span>: <span class=\"hljs-built_in\">var<\/span>(--ag-primary-fg); }\n  `<\/span>;\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-2\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>The <code>css<\/code> tagged template literal doesn&#8217;t return a string. It returns a <code>CSSResult<\/code> object, which is Lit&#8217;s wrapper around the raw CSS text. The actual <code>CSSStyleSheet<\/code> isn&#8217;t created eagerly at class definition time. Lit&#8217;s lifecycle splits this into two distinct phases, both of which were verified against <code><a href=\"https:\/\/github.com\/lit\/lit\/blob\/3e54ba22b24bdd6f5e29e74534aeab65d08c669e\/packages\/reactive-element\/src\/reactive-element.ts\">reactive-element.ts<\/a><\/code>:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-3\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-comment\">\/\/ Phase 1: finalize() \u2014 runs once per class at registration time.<\/span>\n<span class=\"hljs-comment\">\/\/ Flattens and deduplicates the styles array. No CSSStyleSheet created yet.<\/span>\n<span class=\"hljs-keyword\">static<\/span> finalize() {\n  <span class=\"hljs-keyword\">this<\/span>.elementStyles = <span class=\"hljs-keyword\">this<\/span>.finalizeStyles(<span class=\"hljs-keyword\">this<\/span>.styles);\n}\n\n<span class=\"hljs-comment\">\/\/ Phase 2: createRenderRoot() \u2014 runs once on the first instance's DOM connection.<\/span>\n<span class=\"hljs-comment\">\/\/ This is where the CSSStyleSheet is lazily created and cached on the CSSResult.<\/span>\n<span class=\"hljs-comment\">\/\/ Every subsequent instance reuses that same cached reference.<\/span>\nprotected createRenderRoot() {\n  <span class=\"hljs-keyword\">const<\/span> renderRoot =\n    <span class=\"hljs-keyword\">this<\/span>.shadowRoot ??\n    <span class=\"hljs-keyword\">this<\/span>.attachShadow(<span class=\"hljs-keyword\">this<\/span>.constructor.shadowRootOptions);\n  adoptStyles(renderRoot, <span class=\"hljs-keyword\">this<\/span>.constructor.elementStyles);\n  <span class=\"hljs-keyword\">return<\/span> renderRoot;\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-3\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Phase 1 is triggered by <code>customElements.define()<\/code>: the browser calls the <code>observedAttributes<\/code> getter, which triggers <code>finalize()<\/code>. At this point, <code>finalizeStyles()<\/code> flattens any nested style arrays and deduplicates via a <code>Set<\/code>, but no <code>CSSStyleSheet<\/code> object is created yet. Phase 2 happens lazily the first time an instance connects to the DOM. Inside <code>adoptStyles()<\/code>, the <code>.styleSheet<\/code> getter on each <code>CSSResult<\/code> calls <code>new CSSStyleSheet()<\/code> and <code>replaceSync()<\/code> on first access, then caches the result. Every instance after that gets a reference to the same cached object.<\/p>\n\n\n\n<p>The result: <strong>one <code>CSSStyleSheet<\/code> per component class, created on the first instance&#8217;s render, shared by all subsequent instances.<\/strong> <code>createRenderRoot()<\/code> runs once per instance, but <code>new CSSStyleSheet()<\/code> is called exactly once total. So, each shadow root receives a reference to the same cached object.<\/p>\n\n\n\n<p class=\"learn-more\">Note: These implementation details reflect Lit\u2019s architecture at the time of writing.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\"><code>static styles<\/code>: How Lit Makes Style Composition Effortless<\/h2>\n\n\n\n<p><code>static styles<\/code> can be a single <code>CSSResult<\/code> or an array of them. Either way, the DX is refreshingly simple: you write CSS, Lit quietly handles the deduplication, caching, and lifecycle management under the hood. The array form takes it further, letting you compose shared stylesheets across components with minimal ceremony.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Shared Style Modules<\/h3>\n\n\n\n<p>In AgnosticUI, label layout, error text, helper text, and required indicators are identical across every form component. Rather than copying that CSS into each component, they share a single <code>CSSResult<\/code>:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-4\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-comment\">\/\/ v2\/lib\/src\/shared\/form-control-styles.ts<\/span>\n<span class=\"hljs-keyword\">import<\/span> { css } <span class=\"hljs-keyword\">from<\/span> <span class=\"hljs-string\">'lit'<\/span>;\n\n<span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-keyword\">const<\/span> formControlStyles = css`<span class=\"css\">\n  <span class=\"hljs-selector-class\">.ag-form-control__label<\/span> { ... }\n  <span class=\"hljs-selector-class\">.ag-form-control__error<\/span> { ... }\n  <span class=\"hljs-selector-class\">.ag-form-control__helper<\/span> { ... }\n`<\/span>;<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-4\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Input, Toggle, Checkbox, Radio, and Select each compose it in:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-5\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-comment\">\/\/ v2\/lib\/src\/components\/Input\/core\/_Input.ts<\/span>\n<span class=\"hljs-keyword\">import<\/span> { formControlStyles } <span class=\"hljs-keyword\">from<\/span> <span class=\"hljs-string\">'..\/..\/..\/shared\/form-control-styles.js'<\/span>;\n\n<span class=\"hljs-keyword\">export<\/span> <span class=\"hljs-class\"><span class=\"hljs-keyword\">class<\/span> <span class=\"hljs-title\">AgInput<\/span> <span class=\"hljs-keyword\">extends<\/span> <span class=\"hljs-title\">LitElement<\/span> <\/span>{\n  <span class=\"hljs-keyword\">static<\/span> styles = &#91;\n    formControlStyles,\n    css`<span class=\"css\">\n      <span class=\"hljs-selector-pseudo\">:host<\/span> { <span class=\"hljs-attribute\">display<\/span>: block; }\n      <span class=\"hljs-comment\">\/* Input-specific rules ... *\/<\/span>\n    `<\/span>,\n  ];\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-5\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Lit deduplicates across the array: if the same <code>CSSResult<\/code> reference appears in multiple places (including up a class hierarchy), it only creates one <code>CSSStyleSheet<\/code> for it. This deduplication is done in <code><a href=\"https:\/\/github.com\/lit\/lit\/blob\/3e54ba22b24bdd6f5e29e74534aeab65d08c669e\/packages\/reactive-element\/src\/reactive-element.ts\">finalizeStyles()<\/a><\/code> via a <code>Set<\/code> that flattens and deduplicates the styles array before storing it.<\/p>\n\n\n\n<p>To make this concrete: a form using <code>&lt;ag-input&gt;<\/code>, <code>&lt;ag-toggle&gt;<\/code>, and <code>&lt;ag-select&gt;<\/code> together has all three components sharing <code>formControlStyles<\/code>, yet the browser holds exactly one <code>CSSStyleSheet<\/code> for it. One, total, for the entire session.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">DevTools Demo: Per-Instance Sharing and Live Mutation<\/h3>\n\n\n\n<div class=\"wp-block-cp-codepen-gutenberg-embed-block cp_embed_wrapper\"><iframe id=\"cp_embed_019d86ec-a96e-7e51-b29b-da147ee01db5\" src=\"\/\/codepen.io\/editor\/anon\/embed\/019d86ec-a96e-7e51-b29b-da147ee01db5?height=450&amp;theme-id=1&amp;slug-hash=019d86ec-a96e-7e51-b29b-da147ee01db5&amp;default-tab=result\" height=\"450\" scrolling=\"no\" frameborder=\"0\" allowfullscreen allowpaymentrequest name=\"CodePen Embed 019d86ec-a96e-7e51-b29b-da147ee01db5\" title=\"CodePen Embed 019d86ec-a96e-7e51-b29b-da147ee01db5\" class=\"cp_embed_iframe\" style=\"width:100%;overflow:hidden\">CodePen Embed Fallback<\/iframe><\/div>\n\n\n\n<p>The Pen below proves two things: <\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Many instances of the same component all share one <code>CSSStyleSheet<\/code> rather than each holding a copy.<\/li>\n\n\n\n<li>Mutating the sheet via <code>replaceSync()<\/code> propagates to every instance simultaneously.<\/li>\n<\/ol>\n\n\n\n<p>Here&#8217;s the code from the Pen:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-6\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-class\"><span class=\"hljs-keyword\">class<\/span> <span class=\"hljs-title\">DemoButton<\/span> <span class=\"hljs-keyword\">extends<\/span> <span class=\"hljs-title\">HTMLElement<\/span> <\/span>{\n  <span class=\"hljs-keyword\">static<\/span> sheet = <span class=\"hljs-function\">(<span class=\"hljs-params\">(<\/span>) =&gt;<\/span> {\n    <span class=\"hljs-keyword\">const<\/span> s = <span class=\"hljs-keyword\">new<\/span> CSSStyleSheet();\n    s.replaceSync(<span class=\"hljs-string\">`\n      :host { display: inline-block; margin: 2px; }\n      button { background: hotpink; padding: 8px 16px; border: none; cursor: pointer; color: white; }\n    `<\/span>);\n    <span class=\"hljs-keyword\">return<\/span> s;\n  })();\n\n  <span class=\"hljs-keyword\">constructor<\/span>() {\n    <span class=\"hljs-keyword\">super<\/span>();\n    <span class=\"hljs-keyword\">this<\/span>.attachShadow({ <span class=\"hljs-attr\">mode<\/span>: <span class=\"hljs-string\">'open'<\/span> });\n  }\n\n  connectedCallback() {\n    <span class=\"hljs-keyword\">this<\/span>.shadowRoot.adoptedStyleSheets = &#91;DemoButton.sheet];\n    <span class=\"hljs-keyword\">this<\/span>.shadowRoot.innerHTML = <span class=\"hljs-string\">`&lt;button&gt;<span class=\"hljs-subst\">${<span class=\"hljs-keyword\">this<\/span>.getAttribute(<span class=\"hljs-string\">'label'<\/span>) || <span class=\"hljs-string\">'Click'<\/span>}<\/span>&lt;\/button&gt;`<\/span>;\n  }\n}\n\n<span class=\"hljs-keyword\">if<\/span> (!customElements.get(<span class=\"hljs-string\">'demo-button'<\/span>)) {\n  customElements.define(<span class=\"hljs-string\">'demo-button'<\/span>, DemoButton);\n}\n\n<span class=\"hljs-keyword\">const<\/span> app = <span class=\"hljs-built_in\">document<\/span>.getElementById(<span class=\"hljs-string\">'app'<\/span>);<span class=\"hljs-keyword\">for<\/span> (<span class=\"hljs-keyword\">let<\/span> i = <span class=\"hljs-number\">0<\/span>; i &lt; <span class=\"hljs-number\">50<\/span>; i++) {\n  <span class=\"hljs-keyword\">const<\/span> btn = <span class=\"hljs-built_in\">document<\/span>.createElement(<span class=\"hljs-string\">'demo-button'<\/span>);\n  btn.setAttribute(<span class=\"hljs-string\">'label'<\/span>, <span class=\"hljs-string\">`Button <span class=\"hljs-subst\">${i + <span class=\"hljs-number\">1<\/span>}<\/span>`<\/span>);\n  app.appendChild(btn);\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-6\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>If you&#8217;d like to follow along, you may <a href=\"https:\/\/codepen.io\/editor\/roblevin\/pen\/019d86ec-a96e-7e51-b29b-da147ee01db5\">open the above Pen<\/a> and then open DevTools and repeat the following steps:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Inspect the &#8220;Constructed&#8221; Source: In the Elements panel, expand any <code>&lt;demo-button&gt;<\/code> shadow root and select the <code>&lt;button&gt;<\/code>. In the Styles pane, the rule will be labeled (constructed)\u2014no file path or line number exists because it&#8217;s purely in-memory.<\/li>\n\n\n\n<li>Verify the Shared Instance: Prove all instances use the same memory reference by running this in the Console:<\/li>\n<\/ol>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-7\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\">   <span class=\"hljs-keyword\">const<\/span> btns = <span class=\"hljs-built_in\">document<\/span>.querySelectorAll(<span class=\"hljs-string\">'demo-button'<\/span>);\n   <span class=\"hljs-keyword\">const<\/span> sheet1 = btns&#91;<span class=\"hljs-number\">0<\/span>].shadowRoot.adoptedStyleSheets&#91;<span class=\"hljs-number\">0<\/span>];\n   <span class=\"hljs-keyword\">const<\/span> sheet2 = btns&#91;<span class=\"hljs-number\">1<\/span>].shadowRoot.adoptedStyleSheets&#91;<span class=\"hljs-number\">0<\/span>];\n   <span class=\"hljs-built_in\">console<\/span>.log(<span class=\"hljs-string\">\"Shared object?\"<\/span>, sheet1 === sheet2); <span class=\"hljs-comment\">\/\/ true<\/span><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-7\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<ol start=\"3\" class=\"wp-block-list\">\n<li>Confirm Live Mutation: Change the &#8220;master&#8221; sheet to see all 50 instances update to blue simultaneously:<\/li>\n<\/ol>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-8\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\">   DemoButton.sheet.replaceSync(<span class=\"hljs-string\">'button { background: steelblue; padding: 8px 16px; border: none; color: white; }'<\/span>);<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-8\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p class=\"learn-more\"><strong>Pro-Tip:<\/strong> Accessing the sheet via <code>DemoButton.sheet<\/code> is the cleanest way to manage updates. It allows you to mutate styles globally across all components without needing to query specific DOM elements or their shadow roots.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">The Performance Story<\/h2>\n\n\n\n<figure class=\"wp-block-image size-large\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"550\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/04\/577074947-2cccbcd7-5143-4ead-bbfe-6266e36d4757.png?resize=1024%2C550&#038;ssl=1\" alt=\"Old approach vs. new illustration\" class=\"wp-image-9464\" srcset=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/04\/577074947-2cccbcd7-5143-4ead-bbfe-6266e36d4757.png?resize=1024%2C550&amp;ssl=1 1024w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/04\/577074947-2cccbcd7-5143-4ead-bbfe-6266e36d4757.png?resize=300%2C161&amp;ssl=1 300w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/04\/577074947-2cccbcd7-5143-4ead-bbfe-6266e36d4757.png?resize=768%2C412&amp;ssl=1 768w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/04\/577074947-2cccbcd7-5143-4ead-bbfe-6266e36d4757.png?resize=1536%2C825&amp;ssl=1 1536w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/04\/577074947-2cccbcd7-5143-4ead-bbfe-6266e36d4757.png?w=1862&amp;ssl=1 1862w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\">What Was True Before Constructable Stylesheets<\/h3>\n\n\n\n<p>The pre-Constructable approach was to inject a <code>&lt;style&gt;<\/code> tag into each shadow root:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-9\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-comment\">\/\/ The old way<\/span>\n<span class=\"hljs-keyword\">const<\/span> style = <span class=\"hljs-built_in\">document<\/span>.createElement(<span class=\"hljs-string\">'style'<\/span>);\nstyle.textContent = cssText;\n<span class=\"hljs-keyword\">this<\/span>.shadowRoot.appendChild(style);<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-9\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Every <code>&lt;style&gt;<\/code> tag meant a full CSS parse, every time. So, a hundred buttons on the page would result in a hundred parses of the exact same CSS.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">What Constructable Stylesheets change<\/h3>\n\n\n\n<p>With <code>adoptedStyleSheets<\/code>, you get:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table class=\"has-fixed-layout\"><tbody><tr><th>Metric<\/th><th>Old <code>&lt;style&gt;<\/code> injection<\/th><th><code>adoptedStyleSheets<\/code><\/th><\/tr><tr><td>Parses per unique stylesheet<\/td><td>One per instance<\/td><td>One per class (ever)<\/td><\/tr><tr><td>Memory per instance<\/td><td>Full rule tree copy<\/td><td>Reference to shared tree<\/td><\/tr><tr><td>Live mutation<\/td><td>Replace <code>&lt;style&gt;<\/code> textContent, re-parse<\/td><td><code>sheet.replaceSync()<\/code>, immediate propagation<\/td><\/tr><tr><td>FOUC risk<\/td><td>Present if injection is async<\/td><td>None: adoption is synchronous once loaded<\/td><\/tr><tr><td>Serialization (SSR)<\/td><td><code>&lt;style&gt;<\/code> in each shadow host<\/td><td>No serialization equivalent (see below)<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<p>For a library like AgnosticUI with ~55 components, each potentially mounted many times, the parse-once guarantee compounds. The <code>formControlStyles<\/code> sheet that backs <code>&lt;ag-input&gt;<\/code>, <code>&lt;ag-toggle&gt;<\/code>, <code>&lt;ag-select&gt;<\/code>, etc. is parsed once for the entire session.<\/p>\n\n\n\n<p>While the performance gain is negligible for small pages, the real value is architectural. It proactively eliminates memory scaling issues and ensures your CSS footprint stays flat, regardless of how many component instances you mount.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">CSS Module Scripts: The Adjacent Standard<\/h2>\n\n\n\n<p><a href=\"https:\/\/web.dev\/articles\/css-module-scripts\">CSS Module Scripts<\/a> are a separate but related spec. Instead of creating a <code>CSSStyleSheet<\/code> imperatively, you import one directly from a <code>.css<\/code> file:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-10\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-keyword\">import<\/span> sheet <span class=\"hljs-keyword\">from<\/span> <span class=\"hljs-string\">'.\/button.css'<\/span> <span class=\"hljs-keyword\">with<\/span> { <span class=\"hljs-attr\">type<\/span>: <span class=\"hljs-string\">'css'<\/span> };\n<span class=\"hljs-keyword\">this<\/span>.shadowRoot.adoptedStyleSheets = &#91;sheet];<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-10\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>If you&#8217;re using Lit, you probably won&#8217;t need them. Lit&#8217;s <code>css<\/code> tag already gives you everything: no bundler configuration, automatic deduplication via <code>finalizeStyles()<\/code>, and SSR compatibility via <code><a href=\"https:\/\/github.com\/lit\/lit\/tree\/main\/packages\/labs\/ssr\">@lit-labs\/ssr<\/a><\/code> which knows how to convert <code>static styles<\/code> to <code>&lt;style><\/code> tags on the server. CSS Module Scripts have no equivalent hook for that.<\/p>\n\n\n\n<p>That said, browser support is solid (Chrome\/Edge, Firefox 127+, Safari 17.2+) and the spec is worth knowing. They&#8217;re most compelling when you want a real <code>.css<\/code> file your editor treats as CSS rather than a tagged template literal in a <code>.ts<\/code> file. The catch is bundler support: Vite&#8217;s closest equivalent is <a href=\"https:\/\/vite.dev\/guide\/features#disabling-css-injection-into-the-page\"><code>?inline<\/code> imports<\/a>, which return a string, not a <code>CSSStyleSheet<\/code>. Worth watching as that story matures.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What You Can&#8217;t Do (and the Gaps That Remain)<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">No SSR Serialization Path<\/h3>\n\n\n\n<p>Constructed stylesheets live in JavaScript, so they can&#8217;t be &#8220;written&#8221; into an HTML response. Lit SSR (<code><a href=\"https:\/\/github.com\/lit\/lit\/tree\/main\/packages\/labs\/ssr\">@lit-labs\/ssr<\/a><\/code>) manages this by injecting <code>&lt;style><\/code> tags on the server, then switching to <code>adoptedStyleSheets<\/code> on the client.<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>This inflates your HTML payload since every shadow host gets its own <code>&lt;style&gt;<\/code> copy, temporarily losing the &#8220;parse-once&#8221; benefit during initial render.<\/li>\n\n\n\n<li>If you bypass Lit&#8217;s static styles and call <code>adoptedStyleSheets<\/code> manually, you lose this automatic fallback and SSR will break.<\/li>\n<\/ul>\n\n\n\n<p class=\"learn-more\">Active proposals like <a href=\"https:\/\/github.com\/WICG\/webcomponents\/issues\/939\">Declarative CSS Module Scripts<\/a> aim to bridge this gap. For a deep dive into the real-world trade-offs, check out the <a href=\"https:\/\/github.com\/shoelace-style\/shoelace\/issues\/778\">Shoelace community discussion<\/a>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">No <code>@layer<\/code> Integration (Yet)<\/h3>\n\n\n\n<p>While you can use <a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/CSS\/@layer\">CSS Cascade Layers<\/a> inside a sheet, there\u2019s currently no way for a consumer to tell a component which layer its adopted styles should belong to from the outside. It\u2019s a missing piece for advanced global style orchestration.<\/p>\n\n\n\n<p class=\"learn-more\">CSSWG appears to be actively discussing this in <a href=\"https:\/\/github.com\/w3c\/csswg-drafts\/issues\/10176\">issue #10176<\/a><em>.<\/em><\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Live Mutation: Powerful But Constrained<\/h3>\n\n\n\n<p>Since every component instance shares a single reference, live mutation calls like <code>sheet.replaceSync(newCss)<\/code> update every instance at once. Lit doesn&#8217;t expose this directly because &#8220;all-or-nothing&#8221; updates are rarely what you want. For per-instance overrides, probably just stick to CSS Custom Properties or <code>::part<\/code>.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">A Note on Global Token Injection<\/h2>\n\n\n\n<p>One scenario where going below Lit&#8217;s abstraction might be useful is <strong>global design token injection<\/strong> without a <code>&lt;link&gt;<\/code> tag:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-11\" data-shcb-language-name=\"JavaScript\" data-shcb-language-slug=\"javascript\"><span><code class=\"hljs language-javascript\"><span class=\"hljs-comment\">\/\/ Inject token CSS into the document once at app startup<\/span>\n<span class=\"hljs-keyword\">const<\/span> tokenSheet = <span class=\"hljs-keyword\">new<\/span> CSSStyleSheet();\n<span class=\"hljs-keyword\">await<\/span> tokenSheet.replace(<span class=\"hljs-string\">`\n  :root {\n    --ag-primary: #5c73f2;\n    --ag-primary-dark: #3a52e0;\n  }\n`<\/span>);\n<span class=\"hljs-built_in\">document<\/span>.adoptedStyleSheets = &#91;\n  ...document.adoptedStyleSheets,\n  tokenSheet,\n];<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-11\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">JavaScript<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">javascript<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>This is one parsed sheet, available to all shadow roots and regular DOM nodes, with no <code>&lt;link&gt;<\/code> tag required.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Conclusion<\/h2>\n\n\n\n<p>Constructable Stylesheets give you one parsed stylesheet shared across every instance, instead of one <code>&lt;style&gt;<\/code> tag per instance. For a codebase with shared style modules like <code>formControlStyles<\/code>, that adds up.<\/p>\n\n\n\n<p>Lit&#8217;s <code>css<\/code> tag and <code>static styles<\/code> handle deduplication, lifecycle management, SSR fallback, and style composition for you. There are no raw <code>adoptedStyleSheets<\/code> calls in AgnosticUI because there doesn&#8217;t need to be.<\/p>\n\n\n\n<p>The win scales with usage. While the gain is barely noticeable for a single component, it compounds once you mount dozens of instances across your app.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Further Reading<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\">The Core API<\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"https:\/\/web.dev\/articles\/constructable-stylesheets\">Constructable Stylesheets Explainer<\/a> (web.dev): full API walkthrough<\/li>\n\n\n\n<li><a href=\"https:\/\/developer.mozilla.org\/en-US\/docs\/Web\/API\/CSSStyleSheet\">CSS Object Model: CSSStyleSheet<\/a> (MDN): complete reference for the raw API<\/li>\n\n\n\n<li><a href=\"https:\/\/web.dev\/articles\/css-module-scripts\">CSS Module Scripts<\/a> (web.dev): the adjacent spec for importing stylesheets from <code>.css<\/code> files<\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">Lit &amp; Frameworks<\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"https:\/\/lit.dev\/docs\/components\/styles\/\">Lit: Styles<\/a> (lit.dev): how Lit wraps Constructable Stylesheets with <code>static styles<\/code><\/li>\n<\/ul>\n\n\n\n<h3 class=\"wp-block-heading\">Future Specs &amp; Gaps<\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"https:\/\/github.com\/w3c\/csswg-drafts\/issues\/10176\">CSSWG issue #10176<\/a> (GitHub): no way to declare which <code>@layer<\/code> an <code>adoptedStyleSheets<\/code> falls into<\/li>\n\n\n\n<li><a href=\"https:\/\/github.com\/WICG\/webcomponents\/issues\/939\">Declarative CSS Module Scripts proposal<\/a> (WICG): SSR has no native serialization path for adopted sheets<\/li>\n\n\n\n<li><a href=\"https:\/\/github.com\/shoelace-style\/shoelace\/issues\/778\">Shoelace SSR + adoptedStyleSheets discussion<\/a> (GitHub): community deep-dive on the SSR tradeoffs<\/li>\n<\/ul>\n","protected":false},"excerpt":{"rendered":"<p>If you have any shared styles across multiple shadow DOMs (imagine 20 custom button components), a Constructable Stylesheets is just way more efficient.<\/p>\n","protected":false},"author":45,"featured_media":9451,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_acf_changed":false,"sig_custom_text":"","sig_image_type":"featured-image","sig_custom_image":0,"sig_is_disabled":false,"inline_featured_image":false,"_jetpack_memberships_contains_paid_content":false,"footnotes":""},"categories":[1],"tags":[475,7,382,68,36],"class_list":["post-9446","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-blog-post","tag-constuctable-stylesheets","tag-css","tag-lit","tag-shadow-dom","tag-web-components"],"acf":[],"jetpack_featured_media_url":"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/04\/one-to-many.jpg?fit=2000%2C1200&ssl=1","jetpack_sharing_enabled":true,"_links":{"self":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts\/9446","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/users\/45"}],"replies":[{"embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/comments?post=9446"}],"version-history":[{"count":8,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts\/9446\/revisions"}],"predecessor-version":[{"id":9468,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts\/9446\/revisions\/9468"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/media\/9451"}],"wp:attachment":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/media?parent=9446"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/categories?post=9446"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/tags?post=9446"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}