Let me preface this post by saying I’m not against web frameworks like Angular or React (and yes React is a framework no matter its devs say). If you’re developing a complex web application, using a web framework is probably way to go1.
If you’re developing a component library, to be used by other people developing web applications, then your choice of framework will limit your users to that framework (my favorite component library is Mantine, but it is only available for React). Most frameworks don’t play nice with each other, web component-based libraries being the sole exception2.
But many web component libraries (like Shoelace) depend on Lit, which is a framework, which means that if the app developer is not using Lit, then their app has two frameworks now.
So this post is an attempt at making sense of the features natively provided by the browser, and how well and how far one can take them in order to develop a component library, without needing to rely on a framework of some kind.
Web Components
The browser-native approach for developing component libraries are web components. However, technically, web components aren’t their own thing, rather they are a mix of three related but ultimately separate standards: Custom Elements, HTML Templates, and Shadow DOM.
When I first heard about web components, I was expecting them to be something like the following:
<template name="my-split-panel">
<style>...</style>
<script>...</script>
<div>
<slot name="left-panel">...</slot>
<div id="splitter">...</div>
<slot name="right-panel">...</slot>
</div>
</template>
Which you’d then use (assuming they were written in a separate file) like the following:
<html>
<head>
<link rel="import" href="/components/my-split-panel.html">
</head>
<body>
<my-split-panel>
<div slot="left-panel">...</div>
<div slot="right-panel">...</div>
</my-split-panel>
</body>
</html>
Pretty simple right?
Right?…
Boy was I wrong! Oh so wrong…
Unfortunately things are quite a bit different in reality. The HTML import spec never got accepted so that’s out. Not a major issue, you could just use some sort of bundler to join the files into one.
The bigger issue are the templates: You can’t instantiate HTML templates from within HTML. You can’t actually name them. You can give them an ID, and find them and instantiate them from JavaScript, but not from HTML.
So if I wanted to make something like a reusable “card” or “avatar”, or “navigation” component, which has no associated behavior but merely styles some divs a certain way, I gotta use JavaScript. A “split button” component that reuses builtin browser behavior? Sorry, JavaScript it is. Forget “server side rendering” too.
Why did they do it this way? I’m sure there was a reason, but it can’t have been a good one.
Ok so we need to create a “Custom Element” in JavaScript to achieve that custom <my-split-panel/>
tag. What does that look like?
const template = document.createElement("template");
template.innerHTML = `
<template>
<style>...</style>
<div>
<slot name="left-panel"></slot>
<div id="splitter">...</div>
<slot name="right-panel"></slot>
</div>
</template>
`;
class MySplitPanel extends HTMLElement {
constructor() {
super();
const shadowRoot = this.attachShadow({ mode: "open" });
shadowRoot.appendChild(template.content.cloneNode(true));
...
}
connectedCallback() {
// got attached to the DOM
...
}
}
customElements.define("my-split-panel", MySplitPanel);
We could get the template out of the HTML file using getElementById
, but actually putting the template into an HTML file wouldn’t make any sense for a reusable component library (can’t import HTML remember?), so we need to put it into a string in the JS file. Nobody thought this was dumb? Just me?
So Custom Elements, other than being a pure JS thing instead of the HTML+CSS+JS hybrid they should be, are pretty reasonable. They don’t handle any kind of reactivity as you’d expect from a modern javascript framework, and they need some annoying boilerplate, but otherwise they’re fine.
Or they would be, if not for that “shadowRoot” thing, which is part of the Shadow DOM spec.
What’s a Shadow DOM?
Shadow DOM is a way to encapsulate the internals of a component from the rest of the page, meaning CSS cannot directly affect a web component’s internal nodes and any selector queries won’t find those nodes either. Quoting mdn:
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.
Funny, somehow this was never a problem for Angular, or React, or Vue, or Svelte, or any other framework, but Custom Elements without shadow DOM would be impossibly fragile? I’m sorry but I don’t see how. Even without frameworks and browser support, web “components” have been a thing since forever!
I can plop a CodeMirror instance into my webpage and not worry about blowing up their styles. Why would I? They’re all namespaced as “cm-”. Perhaps they should have used a longer namespace with less of a chance of collision, sure, but that’s neither here nor there. CSS modules work via name mangling and are redundant with this whole shadow DOM approach. CSS variables? Better remember to namespace them as shadow DOM or not they can conflict. Selectors? Just don’t write lousy selectors! When has that ever been a problem?
Shadow DOM is a misguided idea. The ability for JavaScript and CSS to dig down and modify components made by other people is a feature, not a bug. The author of a web component has to put a lot of effort into making it possible to style their component from the outside. Shadow DOM does not play well at all with external stylesheets (e.g. Bootstrap) or even Tailwind. It sucks IMO, and I’m not alone in this sentiment.
Well, Shadow DOM is technically a separate spec right? We can simply not use it, right? … Right? Unfortunately template slots require a shadow DOM.
So we can avoid this shadow DOM business so long as we don’t use slots, meaning we can’t place sub-elements provided by the consumer of our component in the right place without using a shadow DOM (we can’t use an internal <style/>
block either but CSS modules are a much better solution for that anyway so it doesn’t matter).
Why did they do it this way? I’m sure there was a reason, but it can’t have been a good one.
Well, technically we can place the elements in the right place, just not using slots. There’s nothing preventing us from doing the following to set the components in the right place:
const splitPanelTemplate = document.createElement("template");
splitPanelTemplate.innerHTML = `
<div>
<template id="my-split-panel-left"></template>
<div id="my-split-panel-splitter">...</div>
<template id="my-split-panel-right"></template>
</div>
`;
class MySplitPanel extends HTMLElement {
constructor() {
super();
this.template = splitPanelTemplate.content.cloneNode(true);
this.left = this.template.getElementById("my-split-panel-left");
this.right = this.template.getElementById("my-split-panel-right");
...
}
connectedCallback() {
let left = this.querySelector("[data-slot='left']");
let right = this.querySelector("[data-slot='right']");
this.left.replaceWith(left);
this.right.replaceWith(right);
this.innerHTML = this.template.firstElementChild.innerHTML;
}
}
// make sure to initialize the component after its children
window.onload = () => {
customElements.define("my-split-panel", MySplitPanel);
}
There are some important things to note here. First, we’re emulating slots with data attributes, by replacing the child nodes of the cloned template node with the child nodes with the corresponding data-slot annotation.
Second, The connectedCallback()
method is not necessarily called when the component’s children have already been created, but when the browser knows about the component and sees its tag. We work around that by only defining the custom element on window.onload
, guaranteeing the child elements are in place first.
Lastly, this does not handle the case where an element in a slot is changed programmatically. For that, we need to set up a MutationObserver. Not a big deal, but it would be a lot nicer to have the slotchanged event available.
My point with the above is not to tell you this is the right way to do things, it’s to show you that I can emulate shadow DOM slots without a lot of ceremony, meaning there’s no reason the browser couldn’t do it too. It’s an unnecessary restriction.
Conclusion
Boy what a mess. I do not like this standard, not one bit. That said, the Custom Element part of it can be salvaged, and also templates up to a point. For the example above I wouldn’t bother with a template at all, I’d just ask the user to provide the div for the splitter as well. One extra line of code from the user for more control on their part and a much simpler component on my part? Win win. These web components that just add behavior to a set of user-defined light DOM elements are being called “HTML Web Components” by some other light DOM loving folk out there.
I hate that name, but I can’t come up with a better one. I love the idea though, they work great, are very efficient, and fit in well with the existing web. The only issue is that they require the user to provide all the internal divs themselves, which depending on the component may defeat the whole point. Maybe one day I’ll write a micro compiler from my imaginary HTML+CSS+JS Web Component standard to a light DOM-based Custom Element as the one I showed above. Sort of like a micro Svelte (without reactivity or any other fancy features).
Do let me know in the comments if I’m an idiot for not understanding the brilliance of the shadow DOM. As for me, I prefer the light.
If you’re making a blog or some news site, you should not be using a frontend framework, you should be using some sort of static site generation or server side rendering, barely any JavaScript should be arriving at your user’s computer. Be kind to your users.
Technically they don’t play well with React… which is why Lit has a React compatibility library of some sort, and why Shoelace wraps all of its components as React components.