SJ (Scott Jehl)

HTML Web Components Can Have a Little Shadow DOM, As A Treat

photo of scott jehl profile headshot

By Scott Jehl

Well, I swore myself off blogging until I get this course launched and sure enough, here I am again. But a little idea about HTML Web Components has been squatting in my brain for a little while and I needed a place to put it.

There's been a lot of great writing about HTML Web Components in the past year, and as an advocate of performant, resilient, progressive enhancement patterns, I'm a big fan of the whole ethos of it. That said, some of the discourse around HTML Web Components tends to conflate a couple of things that I have come to understand need not be conflated.

Those are, 1) wrapping already-useful HTML and augmenting it via custom element lifecycle features is a great pattern for websites, and 2) Shadow DOM is probably not a helpful part of that process.

Now to be fair, I've dunked on shadow DOM a bit in the past myself... such as when it's used as a container for piles of JavaScript-generated HTML that would be better off in the page from the start. But the more I've worked with web components, the more I've come to see that shadow DOM (and slots in particular) may still play an interesting role in the HTML Web Components story.

Cascading before the light

A post that led me to come around to this idea was Nathan Knowler's late 2023 article about shadow DOM styles and the CSS cascade. Did you know that shadow DOM styles cascade before the light DOM styles we typically write in our site.css files and the like? I'll admit that I didn't know that before I read Nathan's post about it. To put it a different way, the styles that sit inside a shadow DOM root are applied to the page's CSS cascade after the "user agent" styles (as in the ones that give you the default appearance of a button element) and after the "user styles" (as in, the ones you can optionally configure in browser settings)–but importantly, still before the global page-level styles that authors like us typically write (eg, your average site.css file).

It's due to this situation that a global, inheritable style like * { color: red; } will override a shadow DOM style :host { color: green; }. Even if that * style comes first in the HTML source, it will be added to the CSS cascade after the shadow DOM's styles in the overall cascade. (That said, if you really do want a shadow DOM style to "win", you can use !important in a shadow DOM rule to ensure it won't be overridden by styles that cascade over it from the light DOM authored CSS. And in that case, !important is considered good practice!)

Making realer elements

Anyway, more recently over on Mastodon, Nathan noted that we might do well to consider our shadow DOM styles and their order in the cascade as a sort of "user agent styles" for our custom elements: providing some default, low-specificity styles for our slotted light-dom HTML elements while allowing them to be easily overridden.

As we gain further abilities to extend the Web Platform, I think it’s helpful to consider how we might approach our custom elements less like authors and more like a user agent would.

Nathan Knowler

I think this a pretty powerful idea. If we think of web components as our tool to create somewhat "real" elements for other authors to reuse, it's neat that we can follow some "real HTML element" patterns when it comes to styling them in ways that defer to the author's preference in the cascade. Light-DOM-only HTML web components can't really offer this in an equally reliable way, because their styles will compete in the same cascade as the other light DOM styles that the author adds, where specificity battles are everything. Depending on where and how those styles are added to the page, they could easily override author styles in ways that aren't as friendly as those that a built-in element would offer.

Maybe slots make it okay?

There's a lot of focus on the unique encapsulation aspects of shadow DOM, so it's easy to forget that slot elements flip that on its head: slots are just a window revealing our ordinary HTML elements that sit alongside the shadow root. They receive regular-old styles from site.css as you'd expect, they submit their data expectedly with forms, they don't have the accessibility issues that can occur with shadow DOM, because they're really just regular DOM!

A shadow root with a slot gives us a means to style slotted light DOM in a way that defers to the author, should they choose to do so. Just like a built-in element! This makes me think that there's a world of potential use cases for HTML Web Components that imperatively attach a shadow root and perhaps only use it to insert some styles and a slot, allowing the light DOM that is already there to do what it does well.

Let's try...

An Example HTML Web Component with Shadow DOM

I'm going to make a component called nice-quote that is designed to wrap a blockquote element and extend the default blockquote element's presentation a bit, in a light-handed way. It's a simple example, but it should get the idea across.

First, the HTML:

<nice-quote>
    <blockquote>
        <p>Your HTML Web Component should be fine with a piece of shadow DOM sometimes, as a treat.</p>
    </blockquote>
</nice-quote>

As for the JavaScript, we'll start with a typical custom element definition:

class NiceQuote extends HTMLElement {
...
}
customElements.define('nice-quote', NiceQuote);

Now, if I was building a typical HTML web component, I might add a connectedCallback handler next to manipulate or add behavior to the light DOM elements inside nice-quote. And again, that's typically the best lifecycle callback for doing those things, since it runs when the component is actually connected to the DOM (as opposed to say, an element created with document.createElement that is not yet appended), and so within connectedCallback we're able to query for elements inside it. (Small caveat: there are situations when even connectedCallback will run before the element's light DOM children are available to it, and one way to avoid that is to have type=module on your script element, but that's another article).

Anyway, instead of connectedCallback, this time I'm going to use the constructor because all I need to do is attach and populate a shadow root, and that's one of those tasks the constructor is good for. So I'll use it to do just that, so I can add a slot and a stylesheet.

Here are those steps.

  1. Always call super() first because we're extending the HTMLElement class,
  2. Attach a shadow root,
  3. Insert some HTML in that root: I'll add a style element to contain my CSS, and install a window to see my light DOM elements shine through (that's the slot part).

Here's how that looks:

class NiceQuote extends HTMLElement {
    constructor(){
        super();
        this.attachShadow({mode: 'open'});
        this.shadowRoot.innerHTML = `
            <style>...</style>
            <slot></slot>
        `;
    }
}
customElements.define('nice-quote', NiceQuote);

Okay, lastly, let's add those styles into the shadow DOM. For this, I'm going to use :host and ::slotted to style the nice-quote and blockquote elements from inside the shadow DOM (these are some of the few ways shadow DOM styles can style light DOM elements).

Here's that final web component code:

class NiceQuote extends HTMLElement {
    constructor(){
        super();
        this.attachShadow({mode: 'open'});
        this.shadowRoot.innerHTML = `
            <slot></slot>
            <style>
                :host {
                    display: block;
                    max-inline-size: 34em;
                }
                ::slotted(blockquote) {
                    margin: 2rem 0;
                    font-family: serif;
                    font-style: italic;
                    line-height: 1.2;
                    font-size: 2em;
                    padding: 1em 2rem;
                    line-height: 1.3;
                    background: #ebfffd;
                    border-inline-start: .5rem solid salmon;
                }
            </style>
        `;
    }
}
customElements.define('nice-quote', NiceQuote);

I've added this JavaScript on the page here too so here's how that nice-quote component looks in HTML:

Your HTML Web Component should be fine with a piece of shadow DOM sometimes, as a treat.

Looks nice I hope.

The Cascade Benefits

So yeah, perhaps the nicest part of this is that global styles can override the presentation of this nice quote if I'd like, because the component styles cascade before them in order. For example, if I didn't want the nice-quote to make my blockquote text italic, I can override it with a regular blockquote element selector, like this, and that style could come long before the component in source order without any worries.

<style>
blockquote {
    font-style: normal;
}
</style>
<nice-quote>
    <blockquote>
        <p>Your HTML Web Component should be fine with a piece of shadow DOM sometimes, as a treat.</p>
    </blockquote>
</nice-quote>

In fact, I didn't mention it but the first quote from Nathan in this post was using this shadowy component already, and wouldn't you know that several of my site's ordinary styles are overriding it in various ways in this page.

Embrace those priorities of constituencies!

Looking Ahead

Admittedly, the example in this post is purposefully simple as it's meant to show the pattern itself without involving too many other concerns. It's likely that an actual component that appropriately calls for this pattern would be a bit more complex, augmenting behavior in addition the styles, to say the least.

I'd love to hear about others experimenting with these sorts of patterns in their HTML Web Components.

Some kind folks read early drafts of this post and offered feedback. I want to say thanks to Nathan Knowler and Mayank

HEY! If the ideas in this article interest you, perhaps you'd also be interested in my soon-to-be-released course, Web Components Demystified, on pre-sale now:

Web Components Demystified: A COMPREHENSIVE, PREMIUM COURSE ABOUT BUILDING DYNAMIC, FAST, RESILIENT APPS WITH WEB STANDARDS.