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? Yes and there's four ways to do it.

1. Inherited Styles

Some global styles will actually automatically penetrate shadow DOM and be inherited by default.

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 official list of inheritable CSS properties is here. For example, Mdash sets a default text color on body:

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

Since color is an inherited property it applies to all elements on the page including elements inside shadow DOM. 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 styles, then you can avoid this 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 building a design system and want design tokens available to Web Components using shadow DOM you can:

/* A global stylesheet with custom properties */
:root {
  --m-color-red-3: #a2204f;
}

/* Those are available in a Web Component's <style> tag  */
p {
  color: var(--m-color-red-3);
}

Like inheritable styles there's nothing you need to do. This is built-in CSS magic 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

A shadow DOM can make specific elements available to global styles with the part attribute:

<!-- In a Web Component's shadow DOM -->
<p part="intro">I can be styled from outside the shadow root.</p>
<p>I cannot be styled from outside the shadow root.</p>

Then using CSS you can style intro:

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

4. Import Global Stylesheet

Lastly, we can in fact expose an external stylesheet - every bit of it - to a shadow DOM by simply importing the stylesheet in the Web Component template:

/* 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 expect. It's kind of like you weren't using shadow DOM at all, except the component's styles won't leak outward, which is perfect. Check this out:

<!-- 
  The Web Component's template.
  Utility classes like .flex are available and
  global element styles are applied, like <a> styles.
-->
<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>
    /* The Mdash design system's styles come in */
    @import "https://unpkg.com/m-@3.2.0/dist/min.css";

    /* But this component's styles don't go 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 the 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'm not familiar with any compelling use cases.

A note about slots

It's also 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

Scoped 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. <my-element> */
    :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('my-element', class extend HTMLElement {
    // Uses the <template> above...
  })
</script>

Scoped styles ensure a Web Component's unique styles don't leak out an impact other elements on the page. 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 and set their min-width or create classes that do it or even use id selectors, but there's just no need for a style abstraction like this in a Web Component. A direct 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!

ย