Web Components: Templates, Slots, and Shadow DOM Aren't Great

November 20, 2023 📬 Get My Weekly Newsletter

In two previous posts, I explored the custom elements part of Web Components, concluding that the lifecycle callbacks provide value beyond rolling your own. I want to look at the other two parts of Web Components, which are the Shadow DOM and the <template> tag. These provide a templating mechanism that doesn’t work like any other web application templating environment and is incredibly limiting to the point I must be just not understanding.

Let’s start with the <template> element. This element allows you to place markup into the DOM that is ignored by the browser and has no semantic meaning. This is pretty useful, because the only way to approach this is to do something hacky like make a div with role="presentation" or something.

Here is how you could use it. Let’s create a template <figure> to show a random picture from picsum.photos:

<template id="pic">
  <figure>
    <img width="64" />
    <figcaption />
  </figure>
</template>
<button>Create Picture</button>
<section>
  <!-- dynamically created figures will go here -->
</section>

The <template> is available via normal DOM API calls, however it’s contents are not its children, so to use the template’s contents, you must use .content to access them. This returns a DocumentFragment, which you can then clone via cloneNode(true). The clone can be manipulated and inserted into the DOM:

const template = document.getElementById("pic")
const button   = document.querySelector("button")
const section  = document.querySelector("session")
const content  = template.content

button.addEventListener("click", (event) => {
  event.preventDefault()

  const node    = content.cloneNode(true)
  const img     = node.querySelector("figure img")
  const caption = node.querySelector("figure figcaption")

  const randomNumber = Math.round(Math.random() * 200)

  img.setAttribute("src",`https://picsum.photos/${randomNumber}`)
  img.setAttribute("alt","Random picture")

  caption.innerText = `Picsum ${randomNumber}`

  document.body.appendChild(node)
})

You can see this in action on CodePen. Each time you click the button, a new node is inserted (note that it will appear slow because picsum.photos is slow—the code executes quickly). Note that the CodePen includes the following CSS, which isn’t needed for the functionality, but which will become relevant later:

figure {
  padding: 1rem;
  border: solid thin grey;
  border-radius: 1rem;
}
figure img {
  border-radius: 0.5rem;
}
figure figcaption {
  font-weight: bold;
}

Where Templates Fall Down

This isn’t what most web developers think of as a template. For as long as I can remember, templates for web apps provided a more direct way to insert dynamic elements. If we created a Rails version of this template, it might look like so:

<!-- views/partials/_pic.html -->
<figure>
  <img width="64" 
       src="<%= image_src %>"
       alt="<%= image_alt %>" />
  <figcaption>
    <%= caption %>
  </figcaption>
</figure>

There would then be code to set image_src, image_alt, and caption in much the same way as our JavaScript does above.

The <template> version really doesn’t make it clear what is going to be set dynamically, though perhaps it’s a feature that you can manipulate any part of the internals. Almost all web app templating systems boil down to string manipulation, and the <template> version of the code is more sophisticated, as it can manipulate the DOM using the browser’s APIs.

That seems useful, but, as we will see, will result in a ton of verbose low-level code.

That said, custom elements can add some features to templates, so let’s change this to a custom element that can display a picture and a caption.

Using Templates in Custom Elements

Instead of a button to create a random picture, let’s create a picsum-pic element. For this example, we’ll use it four times: twice in the normal way, once omitting the caption, and once omitting everything. This will allow us to understand all reasonable edge cases.

<template id="pic">
  <figure>
    <img width="64" />
    <figcaption />
  </figure>
</template>

<picsum-pic number="123"
            caption="Moon rocks">
</picsum-pic>

<picsum-pic number="665"
            caption="Mountain trail">
</picsum-pic>

<picsum-pic number="12">
</picsum-pic>

<picsum-pic>
</picsum-pic>

The <template> is the same as before, as is the CSS. For the JavaScript, we’ll extend HTMLElement. In the constructor, we’ll grab number and caption:

class PicsumPic extends HTMLElement {
  constructor() {
    super()
    this.number = this.getAttribute("number")
    this.caption = this.getAttribute("caption")
  }

Next, we’ll implement connectedCallback to run basically the same code we saw earlier, however we’ll follow the vibe of silent failures and do nothing if there is no number and omit the <figcaption> if there is no caption. We’ll also define the custom element as picsum-pic after the class definition.

  connectedCallback() {
    const template = document.getElementById("pic");
    const content  = template.content;
    const node     = content.cloneNode(true);

    const img     = node.querySelector("figure img");
    const caption = node.querySelector("figure figcaption");

    if (!this.number) {
      return
    }
    img.setAttribute("src", `https://picsum.photos/id/${this.number}/200`);
    if (this.caption) {
      img.setAttribute("alt", this.caption)
      caption.innerText = this.caption      
    }
    this.appendChild(node);
  }
}
customElements.define("picsum-pic",PicsumPic)

You can see this in action on CodePen.

There are two things that aren’t great about this:

  • We have to manually grab the attributes from the custom element.
  • The way we manage dynamic data feels super manual and low-level.

Accessing Attributes via Lifecycle Callback

Given that our connectedCallback() can handle the situation when number or caption are omitted, we can make use of the lifecycle callback method attributeChangedCallback(), which will be called if an attribute we are observing is changed. Crucially, this callback is called when the attributes are given their initial values1

First, we must declare a static member named observedAttributes like so:

class PicsumPic extends HTMLElement {
  static observedAttributes = [
    "number",
    "caption",
  ]

Then, if the values for number or caption change—including being given their initial values—the method attributeChangedCallback will be called. We can remove the constructor() and add that method instead:

attributeChangedCallback(name,oldValue,newValue) {
  this[name] = newValue
}

The custom element works the same way, as you can see in the CodePen. That’s nice!

But, attributeChangedCallback is called anytime the attributes are changed, so we really should respond to those changes and update the state of the custom element’s child nodes. Doing this requires a significant change in the class, but let’s look at that.

Responding to Attribute Changes

First, let’s change the HTML to allow a form to submit a number and a caption:

<template id="pic">
  <figure>
    <img width="64" />
    <figcaption />
  </figure>
</template>
<picsum-pic />
<form>
  <label for="number">
    Number
    <input type="text" name="number" id="number">
  </label>
  <label for="caption">
    Caption
    <input type="text" name="caption" id="caption">
  </label>
  <button>View Pic</button>
</form>

Next, we’ll add some code to grab the input values when the button is clicked and pass those along to the custom element:

const numberInput  = document.querySelector("input[name='number']")
const captionInput = document.querySelector("input[name='caption']")
const button       = document.querySelector("button")
const picsumPic    = document.querySelector("picsum-pic")

button.addEventListener("click", (event) => {
  event.preventDefault()

  const number  = numberInput.value
  const caption = captionInput.value

  picsumPic.setAttribute("number", number)
  picsumPic.setAttribute("caption", caption)
});

I’ll be honest, I’m not sure the best way to structure the custom element’s code, so what I did was to create updatePic and updateCaption to handle updating their respective bits of the element, and calling them from connectedCallback as well as attributeChangedCallback.

Here’s attributeChangedCallback:

attributeChangedCallback(name, oldValue, newValue) {
  this[name] = newValue
  this.updatePic()
  this.updateCaption()
}

For connectedCallback, it’s a bit tricky because we need the Element that is inserted into the DOM. The only way I could find to do this was to access firstElementChild from the cloned Node. This won’t work if the <template> contains mulitple nodes at the top2. I’ll save that as an instance variable so that updatePic and updateCaption can use it:

connectedCallback() {
  const template = document.getElementById("pic")
  const content  = template.content
  const node     = content.cloneNode(true)

  this.element = node.firstElementChild

  this.updatePic()
  this.updateCaption()
  this.appendChild(node)
}

Now, updatePic() will handle updating the <img> element. If this.element isn’t defined, it will do nothing. If this.number is defined, it’ll set the src attribute, otherwise clear it. If this.caption is defined, it’ll set the alt attribute, otherwise clear it.

updatePic() {
  if (!this.element) {
    return;
  }

  const img = this.element.querySelector("figure img");
  if (this.number) {
    img.setAttribute("src", `https://picsum.photos/id/${this.number}/200`);
  } else {
    img.removeAttribute("src");
  }

  if (this.caption) {
    img.setAttribute("alt", this.caption);
  } else {
    img.removeAttribute("alt");
  }
}

Lastly, updateCaption will work similarly:

updateCaption() {
  const caption = this.element.querySelector("figure figcaption");
  if (this.caption) {
    caption.innerText = this.caption;
  } else {
    caption.innerText = "";
  }
}

You can see this working on CodePen.

This is pretty complex, and if you write React or Vue or anything, it probably feels very verbose. If you were to do this without attributeChangedCallback, you’d need to use MutationObserver and it would be even more verbose and complicated that what we have here. So, attributeChangedCallback does save some code and is useful.

OK, so that handles managing the attributes, but is there a way to improve how dynamic data is set?

The answer is…sort of.

Slots…Are a Part of the Spec, I Can Say That Much for Them

The number attribute is used to create a URL that is then placed into the src attribute of the <img> tag. The caption attribute is kinda dumped into <figcaption> and it turns out we can avoid managing that by using slots.

Slots are not super great, and they come at great cost. Let’s see.

The way they work is that you put markup inside your custom element and add the slot attribute. If the template contains a <slot> element, it is replaced with the markup with the slot attribute.

For example, here is our updated template:

<template id="pic">
  <figure>
    <img width="64" />
    <figcaption>
      <slot name="caption" />
    </figcaption>
  </figure>
</template>

If we use our custom element like so:

<picsum-pic>
  <h3 slot="caption">Some Caption</h3>
</picsum-pic>

…it can produce the following HTML (but requires a small change in our code, which we’ll see in a second):

<figure>
  <img width="64" />
  <figcaption>
    <h3>Some Caption</h3>
  </figcaption>
</figure>

So, what is this change? The change is that we must use the Shadow DOM, which creates a completely isolated document where our custom element’s markup will go and that document is inserted where we’ve referenced the custom element. If none of that sounds like it has anything to do with dynamic replacement of information in a template, you are not alone.

Shadow DOM has a few implications, but the immediate one is that slots don’t work if you aren’t using the Shadow DOM. I don’t know why.

Here is the updated connectedCallback. Instead of appending the child to the custom element, we attach a Shadow Root to the element (via attachShadow), then call appendChild on that. It is during this part of the process that the slots are used.

connectedCallback() {
  const template = document.getElementById("pic");
  const content  = template.content;
  const node     = content.cloneNode(true);

  this.element = node.firstElementChild;

  this.updatePic();
  const shadowRoot = this.attachShadow({ mode: "open" });
  shadowRoot.appendChild(node);
}

attributeChangedCallback() no longer needs to call updateCaption. In fact, updateCaption can be removed.

attributeChangedCallback(name, oldValue, newValue) {
  this[name] = newValue;
  this.updatePic();
}

Our form-handling code will now need to set the innerText of the slot to the value of the caption:

button.addEventListener("click", (event) => {
  event.preventDefault();

  const number      = numberInput.value;
  const caption     = captionInput.value;
  const captionSlot = picsumPic.querySelector("[slot='caption']")

  picsumPic.setAttribute("number", number);
  captionSlot.innerText = caption;
});

Lastly, we will remove some code from updatePic that used the caption to get the alt text.

  updatePic() {
    if (!this.element) {
      return;
    }

    const img = this.element.querySelector("figure img");
    if (this.number) {
      img.setAttribute(
            "src",
            `https://picsum.photos/id/${this.number}/200`);
    } else {
      img.removeAttribute("src");
    }
  }

You can see this on CodePen. It’s…sort of working.

I believe the alt text could still be set like it was before, but it requires digging into the slotted element, which is now potentially more than just text, and figuring out how to turn that into alt text. You can fork the CodePen if you want to try :)

That said, the behavior where the <h3 slot="caption"> is being put into the custom element is working. Despite the limitations on what can be inserted where, this is a nice bit of functionality to not have to write ourselves.

What’s not working is our styles. Way back at the top, I put a border around the component and put a border radius on the image. Those aren’t there any more.

This is the Shadow DOM. Our document fragment cannot access the document’s stylesheet. This is by design.

Shadow DOM’s Limitations

The DOM tree created by shadowRoot.appendChild(node) is encapsulated from the rest of the DOM tree. This means that CSS does not affect it (it also means the way JavaScript interacts is different, but that’s another post).

In order to style the <figure>, <img>, and <figcaption>, we must provide styles to the markup separately. There are a lot of ways of doing this, but if we want our custom element to use our global styles, it’s a huge pain.

To demonstrate a way to do this, we can create a <style> element, add that to the shadowRoot, like so:

const style = document.createElement("style");
style.innerText = `
figure:has(img[src]) {
  padding: 1rem;
  border: solid thin grey;
  border-radius: 1rem;
}
figure img {
  border-radius: 0.5rem;
}
figure figcaption {
  font-weight: bold;
}`;
shadowRoot.appendChild(style);

This is…gross. It’s not sustainable at all. If you use utility CSS, this becomes a total nightmare. Yes, you can put a <link> tag into the Shadow DOM root, but it’s incredibly slow when you have more than few components on the page.

Konnor Rogers has a detailed blog post on various options to do this with Tailwind, which are somewhat generalizable. They will at least give you an example of what you are up against. Some options are better than others, but this seems like there is friction no matter what. They all seem like using the Shadow DOM in a way that was not intended.

To be honest, I’m not sure how the Shadow DOM is intended to be used or how styles are intended to be managed. Even if you use semantic CSS everywhere (e.g. hanging styles off of a semantic class= value), you still need access to a shared set of custom properties that define the design system’s fonts, colors, sizes, and spacings. There’s no obvious way to share that with elements inside a Shadow DOM.

Update Based on Feedback: It seems the way CSS is to be shared with the Shadow DOM is only via custom properties. The Shadow DOM does have access to custom properties, though I am unable to find any documentation that this is true. You can see this in action in this CodePen. From what I can tell, only properties set on the :root pseuduo-selector are available. I had forgotten about this and, it just doesn’t seem documented anywhere.

But, what it means is that to create truly re-usable components using <template> and Shadow DOM, you basically cannot use utility CSS and must use a CSS strategy where all re-use is done through custom properties. This is limiting.

End of Update

Second Update on Nov 23, 2023 Per PointlessOne on Mastodon, the CSS property inheritance rules do apply to the ShadowDOM elements, though I cannot piece together how this is documented. If you look at this CodePen you can see a few things:

  • Custom Properties declared on the custom element properly override the :root custom properties and are used inside the ShadowDOM. I tried to do this before and must’ve messed it up and thought it didn’t work. Silent failures are the worst.
  • Inheritable properties like color do get inherited inside the ShadowDOM. You can see this in the CodePen where the text color on a parent element and on the custom element itself do affect the text inside. This is unexpected behavior since I thought the entire point of ShadowDOM was isolation. Essentially, this seems to mean that if your custom element that uses ShadowDOM needs to display text, it has to be really careful about the colors, otherwise you could end up with the main document setting white text on your white background.
  • You can expose part= on any element and that can be styled externally. This seems to be how you would achieve customization.
  • You can use the :host selector inside the shadowDOM to reset things, but this falls victim to specificity issues and requires !important.

This new information makes the rules around isolation even more convoluted and confusing, which I guess is consistent with most of CSS.

End of Second Update

And it is super odd to me that these two features are intertwined. Why does using templates and slots require using a Shadow DOM? It makes no sense to me.

What Web Components Do Is Not Much…but Not Nothin’

From what I can tell, the Web Components APIs provide a two things that you can’t do any other way:

  • Receive code callbacks during DOM lifecycle events, such as the addition/removal of elements or the modification of attributes.
  • Isolate a document, its CSS, and its JavaScript from the larger document containing it.

Custom elements and Shadow DOM are just the way you access these features.

What seem like design errors to me are:

  • Inability to replace attributes in a <template>
  • Coupling of <slot> behavior with Shadow DOM
  • Inability to allow Shadow DOM to access some global styles or some code
  • Inability to package a custom element for re-use with a single line of code

Still missing, after years, is a way to locate elements defensively. Events are still wired and managed by ensuring magic strings are the same across the codebase. And now, with Web Components, we can use undefined custom elements without an error or even a warning, and specify markup for a nonexistent slot.

The web’s vibe of silently failing with no messaging on code that is 99.44% buggy is endlessly frustrating. It is the biggest driver of the creation and adoption of frameworks.

Presumably, existing frameworks will refactor their internals to use these APIs under the covers where it makes sense. New frameworks will continue to be built using these APIs. But there is no world in which “Web Components” are the alternative to stuff like React, Vue, or Angular. Building re-usable code using only the APIs provided by the browser will still leave you wanting more. Which means the continuation of internal and open source frameworks.


  1. 1I really tried reading the spec, but it's impenetrable to me. MDN's documentation on attributeChangedCallback is pretty vague on when it is called. To be honest, this is one of the major problems with Web Components is that the spec is unreadable to someone who is not building a web browser, and ancillary documentation is Webpack-level terrible. It's nothing but vague descriptions and happy-path-only examples. ↩
  2. 2I think it might not be allowed for <template> to have more than one child node inside it, but the spec, again, is impenetrable to me. And, let's be honest, even if it is not allowed, the entire way the web handles errors is to just let them happen and silently break everything. ↩