Closed Shadow Roots for Design Systems

I've already discussed why I believe Web Components are great for Design Systems. It's becoming clear to me that setting the mode of the ShadowRoot property for these components to closed rather than the default of open has a lot of advantages for Design Systems as well. Don't agree? Humor me for a bit, please!

Let's talk a bit about the Shadow DOM and custom elements. Here's a great quote from MDN that describes the general philosophy of custom elements and Web Components.

"An important aspect of custom elements is encapsulation, because a custom element, by definition, is a piece of reusable functionality: it might be dropped into any web page and be expected to work. So it's important that code running in the page should not be able to accidentally break a custom element by modifying its internal implementation."

This is what we want from Design System components as well. We want them to contain a reusable piece of functionality (HTML, JS, CSS) that can be placed anywhere and work without any external dependencies. We want to use our components in any JavaScript framework, or none at all, and ensure that application code does not negatively impact our components by breaking styling or behavior with JavaScript.

One of the big advantages of using Web Components for this is around encapsulation. Encapsulation is extremely important. It ensures that users don't get to mess with our components unless we provide knobs for doing so. Encapsulation promotes consistency. It also ensures less buggy code. MDN believes so as well.

"Without the encapsulation provided by shadow DOM, custom elements would be impossibly fragile. It would be too easy for a page to accidentally break a custom element's behavior or layout by running some page JavaScript or CSS. As a custom element developer, you'd never know whether the selectors applicable inside your custom element conflicted with those that applied in a page that chose to use your custom element."

All of us who've worked on Design Systems for a reasonable amount of time know how important it is for changes to our components to be conversations/requests that turn into work on our end, rather than hacks on their end that immediately destroy the UI/UX or consistency of a product.

Hold up, wtf is a Shadow DOM?

"A set of JavaScript APIs for attaching an encapsulated "shadow" DOM tree to an element — which is rendered separately from the main document DOM — and controlling associated functionality. In this way, you can keep an element's features private, so they can be scripted and styled without the fear of collision with other parts of the document."

I believe I caught Wes Bos on Syntax.fm referring to the Shadow DOM in a very simple way: it's a private, separate DOM that's inside of the regular DOM you're used to working with. If you're still a bit unclear of what a Shadow DOM is, read through that MDN link above. It does a fabulous job. It's important to understand what a Shadow DOM is before we proceed.

Now let's talk about encapsulation a bit more for CSS and JavaScript.

1. Styling / CSS Encapsulation Protections

By default, Web Component styling is encapsulated because of the Shadow DOM. Styles from your application cannot override our component's styles unless those components expose either CSS variables or parts via their public API.

Let's look at a brief example. Say your application code had the following CSS.

/* Within your application */
/* app.css */
button {
  /* We _really_ want all buttons to be red! */
  background-color: red !important;
}

And say you had a Lit Web Component with the following.

@customElement('simple-button')
export class SimpleButton extends LitElement {
  static styles = css`
    button {
      background-color: blue;
    }
  `;

  render() {
    return html`<button><slot></slot></button>`;
  }
}

Whenever <simple-button></simple-button> is rendered in the DOM, the page CSS does not affect nodes inside the shadow DOM due to the encapsulation protections. The button background color would remain blue.

Sweet. This is exactly what we want with Design System components - to make styling adjustments part of our component's public API, so that a consuming application's styles can't clobber our component styling.

We want styling to be very intentional and to allow only certain knobs to be adjusted so that the component doesn't end up looking like Frankenstein's monster. I discussed exposing these things via a component's public API already in a previous post if you're interested.

For a quick example, here's how we might expose a CSS variable for background-color to be adjusted.

@customElement('simple-button')
export class SimpleButton extends LitElement {
  static styles = css`
    button {
      background-color: var(--simple-button-background-color, blue);
    }
  `;

  render() {
    return html`<button><slot></slot></button>`;
  }
}
/* Within your application */
/* app.css */
:root {
  /* Set the CSS variable to the color we want. */
  --simple-button-background-color: red;
}

2. JavaScript Encapsulation Protections

Well, we've covered CSS. How about JavaScript?

JavaScript is a bit different, in that there is a way to interact with the internals of a component when its mode is set to open.

Taking a look at the MDN example, there are some protections in place already. You can't query the internals of a Web Component by simply using querySelector or any other selection method. But you can get to them by using host.shadowRoot.querySelector as outlined in the example a bit further down.

If you use our examples from above, doing something like this won't work.

// Try to query for the <button> tag within `simple-button`
document.querySelector('button');
// > null

But if you do the following, you can.

// Query through the host's shadow root
document.querySelector('simple-button').shadowRoot.querySelector('button');
// > <button></button>

This is quite different than what we covered with CSS. With CSS, you have to intentionally expose variables or parts via the public API to allow for the consuming application to modify styling, whereas with JavaScript, you can break the encapsulation by simply targeting the host and using shadowRoot to query inside of it. What a bummer!

This is problematic, because it means the encapsulation promise we've discussed thus far is great for CSS, but not for JavaScript. But what if I told you there was a way that could help? It's not perfect either, but it's one more extra protection to keep the internals of your components private from outsiders.

Close it up

"If you don't want to give the page this ability, pass {mode: 'closed'} instead, and then shadowRoot returns null."

That's right - we can set the mode to closed for our component to restrict that access.

@customElement('simple-button')
export class SimpleButton extends LitElement {
  static shadowRootOptions = {
    ...LitElement.shadowRootOptions,
    mode: 'closed',
  };

  static styles = css`
    button {
      background-color: blue;
    }
  `;

  render() {
    return html`<button><slot></slot></button>`;
  }
}

Doing this provides a similar encapsulation as our CSS flow above. It ensures no one gets to put their hands in our cookie jar and interact via JavaScript with the internals of our components. It helps prevent misuse of our components.

If you've read through to the next paragraph, you'll notice the following.

"However, you should not consider this (setting mode: 'closed') a strong security mechanism, because there are ways it can be evaded, for example by browser extensions running in the page. It's more of an indication that the page should not access the internals of your shadow DOM tree."

Users can programmatically set the mode back to open if they'd like, but to me this means they're signing themselves up for potential unknowns. It's a break in the contract.

To follow the philosophies of Web Components above, users shouldn't care what the internals of your component are or are doing under the hood - they use your components because they want the UI/UX they provide. Just like how you treat react-query or any other library as a black box - you insert inputs, you get outputs - you should treat closed shadow root Web Components the same way. You don't go dumpster diving into react-query - you interact with the public API directly. If you do dive into the internals and change things, you're probably doing something you shouldn't and it's a huge code smell.

"When the mode of a shadow root is "closed", the shadow root's implementation internals are inaccessible and unchangeable from JavaScript—in the same way the implementation internals of, for example, the <video> element are inaccessible and unchangeable from JavaScript."

No one dives into the <video> element internals to interact with it. They use the public API instead. Why should our components be any different?

Okay, but how do I interact with these things?

So how do we expect users to interact with our components if the mode is set to closed? The same way you do with native elements. Just like when you need to interact with the native <input /> element, you go through the attributes, properties, and methods. You attach event listeners when things happen. Stick to JavaScript fundamentals!

For our simple-button component above, you can interact with the element just fine with JavaScript APIs via the host.

// We use the tag name directly and don't require
// the `.shadowRoot` at all! Everything can
// go through the host element.
document
  .querySelector('simple-button')
  .addEventListener('click', () => console.log('clicked'));

// Click it
document.querySelector('simple-button').click();

// Check if it is disabled or not
document.querySelector('simple-button').disabled;

Button here is a simplistic case, but my personal philosophy is to stick to what native does. Writing a Web Component that ultimately renders an <input />? Make the API identical to the native input element. Making a Modal/Dialog Web Component? Expose the open attribute and showModal methods just like the native elements. This makes your component APIs familiar, standardized, and consistent.

Rely on Composition

You may be wondering how you access content within the closed shadow root. The answer is that Design System authors should be relying on composition. Say you have a Modal component and want to render a form inside of it.

<simple-modal label="Information" open>
  <form>
    <label for="firstname">First name:</label>
    <input type="text" name="firstname" id="firstname" /><br />

    <button type="submit">Submit</button>
  </form>
</simple-modal>

And for pseudocode say simple-modal looks something like.

render() {
    return html`
      <dialog ?open=${this.open}>
        <h2>${this.label}</h2>

        <slot></slot>
      </dialog>`;
  }

Rely on slots for compositional APIs. Because of our slot, the form element is still accessible to you via JavaScript directly because it is in the "light DOM". This means, that yes, you can interact with everything here by doing the following.

document.querySelector('simple-modal #firstname').value = 'Tony';
document.querySelector('simple-modal button').click();

The h2 rendered inside of the Modal on the other hand, is not accessible because it is inside of the closed shadow root of the simple-modal component. It's important to decide what should remain accessible versus not, but that's one of the advantages in my opinion of encapsulation.

Bringing it all together

All of this is why I believe mode: 'closed' is great for Design System components. It forces Design System authors to write public APIs that a consumer can use to interact with your components rather than them diving into the internals of your components. It'll help promote consistency and reliability. It has the advantages of Web Components, where anyone can place your Web Component on their page, no matter the framework and get a full UI/UX experience for that particular component. It offers privacy and encapsulation to Design System authors.

When building Design Systems, we want to ensure folks are using our components as they were intended. Closing the shadow root provides a ton of protections for authors. It's always better to start as the most restrictive and slowly open things up, rather than going the opposite direction and attempting to shove the genie back into the bottle later. It also promotes a good separation of concerns, between Design Sytem components and application code, which most developers should appreciate.

👋