Skip to content

Eschewing Shadow DOM

Publish date: Author: Heydon

Regarding styling, Shadow DOM is really good at one thing: preventing per-component styles from leaking out and affecting other parts of the document.

Unfortunately, Shadow DOM also has a very opinionated way of preventing styles being applied from the parent document. Here is a roundup of the quirks and associated issues I’ve encountered:

Shadow DOM styling issues

Universal styles

Some universal styles (using the * selector) are applied, and others are not. The color property appears to pierce shadow boundaries, but I’ve had no luck with box-sizing. Having to set box-sizing: border-box for each component is redundant.

Inheritance

Document styles are inherited in Shadow DOM. But inheritance only gets you so far, especially since not all properties are inheritable by default anyway. I’ve tried hacking things together with the explicit inherit keyword, but that seems to have no effect.

Using the following is no good, because it forces every node to inherit every property from the parent. If <my-component> had display: flex applied, every one of the component’s descendants would adopt it.

* {
all: inherit; /* yikes */
}

Slot restrictions

For performance reasons, the ::slotted() selector, which allows you to style the Light DOM content of your component from within your Shadow DOM, only lets you affect child elements and not descendants at depth. This is a bit restrictive. Worse, you cannot seem to use sibling combinators like + or ~. None of the following works:

::slotted(*) + * /* Nope */

::slotted(* + *) /* Nope */

::slotted(*) + ::slotted(*) /* Nope */

Specificity

In making layout components, I like to take a progressive approach: style the component and its Light DOM nodes in my document stylesheet, then enhance with instance-specific styles via props (attributes) in Shadow DOM.

For a component instance that looks like this...

<my-component itemWidth="10rem"></my-component>

...I would apply the itemWidth value in the Shadow DOM using interpolation:

`
<style>
::slotted(*) {
max-inline-size: ${this.itemWidth};
}
</style>
`
;

However, despite being a value specific to my component instance, this will be overridden by the default/fallback styles because they inevitably use a more specific selector:

my-component > * {
max-inline-size: 30ch;
}

This is fixed with !important, but feels counter-intuitive and hacky:

<style>
::slotted(*) {
max-inline-size: ${this.itemWidth} !important; /* urgh */
}
</style>

Instance styling without Shadow DOM

For all these reasons, I have developed a different approach to styling my layout components. It’s experimental for web components, but is not unprecedented: Conceptually it is influenced by the sadly defunct scoped CSS spec (as emulated in Vue) and implementations for React like Styled Components. In short: it namespaces and embeds prop-derived instance styles in the document head.

Here’s the full custom element code for a simple component. It lets you use a measure prop to control the max-inline-size of a center-aligned block element.

export default class Center extends HTMLElement {
constructor() {
super();
this.render = () => {
this.i = `Center-${this.measure}`;
this.dataset.i = this.i;
if (!document.getElementById(this.i)) {
document.head.innerHTML += `
<style id="${this.i}">
[data-i="${this.i}"] {
max-inline-size: ${this.measure};
}
</style>
`
;

}
};
}

get measure() {
return this.getAttribute('measure') || '65ch';
}

set measure(val) {
return this.setAttribute('measure', val);
}

static get observedAttributes() {
return ['measure'];
}

connectedCallback() {
this.render();
}

attributeChangedCallback() {
this.render();
}
}

if ('customElements' in window) {
customElements.define('center-l', Center);
}

On connectedCallback, and each time the measure prop changes, the constructor’s render function is fired. First, the function creates a string based on the new measure value.

this.i = `Center-${this.measure}`;

This identifier is applied to the component instance as a data attribute. Correspondingly, it is used as the id for a <style> element, and within that <style> element using the attribute selector syntax.

this.dataset.i = this.i;
if (!document.getElementById(this.i)) {
document.head.innerHTML += `
<style id="${this.i}">
[data-i="${this.i}"] {
max-inline-size: ${this.measure};
}
</style>
`
;

}

Note the if. A new stylesheet is only added if one with an identifier that matches the current configuration does not already exist. That is, if I were to add a second <center-l> component to my page with the same measure value, it would defer to the existing embedded stylesheet. This keeps DOM operations and bloat to a minimum.

Default values

My measure getter supplies a default value where none exists:

get measure() {
return this.getAttribute('measure') || '65ch';
}

This is needed so that this.measure does not return undefined. But it does not preclude me from adding a default to my document stylesheet as well — for where JS fails or custom elements are not supported:

center-l {
margin-inline: auto;
max-inline-size: 65ch;
}

Performance

Crucially, because Shadow DOM is not involved, it is possible to use a library like JSDOM or headless Chrome to run the code and embedded the necessary (initial) styles on the server. Such tools cannot currently prerender Shadow DOM content (read Server-side rendering web components for more detail).

With the static site generator, Eleventy, I was able to server-side render initial styles using JSDOM and a post-processing “transform” function. Note this required a fork of JSDOM that supports custom elements. Hopefully official custom element support will land soon.

eleventyConfig.addTransform('ssr', function(page) {
let dom = new JSDOM(page, {
resources: 'usable',
runScripts: 'dangerously'
});
let document = dom.window.document;
return '<!DOCTYPE html>\r\n' + document.documentElement.outerHTML;
});

With server-side rendering in place, these kinds of layout-specific components do not require JavaScript to run on the client at all—at least not for initial styling. Rerendering is supported on attributeChangedCallback mostly for in-browser design experimentation, using developer tools.


If you find yourself wrestling with CSS layout, it’s likely you’re making decisions for browsers they should be making themselves. Through a series of simple, composable layouts, Every Layout will teach you how to better harness the built-in algorithms that power browsers and CSS.

Buy Every Layout