Eschewing Shadow DOM
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.