Zach’s ugly mug (his face) Zach Leatherman

3 Methods for Scoped Styles in Web Components That Work Everywhere

April 06, 2023

Web components are great. They’re versatile. They can be rendered on the server or (very much less preferably) on the client. They may or may not need interactivity or client-side JavaScript. And unlike heftier legacy frameworks like React, when you need to add interactivity they can still be extremely lightweight by leveraging (dare I say it…) The Platform™ *audible gasp*.

But let’s not get distracted from the task at hand: we want to evaluate a few different options on the table to apply styles (CSS) to our web components.

Here are the things we want to think about:

  1. Encapsulation. We don’t want styles from distinct components to interfere with each other (that’s what this blog post is here for, after all).
  2. Performance. Predictably, a method popularized for CSS encapsulation (CSS-in-JS) was less than ideal because it was slow-by-default. Let’s keep performance at the forefront of our minds.
  3. Browser compatibility. Let’s not go so far out on the cutting edge that we leave some of our visitors behind.
  4. Code re-use without duplication, during both authoring and the output. We want to streamline our output to avoid sending any more code down the wire than we need to.
  5. Client-side framework independent. The methods described in this post do not use any client-side libraries or frameworks.

Methods for encapsulated styles:

  1. Declarative Shadow DOM
  2. Shadow DOM
  3. WebC
  4. Future bonus method: CSS @scope

1. Declarative Shadow DOM

This is the newest kid on the block. And with Safari recently shipping support (in 16.4), only Firefox is now conspicuously missing from the evergreens.

<sample-component>
	Fallback content.
	<template shadowrootmode="open">
		Server rendered Declarative Shadow DOM.
		<style>
		:host {
			text-decoration: underline;
			text-decoration-color: red;
		}
		</style>
	</template>
</sample-component>

Declarative Shadow DOM is ⚠️ not supported in your browser and will require the polyfill.

Before. Fallback content. After.

Note that the underline is restricted to the component and the component only as Declarative Shadow DOM styles are encapsulated for free by the platform! Awesome!

It’s also worth noting that Shadow DOM is not completely isolated from its host page—some styles are inherited! Chris Haynes elaborates in this 2019 post: IndieWeb Avatar for https://lamplightdev.com/Why is my Web Component inheriting styles?

Expand to learn about shadowroot versus shadowrootmode

Astute observers may note that the Can I Use support table is for the shadowroot attribute, the non-streaming version of Declarative Shadow DOM:

  • shadowrootmode Streaming-friendly
  • shadowroot Not streaming friendly (deprecated)

You can use them both together I suppose but rolling with shadowrootmode only is probably your best bet moving forward.

You can view the browser support for shadowrootmode specifically but it is currently inaccurate. I filed a PR to fix it!

The big drawback of Declarative Shadow DOM comes when you have multiple instances of the same component on the page: you need to duplicate the styles and template content in each instance (not ideal).

Expand to see an example.
<sample-component>
	<!-- duplicated -->
	Fallback content.
	<template shadowrootmode="open">
		Server rendered Declarative Shadow DOM.
		<style>
		:host {
			text-decoration: underline;
		}
		</style>
	</template>
</sample-component>
<sample-component>
	<!-- duplicated -->
	Fallback content.
	<template shadowrootmode="open">
		Server rendered Declarative Shadow DOM.
		<style>
		:host {
			text-decoration: underline;
		}
		</style>
	</template>
</sample-component>

It’s worth noting that WebC can assist you when you’re authoring components with Declarative Shadow DOM so that you don’t have to duplicate this template content yourself!

Polyfill and Client-side JavaScript

If you want to add additional clientside interactivity to the component, use the Custom Elements API to do so. This is recommended and required for Declarative Shadow DOM components in Firefox, currently lacking support and requiring a polyfill.

This is less than ideal, as it places a JavaScript dependency on CSS (in Firefox only).

<script>
if("customElements" in window) {
	customElements.define("sample-component", class extends HTMLElement {
		connectedCallback() {
			// polyfill (only applies if needed)
			polyfillDeclarativeShadowDom(this);
		}
	});
}
</script>
Expand to see the Declarative Shadow DOM polyfill code.
// Declarative Shadow DOM polyfill
// Supports both streaming (shadowrootmode) and non-streaming (shadowroot)
function polyfillDeclarativeShadowDom(node) {
	let shadowroot = node.shadowRoot;
	if(!shadowroot) {
		let tmpl = node.querySelector(":scope > template:is([shadowrootmode], [shadowroot])");
		if(tmpl) {
			// default mode is "closed"
			let mode = tmpl.getAttribute("shadowrootmode") || tmpl.getAttribute("shadowroot") || "closed";
			shadowroot = node.attachShadow({ mode });
			shadowroot.appendChild(tmpl.content.cloneNode(true));
		}
	}
}

Summary

  • Performance ★★★☆
    • ✅ Server rendered content.
    • ✅ No JavaScript requirement to apply encapsulated styles (except Firefox, keep reading).
  • Compatibility ★★☆☆
    • This is a very new feature that just shipped in Safari.
    • (Temporary) Firefox requires a JavaScript polyfill, which means that Firefox does have a JS dependency on CSS.
  • Duplication ★☆☆☆
    • ❌ Declarative Shadow DOM template markup is duplicated throughout every component instance. Whew! This is less than ideal but it’s important to remember that it’s still much faster than alternative CSS-in-JS methods.

2. Shadow DOM

This method uses JavaScript to client-render markup into Shadow DOM. Here’s a code sample of how two instances of <sample-component> might look in your editor:

<sample-component>Fallback content.</sample-component>
<sample-component>Fallback content.</sample-component>
<template id="shadow-dom-template">
	Client-rendered Shadow DOM
	<style>
	:host {
		text-decoration: underline;
		text-decoration-color: green;
	}
	</style>
</template>
<script>
if("customElements" in window) {
	customElements.define("sample-component", class extends HTMLElement {
		connectedCallback() {
			let template = document.getElementById("shadow-dom-template");
			let shadowroot = this.attachShadow({ mode: "open" });
			shadowroot.appendChild(template.content.cloneNode(true));
		}
	});
}
</script>
Before Fallback content. Inbetween Fallback content. After

Again the benefit here is that the styles applied do not leak out—they’re encapsulated by Shadow DOM—but we did need to use JavaScript to attach the styles.

Summary

Client-rendering is limiting here. While I wouldn’t be so prescriptive to say that it isn’t useful (some features are secondary/optional after all, depending on your use case and requirements)—but it does heavily limit where the approach can be applied.

  • Performance ★☆☆☆
    • ❌ Client rendered content.
    • ❌ JavaScript requirement to apply encapsulated styles.
  • Compatibility ★★★☆
    • ✅❌ Very broad browser support but I gotta dock one star for JavaScript-generated content, which (even independent of performance) can have further implications for SEO et al.
  • Duplication ★★★★
    • ✅ Only one instance of the template code is required on a page and can be re-used by every component instance.

3. WebC

Haven’t heard of WebC? It’s a single file component format for Web Components—and it works great with Eleventy. Learn more on the WebC docs.

Consider a WebC component file sample-component.webc with the following content:

Server rendered HTML.
<style webc:scoped>
:host {
	text-decoration: underline;
	text-decoration-color: blue;
}
</style>

And on our page we’ll use it twice to show how it scales:

<sample-component>Fallback content.</sample-component>
<sample-component>Fallback content.</sample-component>

This is how the above template renders:

<style>.ws0ljrjcl{text-decoration:underline;text-decoration-color:blue}</style>
<sample-component class="ws0ljrjcl">Server rendered HTML.</sample-component>
<sample-component class="ws0ljrjcl">Server rendered HTML.</sample-component>
Before Server rendered HTML. Inbetween Server rendered HTML. After

As you can see from the rendered code, the webc:scoped feature generates a component specific class name and adds that to the component for encapsulated styles. The class is shared across instances and the component CSS is only added once per page.

This allows you to author your component CSS without additional ceremony and WebC will compile it to CSS that has an extremely wide browser compatibility profile (working in Firefox without JS and even in legacy versions of the evergreens).

Client JavaScript

WebC de-duplicates JS in the same way as CSS too. This means we can add <script> in the component file for our Custom Element client JavaScript and this code will only appear on the page once, no matter how many instances of the component exist on the page.

Expand to see sample-component.webc using the Custom Elements API
Server rendered HTML
<style webc:scoped>
:host {
	text-decoration: underline;
	text-decoration-color: blue;
}
</style>
<script>
if("customElements" in window) {
	window.customElements.define("sample-component", class extends HTMLElement {
		connectedCallback() {
			// Do more things
		}
	});
}
</script>

Summary

I won’t give star ratings to something I built 😅 but I do think WebC allows folks to broaden access to things built with web components without the drawbacks of other methods!

  • Performance
    • ✅ Server rendered content.
    • ✅ No JavaScript requirement to apply encapsulated styles.
  • Compatibility
    • ✅ The broadest browser support.
  • Duplication
    • ✅ The component code lives in the component file, editable in one place. The CSS only appears once on a page, independent of how many times you use the component.

It’s also worth noting here that Declarative Shadow DOM and Shadow DOM methods can be used with WebC too! Have a look at sample WebC components for each of the methods documented here on @11ty/demo-webc-shadow-dom.

4. CSS @scope

While not currently available, platform-support for scoping without Shadow DOM may be coming and IndieWeb Avatar for https://www.oddbird.net/Miriam Suzanne is leading the charge!

Learn more:

Keep an eye on this method—it’s exciting!


< Newer
Stanford WebCamp 2023
Older >
Defaulting on Single Page Applications (SPA)

Zach Leatherman IndieWeb Avatar for https://zachleat.com/is a builder for the web at IndieWeb Avatar for https://cloudcannon.com/CloudCannon. He is the creator and maintainer of IndieWeb Avatar for https://www.11ty.devEleventy (11ty), an award-winning open source site generator. At one point he became entirely too fixated on web fonts. He has given 79 talks in nine different countries at events like Beyond Tellerrand, Smashing Conference, Jamstack Conf, CSSConf, and The White House. Formerly part of Netlify, Filament Group, NEJS CONF, and NebraskaJS. Learn more about Zach »

23 Reposts

IndieWeb Avatar for https://www.alvinashcraft.comIndieWeb Avatar for https://som2nypost.comIndieWeb Avatar for https://dinezh.comIndieWeb Avatar for https://soatdev.comBruce B AndersonLea RosemaLoraine LawsonZach Leatherman :11ty:Rob WoodRoderick GadellaawestbrookJoe Gaffey~/j4v1Hubert SablonnièreThomas Steiner :chrome:Daniel Beutner 🦄sombriksEl Perro NegroLene SaileEvanEleventy 🎈 v2.0.1pgrucza@toot.cafeKeith J Grant

28 Likes

Tyler StickajadonnJeffPaul MasontziLea RosemaRob WooddovydenRoderick GadellaawestbrookJoe GaffeySha RazeekRafatehwill@indieweb.socialFlorian GeierstangerHubert SablonnièreNathan BowersTanner HodgesRustieEl Perro NegroMayankVadim MakeevColinautEvanweltEleventy 🎈 v2.0.1Dave RupertRicky de Laveaga
7 Comments
  1. Stuart Langridge

    Stuart Langridge

    @zachleat I was thinking, what the hell is this webc thing? Browsers do this now? And then I realised what it is :) Maybe the pros and cons section should have “requires a build step” item and maybe a “requires 11ty” item? But that aside, this is neat and a really useful summary,… Truncated

  2. Zach Leatherman :11ty:

    Zach Leatherman :11ty:

    @sil Good feedback, thank you!

  3. Mayank

    Mayank

    @ZAcHlEAT ONE tHing I'M LOOKinG forWARd tO iN `@SCoPE` is tHE coNCePT of A "loWer bOuNDArY" WHICH PrevENTs StylEs FRoM LEAkINg into chIlDren

  4. Zach Leatherman :11ty:

    Zach Leatherman :11ty:

    @hi_mayank *yes* that will be excellent! cc @mia

  5. westbrook

    westbrook

    @zachleat I’d love to know more about the actual time difference to go from three starts to one in the performance section for DSD to CSSD. I get the star cost of requiring JS, but for such a trivial component, I’d say the difference would be negligible and for more complex compo… Truncated

  6. westbrook

    westbrook

    @zachleat It would also be cool to see if the duplication costs you‘ve listed are dev time or view time expenses. With a tool, like 11ty, you’re not quoted to duplicate that data yourself and with gzip/brotli the actual file size costs of that duplication seem to be mostly nullif… Truncated

  7. westbrook

    westbrook

    @hi_mayank @zachleat is this not what the next level of shadow DOM is for!?! 🤪

Shamelessly plug your related post

These are webmentions via the IndieWeb and webmention.io.

Sharing on social media?

This is what will show up when you share this post on Social Media:

How did you do this? I automated my Open Graph images. (Peer behind the curtain at the test page)