The Shadow DOM is in the front

Due to its name, I used to think the Shadow DOM was this hard-to-use, murky thing that caused weird things to happen to the UI. So I mistrusted it—I thought it was something to be used only with great caution.

But recently something clicked. It isn't somewhere behind in the shadows.

It's in the front: the Shadow DOM is a facade that obscures the Plain DOM.

A photograph of the top of some brick buildings in Antwerp, showing the facade's distinctive stepping pattern

How the Shadow DOM works

The Shadow DOM is typically used hand-in-hand with Custom Elements.

You can make any custom HTML element (with any look & feel) in plain JavaScript using customElements.define("my-custom-element", MyElementClass)—as long as there's a hyphen in the name.

And in your custom element class, if you call this.attachShadow({ mode: "open" }), you get this.shadowRoot: a shadow root node you can render to—just like any other element.

Consider this silly element, which dances when you click it:

Rendered HTML
<dancing-man
  left="superb"
  right="fantastic"
></dancing-man>

This little guy is ~60 lines of CSS and ~70 lines of JS (full code here):

const dancers = [
  ["(•_•)", "<) )╯", " / \\ "],
  ["(•_•)", "\\( (>", " / \\ "],
  ["(•_•)", "╰( (>", " / \\ "],
  ["(•_•)", "<) )/", " / \\ "],
];

class DancingManElement extends HTMLElement {
  // Call attributeChangedCallback() when any of these attributes change
  static observedAttributes = ["left", "right"];

  constructor() {
    super();

    // Local state (our stuff is prefixed with _)
    this._leftSide = true;
    this._clicks = 0;

    // mode: "open" = the ShadowRoot instance will be at this.shadowRoot
    this.attachShadow({ mode: "open" });

    // We're a button, so respond to clicks:
    this.addEventListener("click", (e) => {
      e.preventDefault();
      this._clicks += 1;
      this._leftSide = !this._leftSide;
      this._rerender();
    });

    // A fun font would be nice to have, so load it here
    const font = new FontFace(
      "Comic Neue",
      "url(https://fonts.gstatic.com/s/comicneue/v8/4UaErEJDsxBrF37olUeD_xHM8pxULilENlY.woff2)",
      {
        style: "normal",
        weight: "700",
      },
    );
    font.load().then((loaded_face) => {
      // In order to use the font in our custom element, we must add it to the document
      document.fonts.add(loaded_face);
    });
  }

  connectedCallback() {
    // When mounted to the document, we rerender
    this._rerender();
  }

  attributeChangedCallback(name, oldValue, newValue) {
    // Each attribute we observe changes the content
    this._rerender();
  }

  _rerender() {
    // Default message values:
    const leftMessage = this.getAttribute("left") || "groovy";
    const rightMessage = this.getAttribute("right") || "dig it";

    const dancer = dancers[this._clicks % dancers.length].join("\n");

    // Simplistic render to the Shadow DOM
    this.shadowRoot.innerHTML = `
<style>
  /* Shine animation for when the element is (re)rendered */
  @keyframes shine {
    0% { transform: translateX(100%); }
    100% { transform: translateX(-100%); }
  }
  .dancing-man {
    /* 3 even columns: left dancer right */
    display: inline-grid;
    grid-template-columns: 1fr 1fr 1fr;
    align-items: center;
    gap: 12px;

    /* Looks like a badge/button */
    cursor: pointer;
    border: 2px black solid;
    border-radius: 50%;
    background: linear-gradient(30deg, #e0e0e0, #f9f9f9, #e0e0e0);
    padding: 8px 16px;
    color: #000000;

    /* An offset container for the shine animation */
    position: relative;
    overflow: hidden;
  }
  .dancing-man::before {
    /* The shine animation gradient */
    content: "";
    position: absolute;
    width: 100%;
    height: 100%;
    transform: translateX(-100%);
    background: linear-gradient(
      30deg,
      transparent 20%,
      transparent 40%,
      rgb(255, 255, 255, 0.4) 50%,
      rgb(255, 255, 255, 0.4) 55%,
      transparent 70%,
      transparent 100%
    );
    animation: shine 400ms 100ms 1 ease-in;
  }
  .dancing-man:active {
    /* Darker colors while pressed */
    background: linear-gradient(30deg, #c4c4c4, #f9f9f9, #c4c4c4);
    border-color: #404040;
  }
  .message_left,
  .message_right {
    font-family: 'Comic Neue';
    font-weight: 700;
    font-style: normal;
    visibility: hidden;
  }
  .dancing-man.right .message_right,
  .dancing-man.left .message_left {
    visibility: visible;
  }
</style>
<button class="dancing-man ${this._leftSide ? "left" : "right"}">
  <div class="message_left">${leftMessage}</div>
  <pre class="dancer">${dancer}</pre>
  <div class="message_right">${rightMessage}</div>
</button>
`;
  }
}

customElements.define("dancing-man", DancingManElement);

These Custom Elements are more flexible than components that just render to the Normal DOM.

With the Shadow DOM, they can act like a sticker: behaving and displaying exactly the same on any website, regardless of how that website is styled via CSS.

The Shadow DOM does not inherit any style from the document .

This makes it so flexible! Instead of looking like the document's style, the HTML and CSS in this custom element's Shadow DOM is viewed in a style-less facade that's placed on top of the Normal DOM.

This gives you full control over how your component looks and feels.

Just drop it in

To demonstrate, go and open up any website, paste that code in the developer tools, and edit the HTML to include a <dancing-man> of your own. You can just drop it in and forget about it!

Sure, the tone may be off, but I'm trying to make a point here: it's like a sticker

What else can it do?

The Shadow DOM also lets a Custom Element choose to render its children using the <slot> element.

Our <dancing-man> element does not use a <slot>, so it hides all the children it contains:

Rendered HTML
Look, I've got children!
<dancing-man
  left="what will"
  right="this do"
>
  <span>
    Look, I've got children!
  </span>
</dancing-man>

Here, the user doesn't see that <span>Look, I've got children!</span> child at all. It's completely obscured by the Shadow DOM facade.

This allows our Custom Element to just do what it does well: show a dancing man.

It's neither constrained by the look and feel of the document nor obligated to show any children given to it.

This is even true for the accessibility tree! (which is what screen readers and other assistive devices use to help blind/low vision folks understand the document)

A screenshot of devtools showing that this span has no representation in the accessibility tree
The "Look, I've got children!" span is completely obscured by the Shadow DOM

But what if we want to show these children?

If we were to change our code slightly, we could use the <slot> element and slot attribute to let our dancing-man dance with its children.

Rendered HTML
just dance it'll be ok
<dancing-man-slots>
  <span slot="left">
    just <strong>dance</strong>
  </span>
  <span slot="right">
    it'll be <code>ok</code>
  </span>
</dancing-man-slots>

See how the font is the same as the font in our document? And how the <code> in the child looks like how it does on this page?

These <slot> elements act like literal slots in a facade, revealing the Plain DOM underneath. Like a window showing the inside of a building behind its facade.

The change to our code was to use <slot name="left"> and <slot name="right"> instead of attributes to place child elements in specific slots within the Shadow DOM:

 <button class="dancing-man ${this._leftSide ? "left" : "right"}">
-  <div class="message_left">${leftMessage}</div>
+  <slot name="left" class="message_left">groovy</slot>
  <pre class="dancer">${dancer}</pre>
-  <div class="message_right">${rightMessage}</div>
+  <slot name="right" class="message_right">dig it</slot>
 </button>

The name attribute of <slot name="left">groovy</slot> maps to the slot attribute on our <span slot="left">just <strong>dance</strong></span> child.

If no slot="left" attribute was present, then the fallback content of groovy would be used.

And if you want to show all of the unmapped slots, you can include a <slot>default content</slot> element in the Shadow DOM that doesn't have a name attribute.

In conclusion

The Shadow DOM is a misleading name. It's not this scary, hidden thing lurking in the shadows.

It's a facade (that can have slots) that sits between the user and the Plain DOM.