Skip to content
Courses CSS Nouveau Vanilla Has Never Tasted So Hot Shadow DOM: The Boss Mode of Modular Layout

Shadow DOM: The Boss Mode of Modular Layout

A Quick Recap #

We’ve talked a lot about how to style components using “light DOM”—that is, the “traditional” way of doing things. Now it’s time to get into the specifics of a new and (yes, in some ways very much improved!) way of designing and styling components.

So just WHAT is shadow DOM anyway? To quote from the previous episode:

Shadow DOM—or more precisely a “shadow DOM tree”—is nothing more than a fragment of HTML & CSS which lives in, well, its own tree. Various DOM elements in this tree all hang off of a “shadow root”. This root, obviously, is not <html> like we’re accustomed to.

On a traditional web page, a tree of elements might look like this:

html > body > main > article > header > h2

With Shadow DOM added to an HTML page, you essentially have “multiple DOMs” living side by side. Parts of a component’s “light” DOM can “slot” into the shadow DOM using the slot attribute, and any content not specified via slot will go into the default slot.

In this example:

html > body > main > article > my-design-system-card > h2[slot="header"]

my-design-system-card(shadow root) > header > slot[name="header"]

The card has been provided a h2 tag to be slotted into its header within its shadow tree.

Why is this interesting? The drum I keep beating: encapsulation, separation of content from layout, and scoping.

There are some truly powerful, dare I say magical properties which emerge with this type of system.

The Future is Declarative #

I’m continually flummoxed when a handful of devs seem to look askance on shadow DOM for being “strange”. Now I won’t deny there are some challenges with using shadow DOM, and very complex components can occasionally run into quirky accessibly issues.

However, the truly legit problem with shadow DOM historically has been that it’s a “client-side technology”. In other words, only JavaScript could actually create shadow DOM. Yes, let me restate that because it’s simply bonkers that the web components spec was initially published with this severe limitation. ONLY JAVASCRIPT CAN CREATE SHADOW DOM. WHAT THE ****??!!

If shadow DOM is so powerful, so magical, why can’t it work before JavaScript runs? It affects how HTML works. It really affects how CSS works. And yet the HTML and CSS side of shadow DOM doesn’t exist before any JavaScript executes? Madness.

Thankfully, there is a solution to this problem, and it’s called Declarative Shadow DOM (DSD). DSD is amazing. I will go a step further: DSD will revolutionize and transform how websites and web applications get built. DSD pushes web components technology to HTML-first, server-side rendering. DSD enables “lazily-loaded” components. And so, so much more. In this author’s opinion, the frameworks which take full advantage of DSD haven’t even been built yet*, which is why I’ll probably create an entire course here on The Spicy Web just centered around Declarative Shadow DOM. Yeah, it’s that special.

So we can all agree that shipping the client-side technology of shadow DOM before there was a way to “stream” the contents of shadow roots directly from the server was a colossal mistake—one we’re still paying for years after the initial concepts of web components became widespread. While one might argue (and I sometimes do!) that “leaf” components like buttons or form fields or Very Interactive Pieces of a page are fine as JavaScript-centric widgets, I think it’s virtually without question that the “branch” components you typically write on projects—site headers and navigation bars and sidebars and article snippets and dashboard widgets and product cards and so on and so forth—really need to be HTML-first: HTML sent over the wire and processed immediately by the browser before JavaScript has even had time to turn off the alarm and get out of bed. It’s better for performance, better for project architecture, better for debugging, better for pretty much everything.

Declarative Shadow DOM—along with an increasing number of “declarative” specs in the works—let web components participate in this industry “reset” back to starting with markup streamed to your browser and building out from there.

With that preamble out of the way…let’s dive into the wonders of DSD!

Hello Darkness, My Old Friend #

Sad Ben Affleck GIF

So…here’s a custom element with some text.

<hello-world>Hello World.</hello-word>

Now let’s say we want to wrap this text in a blockquote, and we want to do that using a shadow root. You use the <template> tag for that, along with a special attribute: shadowrootmode. Shadow roots can be either “open” or “closed”, and the technical explanation for this is…never mind. Just stick with “open” (trust me on this 😂). We like our shadow roots open.

We’ll want to make sure we have at least one <slot> tag within our shadow DOM template. This lets the “light DOM” content punch a hole through the shadow DOM so our component’s interleaving functionality will work. I’ll cover slots in a bit more detail shortly.

Let’s put it all together:

<hello-world>
  <template shadowrootmode="open">
    <blockquote><slot></slot></blockquote>
  </template>
  Hello World.
</hello-world>

In this example, the text “Hello World” from the light DOM is slotted in as content inside of the <blockquote> tag that’s in the Shadow DOM. The browser doesn’t relocate it or do anything adverse to it…if you pull up your browser’s dev tools you’ll still see the text right there before the closing </hello-world> tag. But the browser has put together a new, composed tree for rendering purposes with both light and shadow DOM content interleaved. Neat!

Want to style the blockquote within your shadow template? Slap a <style> tag on it. Really!

<hello-world>
  <template shadowrootmode="open">
    <blockquote><slot></slot></blockquote>
    <style>
      blockquote {
        border-inline-start: 5px solid black;
        margin-inline: 0;
        padding-block: 1ch;
        padding-inline-start: 2ch;
      }
    </style>
  </template>
  Hello World.
</hello-world>

I realize it may take some getting used to…this notion that you can just stick styles in style tags and reference elements like blockquote with no additional ceremony. Sure, you may be have gotten in the habit of doing this in frameworks using Vue or Svelte, where they’ll “compile away” your styles using their preferred flavor of class hashing and all that jazz. But here we are, doing this in 100% vanilla HTML & CSS, no JavaScript required, no build step required, no hashing, no nonsense. Mind. Blown. 🤯

Styling the custom element itself from within is pretty simple too: use :host to refer to the custom element. And for targeting the element when certain classes or attributes are present, use the :host() function (aka :host([attr="value"])).

Composing Your Content Using Slots #

The history of the <slot> element and related slot= attribute is really rather fascinating. I’m a self-proclaimed Apple fanboy and enthusiastic proponent of Safari / WebKit, much to the chagrin of a few of my esteemed colleagues, yet even I didn’t realize Apple had had a big hand in developing <slot> as part of Shadow DOM v1 (resolving key issues that were present in the Google-led Shadow DOM v0 proposal).

Here’s how it works. All “light DOM” child content is assigned to the default slot by, er, default. So when you place <slot></slot> into your shadow template, that then will inject any typical light DOM content into the overall structure of the component.

In addition to the default slot, named slots let you designate which specific pieces of child content go where inside of your shadow template. As you will see in a “website layout” example below, there are header and footer named slots as well as a sidebar named slot, and by using attributes like slot="header" in light DOM content, the relevant elements get slotted into those named slots.

You can assign any number of elements to a given slot, just by using the same slot attribute. The big caveat to keep in mind is that slotted nodes must be direct children of the host element. So this works:

<my-element>
  <article slot="entry">Content</article>
</my-element>

But this does not:

<my-element>
  <section>
    <article slot="entry">Content</article>
  </section>
</my-element>

Slots can also contain fallback content, in case there’s no slotted content available:

<dashboard-widget>
  <template shadowrootmode="open">
    <header><slot name="header">I'm a Dashboard Widget</slot></header>
  </template>
</dashboard-widget>

In this case, if no header content is provided in light DOM, the text I'm a Dashboard Widget will display.

Time to Pierce Those Shadow Roots #

Remember allllll the way back in Episode 4, I talked about how CSS variables can pierce shadow roots? Well, that’s exactly right. Shadow roots can indeed be pierced by CSS variables.

🫨

Fine, all right, I will now explain just WTF that means!

CSS variables will work across all shadow boundaries on your web page. This means your design system tokens are fully accessible inside of shadow roots:

✨ Live Demo ✨ #

As you can see, our basic styling of p tags does not extend to the paragraph within the shadow template, nor do any global classes. However, we may still use the --color-maroon variable when styling our shadow p tag.

Neat!

OK, so there you have it: some basics about how to use shadow DOM, styles, and slots! Let’s pause for a moment and start busting some myths, because there’s a whole slew of confusing rhetoric and downright FUD (Fear, Uncertainty, and Doubt) about shadow DOM out there in the wider web dev world, and I want to make sure we’re all on the same page here.

Myth 1: Shadow DOM is for Web Components #

Obviously shadow DOM is most appealing as part of a custom element definition. But you can author custom elements without shadow DOM as we’ve clearly demonstrated, and the flip side is you can attach shadow DOM directly to (some) built-in HTML elements! Don’t believe me? Try this on for size:

<section>
  <template shadowrootmode="open">
    <slot></slot>
    <style>
      :host {
        display: block;
        background: rebeccapurple;
        color: white;
        font-size: 1.75rem;
        text-align: center;
        padding-inline: 1rem;
        padding: 8rem;
      }

      ::slotted(p) {
        text-decoration-line: underline;
        text-decoration-color: #ea4;
        text-decoration-thickness: 5px;
        text-underline-offset: 0.5ch;
      }
    </style>
  </template>
  <p>Hello World.</p>
</section>

Way cool! And y’know what’s even nuttier than that? Building your entire website out of DSD. 😎 Doo, bow bow, chick chicka chicka (…sorry, I just couldn’t resist!)

✨ Live Demo ✨ #

Should you do this? Perhaps not! Or, well, maybe you should! 😅

Either way, maybe now you’re starting to realize what I’m saying when I tell you the web frameworks which fully leverage DSD haven’t even been built yet.

(If you’re curious which built-in HTML elements do support shadow DOM, check out this handy list.)

Myth 2: Shadow DOM is for Scoped Styles #

Sometimes people imagine that “the point” of using shadow DOM is to get scoped styles. So let me dispel this myth right here, right now.

Shadow DOM is not for scoping styles. 🧐

Now before you fire up your preferred messaging app of choice to inform me of the error of my ways and the futility of my thinking, hear me out. I’m not saying you can’t or shouldn’t use shadow DOM for scoped styles, because obviously I do that, and everyone else does that. It’s a major selling point. However, the key point to remember is this: scoped styles are a byproduct of shadow roots. The technical reason the styles are scoped has to do with the intrinsic nature of shadow DOM being a separate tree from the parent document. And it’s not just styles which are scoped: everything is scoped. IDs are scoped. Element searches via querySelector/querySelectorAll are scoped. Slot composition is scoped.

With Shadow DOM, your component’s internals become fully encapsulated, a comfy little HTML/CSS/JavaScript bubble. And, for the most part, you get to control what is allowed into the bubble and what can flow back out of the bubble. It is glorious.

Myth 3: If You Use Shadow DOM, You Can’t Share Any Styles #

Based on the above, you might be forgiven for assuming you can’t “share” any styles between multiple shadow DOM-based components. However, this simply isn’t true!

You absolutely can share styles—and not only that, browsers can detect stylesheets which are common across multiple shadow roots and process them using a single backing sheet for improved performance and memory usage.

The easiest, build-free solution is to use a link tag, such as <link rel="stylesheet" href="/shared-styles.css" />. Components A, B, C, ad infinitum, can all include the same link tag in their shadow templates. If you want a few “CSS reset” sort of rules, maybe a handful of utilities, etc., that’s totally possible. I’ve done it!

Of course you can also just render out “shared” styles right at the top of the template’s <style> tag using your server-side template layer of choice. Don’t bother grinding your teeth over all the style duplication across your page output…modern compression algorithms are really quite excellent at handling many wads of identical strings. Really! (This is also why cries of “OMG, you have display: flex fifty times in your stylesheet! Geez! You should use utility classes!” is an easily dismissed argument because your stylesheets don’t literally send fifty identical strings out over the wire in plain-text. Compression FTW!)

Another solution is to use constructible stylesheets. This is a JavaScript-based solution, so while that’s fine for any highly-interactive “leaf” type components with lots of JavaScript, I wouldn’t recommend it for your average chunk of DSD server-rendered content.

Myth 4: Shadow DOM Must Be Weird, Because Nobody I Know is Talking About It #

There are two kinds of programmers in this world: programmers who think popular technology must be good because it’s popular and therefore “unpopular” technology must be somehow bad, and programmers who think popular technology is just as likely to be bad as it is to be good and the merits of any given technology are largely entirely unrelated to its popularity.

Which kind of programmer would you prefer to be? 😃

Seriously though, I think it’s peculiar that in the most recent 2023 State of CSS survey, the percentage of people who have even heard of styling using shadow DOM, let alone used it, is quite low. This stuff just isn’t on many people’s radar, which is why whenever it does come up, the predictable reaction is “WTF is that? Sounds weird.”

Sigh.

Some days, I tire of pointing out that shadow DOM is literally a part of the web spec (just like all the other web APIs developers use every day). Some days…well, you might say I don’t care anymore.

But then I muster up the enthusiasm to proceed once more unto the breech—and advocate for the benefits of the shadow DOM. Begone, vague sense of difference! Let us smartly embrace the power and flexibility of this important spec on the web platform.

Myth 5: Shadow DOM isn’t Accessible #

Professor Farnsworth says Nonsense!

But don’t take my word for it! I’ll leave you in Manuel Matuzović’s most capable hands. Yes, there are a few wonky edge cases and possible footguns, which is what critics will trot out every day. However, you can generally work around them or drop back down to just light DOM for those few critical pieces. And believe me, spec authors and browser implementors are very motivated to work through these sorts of issues and fix them, as I routinely witness through the efforts of the W3C Web Components Community Group.


With those myths out of the way, let’s talk a bit more about component styling.

Designating Parts of Your Component Using, Well, Parts ⚙ #

So slots let you control how content flows into your component’s shadow template. But what if you want to control what flows out of your component in terms of its styling API? Certainly we can use design tokens via CSS variables, and outside consumers can set component-specific tokens to override your styling as we’ve previously demonstrated—but now we have a new and exciting featureset at our disposal for even more control and flexibility: Shadow Parts.

Parts within your shadow template are designated using the part= attribute. When an element has a part, it then can be targeted via the ::part pseudo-element in any stylesheet authored outside of the component. Part names can be written just like class names, and you can even have multiple parts per element.

<!-- inside a <social-timeline> template -->
<article part="post unread">...</article>
<article part="post">...</article>
social-timeline::part(post unread) {
  color: red;
}

social-timeline::part(post) {
  color: green;
}

There’s also a way you can “export” parts from child components so parent stylesheets can reference those parts from your nested shadow trees. Check out the docs here if you’re curious.

The added benefit of using shadow parts is you can use them as internal references within your own stylesheet! So instead of doing this:

<p part="description" class="description"><slot name="description"></slot></p>

<style>
.description { ... }
</style> 

You can simply do this!

<p part="description"><slot name="description"></slot></p>

<style>
[part="description"] { ... }
</style> 

Or maybe your parts can be slots themselves! (Yes, slot tags can themselves be styled!)

<slot name="description" part="description"></slot>

<style>
slot[name="description"] {
  display: block; /* default for slots is `contents` */
  ...
}
</style> 

Which Styles “Leak” Into Your Shadow DOM? #

So I’ve been talking about the style encapsulation provided by shadow DOM as if it’s an impenetrable wall until you do something like consume an outside CSS variable or provide shadow parts. That’s not entirely true, because a few styles do leak in. Really, it’s just that the parent document provides some “default” values for things you might expect anyway, such as font family, text size, and color. In other words, if your body font is Arial, your shadow DOM text will also be Arial unless it specifically picks something else. These are simply the “inherited” properties of the custom element. Here’s an article containing a partial list of these inheritable properties.

The other thing you need to realize is that the actual custom element itself—aka the “host” element—can be directly styled from the outside. Consider the following example:

<my-custom-element>
  <template shadowrootmode="open">
    <style>
      :host {
        display: block;
        color: green;
      }
    </style>

    <p>I'm not green. I'm blue, da ba dee da ba die…</p>
  </template>
</my-custom-element>

<style>
  my-custom-element {
    color: blue;
  }
</style>

If that’s undesirable, you can wrap all of your shadow template content in another element, and unless you provide a part, it’s totally unavailable.

<my-custom-element>
  <template shadowrootmode="open">
    <style>
      wrap-per {
        display: block;
        color: green;
      }
    </style>

    <wrap-per>
      <p>Say! I do like green text and ham!</p>
    </wrap-per>
  </template>
</my-custom-element>

<style>
  my-custom-element {
    color: blue; /* lol, nope */
  }

  my-custom-element > wrap-per {
    color: orange; /* nice try, but still nope! */
  }
</style>

The Pièce de Résistance: Refactoring Card to Use Shadow DOM #

All right folks, it is time to put everything we’ve learned to good use. Remember that card component we built in the previous episode? We will now refactor it to use shadow DOM.

First, let’s build our shadow template:

<template shadowrootmode="open">
  <header part="header"><slot name="header"></slot></header>
  <slot part="body"></slot>
  <footer part="footer"><slot name="footer"></slot></footer>

  <style>
    :host {
      --card-background: transparent;
      --card-max-width: 52ch;
      --card-padding: var(--size-fluid-2);
      --card-border: 1px solid var(--stone-5);
      --card-border-radius: var(--radius-3);
      --card-shadow: var(--shadow-3);
      --card-footer-padding-block: var(--size-fluid-1);
      --card-footer-divider: 1px solid var(--stone-4);
      --card-footer-background: var(--stone-3);
      --card-footer-flex-direction: row-reverse;

      display: block;
      overflow: hidden;
      background: var(--card-background);
      max-width: var(--card-max-width);
      padding-block: var(--card-padding);
      border: var(--card-border);
      border-radius: var(--card-border-radius);
      box-shadow: var(--card-shadow);
    }

    :host > * {
      padding-inline: var(--card-padding);
    }

    slot[part="body"] {
      display: block;
    }

    header {
      margin-block-end: var(--card-padding);
    }

    ::slotted(:first-of-type) {
      margin-block-start: 0;
    }

    ::slotted(:last-of-type) {
      margin-block-end: 0;
    }

    footer {
      display: flex;
      flex-direction: var(--card-footer-flex-direction);
      margin-block-start: var(--card-padding);
      margin-block-end: calc(0rem - var(--card-padding));
      padding-block: var(--card-footer-padding-block);
      background: var(--card-footer-background);
      border-top: var(--card-footer-divider);
    }

    :host([noheader]) header {
      display: none;
    }

    :host([nofooter]) footer {
      display: none;
    }
  </style>
</template>

That may seem like a hefty chunk of code, but it’s nearly all styles. The markup of the template is pretty simple. (In case you’re curious about ::slotted, that’s a way from within your shadow DOM styles to target “slotted” elements which come from the light DOM. We do that so we can control content margins in the various regions of the card.)

What’s great about this is we can now author our light DOM content and it’s super simple. I love it, I really do.

<spicy-dark-card>
  <!-- DSD template goes here if you're server rendering -->

  <h2 slot="header">Hello, I'm a Spicy Card</h2>

  <p>This time using Declarative Shadow DOM!</p>

  <button slot="footer">Cards are ♥︎</button>
</spicy-dark-card>

✨ Live Demo ✨ #

How amazing is that?! You don’t need to worry about how to author all the right markup to construct a card when you’re “outside” of the card. Simply pass in your content, add a few slot= attributes where needed, and YOU ARE DONE.

This is the way

Not only that, but in addition to all of the theming possibilities via component tokens like we discussed in the previous episode (like changing --card-background and so forth), we also expose the header, footer, and default slot directly as parts. Go nuts!

/* somewhere in an overrides stylesheet… */

spicy-dark-card {
  padding-block-start: 0;
}

spicy-dark-card::part(header) {
  padding-block: var(--card-padding);
  box-shadow: inset 0px 7px 14px pink;
  margin-block-end: 0;
}

spicy-dark-card > [slot="header"] {
  color: purple;
}

spicy-dark-card::part(body) {
  background: mediumvioletred;
  color: yellow;
  padding: 2rem;
}

spicy-dark-card::part(footer) {
  margin-block-start: 0;
  background: purple;
  border: none;
}

Now for some real talk. Do I think shadow parts are “the solution” for theming components?

No, I actually don’t.

Consider the 80/20 rule in this scenario. 80% of all the theming possibilities for your component should be handled through CSS custom properties. They’re great for documentation, you can set them even in style= attributes, and you can reuse component tokens throughout your own internal stylesheet.

But…that remaining 20% of all the crazy WTF stuff people will want to do with your component? Yeah, those can be handled through parts.

An Annoying Gotcha #

Initially when I worked on this, I used a combination of :host and :has to alter header and footer styling depending on if they actually had any slotted content or not. Something like: :host(:has([slot=footer])) footer. Unfortunately, I came to discover to my deepest sorrow that only Safari supports this! Yes, that’s right, other browsers do not! 😭

Not being able to control the styling of your slots based on if they actually have any content slotted into them or not seems like a major oversight in the spec. Thankfully, standards folks are in talks to correct this (and concrete proposals are being evaluated), so hopefully it happens sooner rather than later. In the meantime, we have three options: detect slot content in JavaScript and tweak styles accordingly (ugh), do what I did above and offer noheader and nofooter attributes to control those display options (a reasonable compromise IMHO), or for certain cases, figure out a styling workaround so things still look alright when slotted content is missing (perhaps CSS grid or flexbox combined with gap can fix an issue you might otherwise have if relying on margin or padding between elements).

Know the Power of the Dark Side of the DOM #

Darth Vader says if you only knew the power of the dark side

There is oh so much you can do with a component with the introduction of shadow DOM—things which are very difficult to do with light DOM alone.

In fact, let’s recap the list of problems shadow DOM uniquely solves—solutions which are not available in any other vanilla web technology working in browsers today—because these are some key issues you must understand. Alas, you will literally find quotes from all sorts of people out there such as:

If I could use slots in light DOM, I’d never use shadow DOM.

or even more strangely:

This Bootstrap template I just bought isn’t working well in shadow DOM, so I just won’t use shadow DOM.

What?! So you’re telling me that because the 10-to-15-years-old thinking undergirding a legacy CSS framework isn’t immediately easily compatible with the latest cutting edge technology available to you in modern browsers…well, screw cutting edge, let’s roll back the clock 15 years??

Gee, while we’re at it, let’s all go back to using MooTools! 😂

This is what shadow DOM uniquely gives you:

So there you have it folks. Shadow DOM isn’t some weird little spec off in the corner only suitable for super nerds with intense use cases. It’s ready to be embraced by the wider world of web design & development at scale, and we must expect—nay, demand—that tooling, frameworks, and themes up their game and support this technology from the ground up.

You can get the ball rolling, today!

Next Episode:
Zooming Out to the Design System Production Level