Web Components are the latest breakthrough in web development. Developers can create reusable custom elements using standard Javascript and HTML. Sophisticated web applications can then be built out of straightforward and concise HTML documents.
If successful, Web Components could finally close the gap between writing highly dynamic Javascript, and HTML as a static document.
There's just one problem: Styling
Applying CSS styles to Web Components is a pain and a half. Enough to make many swear off the technology altogether.
In this article we're going to solve that problem. Fully and completely.
Web Components typically use a Shadow DOM to encapsulate all of
the HTML they generate. The Shadow DOM behaves a bit like an
iframe
, with an entire hierarchy of elements completely
separated from the main page. The key difference is that an
iframe
constrains the rendering to a scrollable area
while Web Components are embedded in the layout of the parent page.
One feature that the Shadow DOM shares with iframe
is
that the styling of the parent page cannot affect the component.
On the surface this sounds great! The component is completely separated with no chance of user stying mucking up your carefully designed component. Except... Web Components are just Javascript classes. How the heck are you supposed to style it?
When we look at Web Components, we typically see code like this:
this.attachShadow({mode: 'open'});
this.shadowRoot.innerHTML = "<style> " +
"button { " +
" background: #1E88E5; " +
" color: white; " +
" padding: 2rem 4rem; " +
" border: 0; font-size: 1.5rem; " +
"}" +
"</style>" +
"<button>Sup?</button>";
This code works but has a few key problems. For one, this is rather painful to develop. Who wants to be hacking at a string inside their Javascript with no LSP support?
Perhaps more glaringly, what happens when you want to change the theme of the component in the wild? Pretty much every component technology going all the back to jQuery UI has provided theming!
Themes are important in professional work as they carry brand identity and help integrate the component into the overall UX design of the application. Any component that can't be themed is already dead on arrival.
There's a great article over on CSS Tricks that discusses the various workarounds to the stylesheet problem. Some of the solutions are decent. e.g. Using global CSS variables. But none of them really get us what we want: The ability to inject inline styles in our HTML. And/or link to an external stylesheet of our choosing.
To accomplish that, we're going to have to get a bit clever.
For the rest of this article, I'm going to show you how to link arbitrary styling information into Web Components. Without causing Flashes of Unstyled Content (FOUC).
To see how it's done we're going to create a very simple Web Component that places a styled button on the screen. And then we're going to see how to link our stylesheet into it.
Let's start by looking at a page with the all the necessary HTML and CSS fully embedded. That way we can see what results we want to achive.
As you can see, we have a large blue button on top of a white field, with an antique white background behind that. If there is any delay in the style being applied the button will appear to flash during rendering. This is due to the button recoloring to blue and the white area expanding to accomodate the larger button size.
You can click the "Open" button to view the page directly. Try refreshing the page a few times to see if there is any flashing. Since our style is defined prior to the button's definition you should see a completely stable interface with no flashes during refreshes.
Now let's embed our button into a Web Component:
As you can see, we have some nice, clean Javascript with no stringified stylesheets uglifying our code. That's the good news.
Bad news is that we've lost all style on our button. You can see
that the styles are still there (line 15 of mybutton.js
),
but the .thicc
style is never getting applied.
Take a look at the following code:
A couple things probably stand out right off the bat.
<style>
tag is embedded inside the
<my-button>
Web Component tagWAIT! Before you run off thinking you understand the solution,
it's a bit more complicated than just putting the <style>
tag into the Web Component element. All we did was make the style
avaialble to the Web Component. It still won't apply without some
additional magic.
In fact, we have to be careful here. The <style>
tag inside the element can still affect the rest of the page. This
can cause a Flash of Unstyled Content until our component has fully
consumed it.
The real magic is happening on lines 15-20 of the Javascript. Here
we are looping through the elements contained within the element in
the primary DOM, looking for the <style>
element,
then moving that element from the primary DOM into the Shadow DOM.
this.childNodes.forEach(function(element) {
if(element.nodeName === "STYLE")
{
shadow.appendChild(element);
}
});
If you were to inspect the page, you'll find that the <style>
element has disappeared from the element itself and now resides at the
top of the Shadow DOM. The code then adds the button after the style
element is moved.
However, we're not done yet. If you try to apply this to your own Web Component, it will likely not work. It's only working here because of line 7 of the HTML:
<script src="mybutton.js" defer></script>
See that defer
there? That's telling the browser not
to load the script until the page is done loading. Without this, the
element would have no children when connectedCallback()
is called by the browser to let us know that our element has been
added. Using defer
allows us to attach to the element
after all of its contents have been attached.
In my testing, this will work in Chrome without a Flash of Unstyled Content. Chrome will complete the load of the script before it attempts to render to the screen.
However, other browsers are not so generous. If you click the Open button in Safari and hit Refresh a times, you're going to notice a small flash. It's not bad and can be worked around in the parent document, but we can do better.
Let's look at how we can more completely solve this issue.
Before we dive any deeper, I want to answer a question some of
you might have at this point. If you've studied Web Components at
all, you might be wondering why we can't just use a <slot>
to pull in the <style>
tag?
And that's actually a really good question! For those who are unfamiliar, a Web Component can have a tag like this:
this.shadowRoot.innerHTML = '<button><slot name="my_slot">Default Text</slot></button>';
...and then you can "slot" in content like this:
<my-button>
<span slot="my_slot">Click Me!</slot>
</my-button>
When this code runs, the Click Me! would be rendered instead of
Default Text. The content we provided literally "slots" into the
<slot>
tag we provided.
The problem is that the slotted content never enters the Shadow DOM. This is a technique known as "Light DOM". The Light DOM entries stay part of the parent DOM and are affected by styling in the parent DOM. What happens is that when the browser goes to render the slot, it looks aside to the slotted content and renders that from the parent DOM as if it were positioned in the Shadow DOM.
The end result is that the content is rendered as part of the Shadow DOM, but the content is otherwise unable to interact with the Shadow DOM.
What this means for a slotted <style>
tag is
simple: The <style>
tag doesn't render anything. So
there will be no useful interactions in the chosen slot. We'll have
to look back to moving elements into the Shadow DOM if we want our
styling to take effect.
We have a technique for pulling our style into the Shadow DOM. The only problem is that this happens after the page has fully loaded. Ideally, we'd like to be able to render our tag as soon as the tag content is loaded.
Thankfully, there is an easy way to accomplish this.
We can register a MutationObserver
to be notified
as soon as the childNodes
are avaialble.
The code pattern looks like this:
var observer = new MutationObserver(function(mutations) {
// Call a function to build your Web Component UI
});
observer.observe(this, {attributes: false, childList: true, characterData: false, subtree: false});
We can easily plop this into our component:
As you can see, we've moved the rendering from connectedCallback()
to #attachElements()
. The #attachElements()
method is called twice. Once in connectedCallback()
in
case the Web Component has been invoked late in the page's rendering.
The #attachElements()
call checks if elements and
attributes are available before attempting to render, so it's safe
to call before any attributes or elements are attached.
The second call is in the MutationObserver
which is
triggered at the very end of the childNodes
load. If the
Web Component is loaded at the beginning of the page (e.g. in the
<head>
), this will trigger as soon as the custom
element is fully loaded.
Go ahead and click Open, then refresh the page a few times. No flashes!
This is because our updates are happening during the initial render of the page. Around the exact time we want the updates to happen: as soon as the HTML parser is done loading our tag.
Ok, we've fully solved styling, right? Not so fast. So far we've been working with embedded stylesheets. That's not really ideal, especially if we have multiple uses of the same component. How do we link to an external stylesheet?
So far we've been using embedded stylesheets. This is nice for a demonstration, but how do we link to an external stylesheet? More importantly, how do we link to an external stylesheet without a Flash of Unstyled Content caused by the stylesheet loading after the page loads?
The answer is thankfully as simple as our handlng of the <style>
tag. If we place a <link>
tag in our element and then
move it to our Shadow DOM, something magical happens.
When the browser encounters the <link>
tag, it
immediately loads the stylesheet to prevent unstyled content.
Our MutationObserver
then triggers at the end of the tag
load. The code moves the <link>
tag to the
Shadow DOM.
Because the HTMLLinkElement has already loaded the the stylesheet, no additional load occurs and the styles are immediately applied to the Shadow DOM.
This is exactly what we want.
If you click the "Open" button above and refresh the page a few times, you'll notice that the rendering is solid. No unexpected flashes.
It is possible to click so fast that you cancel the load partway and cause an incomplete page to be briefly shown. This is entirely caused by clicking the refresh button too fast and not something we'll see in normal page loads. If you fully wait for the page to load before hitting refresh, no flashes should be visible.
The only change needed was to add "LINK" to line 25 of the Javascript:
this.childNodes.forEach(function(element) {
if(element.nodeName === "STYLE" || element.nodeName === "LINK")
{
mybutton.#shadow.appendChild(element);
}
});
Don't worry about multiple components using the same stylesheet. The browser already takes care of that for you. If you request the same stylesheet multiple times, the browser will pull it from cache to speed up rendering. The end result is that your stylesheet will only load once. Even if multiple Shadow DOMs are using it.
And that's it! We now have a method of setting our CSS stylesheet independent of our Javascript, allowing for rapid creation of components without getting bogged down in the difficulties of embedding styles.
Even better, we now have a mechanism for allowing users of our components to re-theme them as needed!
Today we've learned methods for integrating regular CSS stylesheets by simply moving elements from the parent DOM to the Shadow DOM.
We went through all the details to make it happen, and learned how to delay our rendering until the Web Component element is fully loaded.
To make this even easier, you can copy/paste this template to start your project:
class WebComponentTemplate extends HTMLElement
{
static observedAttributes = ["YOUR", "ATTRIBUTES", "HERE"];
#shadow;
constructor()
{
super();
}
#attachElements()
{
var that = this;
if(!this.childNodes.length) return;
// Pull in STYLE and LINK tags
this.childNodes.forEach(function(element) {
if(element.nodeName === "STYLE" || element.nodeName === "LINK")
{
that.#shadow.appendChild(element);
}
});
// Add your rendering code here
}
connectedCallback()
{
var that = this;
var observer = new MutationObserver(function(mutations) {
that.#attachElements();
});
observer.observe(this, {attributes: false, childList: true, characterData: false, subtree: false});
this.#shadow = this.attachShadow({ mode: "open" });
this.#attachElements(); // Attempt to load in case we've been invoked late
}
}
customElements.define("YOUR-TAG-NAME", WebComponentTemplate);
In writing this article, I literally created two Web Components
<code-viewer>
and <code-highlighter>
using this technology in order to embed the code examples into this
article.
In doing so, I learned just how easy Web Components can be. Here is the HTML code for the Example 5 tabbed interface above:
<code-viewer>
<link href="css/codeviewer.css" rel="stylesheet" type="text/css">
<files root="example5" render="index.html">
<file>index.html</file>
<file>mybutton.js</file>
<file>mybutton.css</file>
</files>
</code-viewer>
I mean, you have to admit. That's kind of cool!
It's very clear that if we can design good components the future will be Web Components.
It's just been very difficult to do until now.
Which is why I have one more surprise!
I am attempting to lead the charge for better Web Components by creating the Emirgance library for Web Components. While it will eventually provide a large number of quality components for composing applications, it currently focuses on a better base for building them.
Here is how much simpler a custom button is in Emirgance:
class MyButton extends EmirganceBaseElement
{
constructor()
{
super();
}
emirganceInit()
{
var button = document.createElement("button");
button.textContent = this.getAttribute("text");
this.shadowRoot.appendChild(button);
}
}
customElements.define("my-button", MyButton);
All the complexity we just went through is already handled for you.
Stylesheets are automatically pulled in, the Shadow DOM is created
for you, and emirganceInit()
is called after the
element has been fully parsed.
Meanwhile, the HTML stays the same:
<my-button text="Click me!">
<link href="mybutton.css" rel="stylesheet" type="text/css">
</my-button>
Check it out for yourself, and start making amazing new Web Components today!