8 Ways to Style the Shadow DOM

ยท

7 min read

In the past, using shadow DOM meant compromising good CSS maintenance. This is no longer true.

First, a quick introduction to shadow DOM, then we'll learn the eight ways you can style it.

Brief Shadow DOM Intro

HTML becomes DOM. See this W3Schools intro if you're not familiar with the DOM.

The DOM can be accessed by JavaScript and styled by CSS.

Like native HTML, Custom Elements are also part of the DOM.

Custom Elements (any element actually) can optionally create their own DOM inside themselves called shadow DOM.

Shadow DOM content can be made accessible or inaccessible to external JavaScript, i.e. JS outside of the Custom Element class, using the mode option. The shadow DOM is inaccessible to external CSS regardless of mode. Well, not all of it at first anyway. We'll learn how to work around that in a bit.

Shadow DOM is useful because you can:

  • Protect a component's internals from other JavaScript and CSS on the page

  • Create encapsulated styles for the component

  • Safely use ids on the component's internal elements

You don't have to use shadow DOM in a Custom Element, but with the latest features around styling, I don't see any reason not to, so let's learn more about our options for styling an element with shadow DOM.

Styling the shadow DOM

There are two general approaches to styling shadow DOM: internal (from within the shadow root element) and external (from outside, from the regular or "light" DOM). Let's start with external and work our way in. And again, mode is not relevant here as that only applies to JavaScript.

External Styling and the Shadow DOM

Let's use the Mdash global stylesheet as an example. It has tons of styles for all HTML elements as well as utility classes:

<link href="https://unpkg.com/m-@3.2.0/dist/m-.css" rel="stylesheet">

That global stylesheet is of course applied to the DOM, but can we get it to also apply to shadow DOM? How? Yes we can and there's four ways to do it.

1. Inherited Styles

Some styles will actually automatically penetrate shadow DOM and be inherited by default. You don't have to do anything.

In order for a style to be inherited and apply to an element, it must come from a parent. That means any ancestor of shadow DOM along the DOM tree all the way up to body or html must have explicitly set an inheritable property (the full list of those properties is here). For example, Mdash sets a default text color on body and color is inherited, so it applies to all elements on the page including shadow DOM:

body {
  color: var(--m-color-gray-7);
}

Shadow DOM text will inherit that style and also be Gray 7. Or some other color set on a closer ancestor. It's worth clarifying that you do not have to do anything to make this work. This is default CSS behavior even for shadow DOM. If you do not want to inherit, then you can turn it off with all: initial:

/* In your Web Component's <style> tag */
:host {
  all: initial; /* Reverts all properties to HTML spec defaults */
}

2. Custom Properties

Next, all CSS Custom Properties defined on :root are available in shadow DOM. For example, if you're working a design system and want to share design tokens with Web Components using shadow DOM, you can:

/* The Mdash stylesheet we included has custom properties */
:root {
  --m-color-red-3: #a2204f;
}

/* In your Web Component's <style> tag */
p {
  color: var(--m-color-red-3); /* Component's paragraphs are red */
}

Like inheritable styles, there's nothing you need to do. This is more CSS goodness that just works! Using these is an excellent way to easily define global styles and selectively apply them (versus inheriting, which may be unexpected or undesired).

3. ::part pseudo-element

Shadow DOM can make specific elements available to external styles by giving them a part attribute:

<template>
  <p part="intro">I can be styled from outside the shadow root.</p>
  <p>I cannot be styled externally.</p>
</template>

Then using external CSS, you can style the intro paragraph:

custom-element-tag::part(intro) {
  color: red;
}

4. Import Global Stylesheet

Lastly, we can in fact get an external stylesheet - every bit of it - into a shadow DOM by simply importing the stylesheet:

/* In your Web Component's <style> tag */
@import "https://unpkg.com/m-@3.2.0/dist/m-.css";

Yeah baby! It works exactly like you would hope. It's kind of like you weren't using shadow DOM at all, except component styles won't leak outward, which is great. Check it out:

<template>
  <header class="bg-white pad-lg brd-b">
    <nav class="flex align-items-center gap-sm">
      <a href="/">Home</a>
      <a href="/about">About</a>
      <a href="/signup">Sign up</a>
    </nav>
  </header>
  <style>
    /* Mdash design system styles "leaking" in */
    @import "https://unpkg.com/m-@3.2.0/dist/min.css";

    /* This style won't leak out */
    nav {
      height: 50px;
    }
  </style>
</template>

Three key takeaways from this:

  • The browser will not download the stylesheet again if it's already been downloaded before.

  • This means all your styles are automatically applied to elements inside your Web Component, which is great for common elements like links and buttons, and all the wonderful little utility classes are available too!

  • This is awesome native technology ready for use by any web project today! Breathe in that fresh web platform air...๐Ÿ˜ฎโ€๐Ÿ’จ Refreshing!

Constructable StyleSheets

One more option I don't see being very useful, but nevertheless available is constructible stylesheets. This is an API for programmatically creating styles from a big CSS string in light DOM space and "adopting" them in one or more shadow DOM roots, or even to the regular DOM:

// Create a new "constructed" stylesheet in light DOM space
const sheet = new CSSStyleSheet();
sheet.replaceSync('p { color: red; }'); // The CSS rules

// Add this stylesheet to your Web Component's shadow root
constructor() {
  super();
  this.attachShadow({ mode: 'open' }).appendChild(this.#template.content.cloneNode(true));
  this.shadowRoot.adoptedStyleSheets.push(sheet); // Component's paragraphs are red 
}

An interesting option, but I think the four options above are better.

It's worth noting at this point that slots are always styled by external CSS, i.e. slots are light DOM not shadow DOM even though you declare them in your shadow DOM template.

So, those are the five ways you can style shadow DOM with external CSS. Let's now look at three ways Web Components can style themselves.

Internal Styling of Shadow DOM

Style Tag

This option is perhaps the most common. If you're fond of Vue SFC or Riot or Svelte, then you'll appreciate this option.

<template>
  <style>
    /* :host is the custom element root, i.e. <hello-world> */
    :host {
      background: blue;
      color: white;
    }

    /* Other rules are scoped to this custom element */
    p { color: red }
    #name { color: lime }
  </style>

  <p>Hello, <div id="name"></div></p>
  <div>
    This custom element will have a blue background and 
    this div's text will be white.
    The paragraph above will be red and the div inside it will be lime.
    None of these styles will apply to the main page.
  </div>
</template>
<script>
  customElements.define('hello-world', class extend HTMLElement {
    // Uses the <template> above...
  })
</script>

That's such a valuable feature baked right into native Web Components. React can't even do this!

Style Attribute

There is a time and place for the style attribute and Web Components is one place where this makes sense. In the example below you could write a selector to target the first and last divs or create classes or use ids, but there's just no need for that abstraction in a component. Style attribute is a solid choice for specific cases like this:

<template>
  <style>
    :host { display: flex }
    div { padding: 20px }
  </style>

  <div>Foo</div>
  <div style="min-width: 100px">Bar</div>
  <div style="min-width: 80px">Baz</div>
</template>

Styles property

And of course the Custom Element can use JavaScript to programmatically set an element's styles, e.g. this.querySelector('p').styles.color = 'red'. And don't forget that you have those global custom properties available too:

const css = getComputedStyle(document.body)
this.querySelector('p').styles.color = css.getPropertyValue('--m-color-red-3')

Wrapping Up

Web Components are here and they're never going away. They are mature and widely supported and some of the biggest companies with the biggest apps use them (inspect YouTube's DOM or Salesforce or Adobe or SpaceX - Web Components!). The developer experience is great and they can even do things frameworks like React can't. That's not to say Web Components can or should replace frameworks, but you are no doubt writing waaaaaaay more framework-dependent code than you should. The Web Component code you write can be used and will outlast anything built with a framework.

If you've hesitated in the past because of some of the old hang ups, try again. The API is much better, especially with all these options for styling shadow DOM. And with React finally giving in and agreeing to support them in React 19 (which is about to ship), the biggest blocker to Web Component adoption will finally be over. It is time to #useThePlatform!

ย