This content originally appeared on dbushell.com (blog) and was authored by dbushell.com (blog)
In my Anatomy of a Web Component I stopped short of shadow DOM and styles. In this post I’ll share how I’ve tackled that problem with adopted stylesheets.
You don’t need a “styled components” framework or hundreds of utility classes. CSS has everything you need! A basic adherence to classes provides encapsulation. If that’s a struggle then @scope
is around the corner. Shadow DOM offers its own CSS encapsulation with only inherited styles leaking through (a feature, not a bug).
Example Component
To illustrate this experiment I’ll use a basic custom element with a declarative shadow DOM. The same idea can apply to attached shadow DOM too.
<example-component>
<template shadowrootmode="open">
<h1>Example Component</h1>
<p>With a composable adopted stylesheet!</p>
</template>
</example-component>
There are several ways to add styles to this shadow DOM. More if I’m willing to entertain fragile build systems (I’m not). I’m a big fan of separation of concerns. HTML in a .html
document (I’ll allow .php
you’re cool), JavaScript in a .js
file, and CSS in a .css
stylesheet.
For a typical website my CSS directory is a long list similar to:
- reset.css
- typography.css
- header.css
- footer.css
- example-component.css
- contact-form.css
- and more…
Traditionally I would bundle all these files into a single stylesheet for performance reasons. I still would for the root HTML document of any web app, excluding any individual component-specific shadow DOM styles.
My <example-component>
shadow DOM needs a small subset:
- reset.css
- typography.css
- example-component.css
What’s the easiest way to include those styles without a complicated build system?
Option #1
Just @import
everything into one stylesheet (bundled or not) and adopt that into every shadow DOM. This is honestly worth testing if you’ve only a handful of custom elements and well coded, lean and mean CSS. Don’t optimise too early!
Option #2
Another solution is to append a <style>
element to the shadow DOM and import the CSS files required. We could add this declaratively or append it to the shadowRoot
.
<style>
@import "/css/reset.css";
@import "/css/typography.css";
@import "/css/example-component.css";
</style>
This works but it can have performance issues at the nebulous “scale”. If you have tens or even hundreds of custom elements each <style>
has to be parsed. This adds noticable FOUC (flash of unstyled content). DOM changes with a large number of elements can be painfully slow.
Option #3
Let’s look at some pseudo-code for the <example-component>
to imagine how a composable stylesheet could be adopted.
class Component extends HTMLElement {
static tag = "example-component";
static styleParts = ["reset", "typography", Component.tag];
static styleSheet = fetchCSS(Component.styleParts);
static {
customElements.define(Component.tag, Component);
}
connectedCallback() {
if (this.shadowRoot.adoptedStyleSheets.length === 0) {
Component.styleSheet.then((sheet) => {
this.shadowRoot.adoptedStyleSheets = [sheet];
});
}
}
}
In this code we define an array of CSS imports in the styleParts
static field. We also set the static styleSheet
field to whatever this magical fetchCSS
returns.
Inside connectedCallback
we can surmise that styleSheet
is a promise that resolves a CSSStyleSheet
that is adopted by the shadow DOM. If a stylesheet has already been adopted nothing is done.
Below is a naive implementation of fetchCSS
to show the basic idea.
async function fetchCSS(parts = []) {
let css = "";
for (const name of parts) {
css += await (await fetch(`/css/${name}.css`)).text();
}
const sheet = new CSSStyleSheet();
sheet.replaceSync(css);
return sheet;
}
This function iterates the parts one by one, fetches them, and concatenates a string. Finally a CSSStyleSheet
is created from the combined CSS text.
A more robust implementation would gracefully handle fetch errors. The browser cache should be enough to handle elements that use the same parts.
This technique is much more performant that option #2. The styles for each type of custom element are generated and parsed only once. Multiple instances of the same custom element will adopt the same stylesheet without redoing expensive browser work.
FOUC is minimised and practically a non-issue from my experience. You could use element internals state to hide components until the stylesheet is adopted.
Performance and Optimisations
This “no build” approach using multiple single purpose CSS files works well — until it doesn’t. It works perfectly well for a dozen stylesheets and components. Try to load 100 upfront and you’ll suffer. Commonly used parts could be pre-bundled to reduce request count.
fetchCSS
could be parallelised. Care would be needed to ensure CSS imports are stitched together in the specified order (assuming source order matters).
Is concatenation in fetchCSS
even necessary? It’s possible adopting multiple constructed stylesheets is faster if they’re cached.
Common and critical stylesheets can be preloaded.
<link rel="preload" as="style" href="/css/reset.css">
<link rel="preload" as="style" href="/css/typography.css">
<link rel="preload" as="style" href="/css/example-component.css">
This is not free performance. You’ll need to test how many preloads are optimal.
Following the initial page load we could fetch other stylesheets to ensure they’re cached before use. The Service Worker would be a good place to do that lazily.
Adopted stylesheets are measurably faster than using <style>
or <link>
elements. Whether option #3 outperforms option #1 is another question!
I like this approach because it allows for a very nice “no build” no-nonsense developer experience. If you’re building a Tauri web app (or Electron) the network request cost can be discounted by including the assets locally.
What do you think? Is this over-engineered, under-engineered? Why am I even writing CSS myself like an LLM deprived caveman when Tailwind has solved it?
This content originally appeared on dbushell.com (blog) and was authored by dbushell.com (blog)