Web Components Demystified | CSS-Tricks
Scott Jehl released a course called Web Components Demystified. I love that name because it says what the course is about right on the tin: you’re going to learn about web components and clear up any confusion you may already have about them.
And there’s plenty of confusion to go around! “Components” is already a loaded term that’s come to mean everything from a piece of UI, like a search component, to an element you can drop in and reuse anywhere, such as a React component. The web is chock-full of components, tell you what.
But what we’re talking about here is a set of standards where HTML, CSS, and JavaScript rally together so that we can create custom elements that behave exactly how we want them to. It’s how we can make an element called and the browser knows what to do with it.
This is my full set of notes from Scott’s course. I wouldn’t say they’re complete or even a direct one-to-one replacement for watching the course. You’ll still want to do that on your own, and I encourage you to because Scott is an excellent teacher who makes all of this stuff extremely accessible, even to noobs like me.
Chapter 1: What Web Components Are… and Aren’t
Web components are not built-in elements, even though that’s what they might look like at first glance. Rather, they are a set of technologies that allow us to instruct what the element is and how it behaves. Think of it the same way that “responsive web design” is not a thing but rather a set of strategies for adapting design to different web contexts. So, just as responsive web design is a set of ingredients — including media fluid grids, flexible images, and media queries — web components are a concoction involving:
Custom elements
These are HTML elements that are not built into the browser. We make them up. They include a letter and a dash.
Hey, I’m Fancy
We’ll go over these in greater detail in the next module.
HTML templates
Templates are bits of reusable markup that generate more markup. We can hide something until we make use of it.
Much more on this in the third module.
Shadow DOM
The DOM is queryable.
document.querySelector(“h1”);
//
The Shadow DOM is a fragment of the DOM where markup, scripts, and styles are encapsulated from other DOM elements. We’ll cover this in the fourth module, including how to content.
There used to be a fourth “ingredient” called HTML Imports, but those have been nixed.
In short, web components might be called “components” but they aren’t really components more than technologies. In React, components sort of work like partials. It defines a snippet of HTML that you drop into your code and it outputs in the DOM. Web Components are built off of HTML Elements. They are not replaced when rendered the way they are in JavaScript component frameworks. Web components are quite literally HTML elements and have to obey HTML rules. For example:
We’re generating meaningful HTML up-front rather than rendering it in the browser through the client after the fact. Provide the markup and enhance it! Web components have been around a while now, even if it seems we’re only starting to talk about them now.
Chapter 2: Custom Elements
First off, custom elements are not built-in HTML elements. We instruct what they are and how they behave. They are named with a dash and at must contain least one letter. All of the following are valid names for custom elements:
Just remember that there are some reserved names for MathML and SVG elements, like . Also, they cannot be void elements, e.g. , meaning they have to have a correspoonding closing tag.
Since custom elements are not built-in elements, they are undefined by default — and being undefined can be a useful thing! That means we can use them as containers with default properties. For example, they are display: inline by default and inherit the current font-family, which can be useful to pass down to the contents. We can also use them as styling hooks since they can be selected in CSS. Or maybe they can be used for accessibility hints. The bottom line is that they do not require JavaScript in order to make them immediately useful.
Working with JavaScript. If there is one on the page, we can query it and set a click handler on it with an event listener. But if we were to insert more instances on the page later, we would need to query it when it’s appended and re-run the function since it is not part of the original document rendering.
Defining a custom element
This defines and registers the custom element. It teaches the browser that this is an instance of the Custom Elements API and extends the same class that makes other HTML elements valid HTML elements:
My Element
Check out the methods we get immediate access to:
Breaking down the syntax
customElements
.define(
“my-element”,
class extends HTMLElement {}
);
// Functionally the same as:
class MyElement extends HTMLElement {}
customElements.define(“my-element”, MyElement);
export default myElement
// …which makes it importable by other elements:
import MyElement from ‘./MyElement.js’;
const myElement = new MyElement();
document.body.appendChild(myElement);
//
//
//
// Or simply pull it into a page
// Don’t need to `export default` but it doesn’t hurt to leave it
// My Element
//
A few more custom element methods:
customElements.get(‘my-element’);
// returns MyElement Class
customElements.getName(MyElement);
// returns ‘my-element’
customElements.whenDefined(“my-element”);
// waits for custom element to be defined
const el = document.createElement(“spider-man”);
class SpiderMan extends HTMLElement {
constructor() {
super();
console.log(“constructor!!”);
}
}
customElements.define(“spider-man”, SpiderMan);
customElements.upgrade(el);
// returns “constructor!!”
Custom methods and events:
My Element
Bring your own base class, in the same way web components frameworks like Lit do:
class BaseElement extends HTMLElement {
$ = this.querySelector;
}
// extend the base, use its helper
class myElement extends BaseElement {
firstLi = this.$(“li”);
}
Practice prompt
Create a custom HTML element called that displays the text “Hi, World!” when added to the page:
CodePen Embed Fallback
Enhance the element to accept a name attribute, displaying “Hi, [Name]!” instead:
CodePen Embed Fallback
Chapter 3: HTML Templates
The element is not for users but developers. It is not exposed visibly by browsers.
The browser ignores everything in here.
Templates are designed to hold HTML fragments:
A template is selectable in CSS; it just doesn’t render. It’s a document fragment. The inner document is a #document-fragment. Not sure why you’d do this, but it illustrates the point that templates are selectable:
template { display: block; }` /* Nope */
template + div { height: 100px; width: 100px; } /* Works */
The content property
No, not in CSS, but JavaScript. We can query the inner contents of a template and print them somewhere else.
Hi
Using a Document Fragment without a
const myFrag = document.createDocumentFragment();
myFrag.innerHTML = “
Test
“; // Nope
const myP = document.createElement(“p”); // Yep
myP.textContent = “Hi!”;
myFrag.append(myP);
// use the fragment
document.body.append(myFrag);
Clone a node
Hi
Oops, the component only works one time! We need to clone it if we want multiple instances:
Hi
A more practical example
Let’s stub out a template for a list item and then insert them into an unordered list:
The other way to use templates that we’ll get to in the next module: Shadow DOM
Hi, I’m in the Shadow DOM
Chapter 4: Shadow DOM
Here we go, this is a heady chapter! The Shadow DOM reminds me of playing bass in a band: it’s easy to understand but incredibly difficult to master. It’s easy to understand that there are these nodes in the DOM that are encapsulated from everything else. They’re there, we just can’t really touch them with regular CSS and JavaScript without some finagling. It’s the finagling that’s difficult to master. There are times when the Shadow DOM is going to be your best friend because it prevents outside styles and scripts from leaking in and mucking things up. Then again, you’re most certainly going go want to style or apply scripts to those nodes and you have to figure that part out.
That’s where web components really shine. We get the benefits of an element that’s encapsulated from outside noise but we’re left with the responsibility of defining everything for it ourselves.
Select elements are a great example of the Shadow DOM. Shadow roots! Slots! They’re all part of the puzzle.
Using the Shadow DOM
We covered the element in the last chapter and determined that it renders in the Shadow DOM without getting displayed on the page.
This will render in the Shadow DOM.
In this case, the is rendered as a #shadow-root without the element’s tags. It’s a fragment of code. So, while the paragraph inside the template is rendered, the itself is not. It effectively marks the Shadow DOM’s boundaries. If we were to omit the shadowrootmode attribute, then we simply get an unrendered template. Either way, though, the paragraph is there in the DOM and it is encapsulated from other styles and scripts on the page.
These are all of the elements that can have a shadow.
Breaching the shadow
There are times you’re going to want to “pierce” the Shadow DOM to allow for some styling and scripts. The content is relatively protected but we can open the shadowrootmode and allow some access.
This will render in the Shadow DOM.
Now we can query the div that contains the and select the #shadow-root:
document.querySelector(“div”).shadowRoot
// #shadow-root (open)
//
This will render in the Shadow DOM.
We need that in there so we have something to query in the DOM to get to the paragraph. Remember, the is not actually rendered at all.
Additional shadow attributes
Shadow DOM siblings
When you add a shadow root, it becomes the only rendered root in that shadow host. Any elements after a shadow root node in the DOM simply don’t render. If a DOM element contains more than one shadow root node, the ones after the first just become template tags. It’s sort of like the Shadow DOM is a monster that eats the siblings.
Slots bring those siblings back!
I’m a sibling of a shadow root, and I am visible.
All of the siblings go through the slots and are distributed that way. It’s sort of like slots allow us to open the monster’s mouth and see what’s inside.
Declaring the Shadow DOM
Using templates is the declarative way to define the Shadow DOM. We can also define the Shadow DOM imperatively using JavaScript. So, this is doing the exact same thing as the last code snippet, only it’s done programmatically in JavaScript:
This will render in the Shadow DOM.
Another example:
available
So, is it better to be declarative or imperative? Like the weather where I live, it just depends.
Both approaches have their benefits.
We can set the shadow mode via Javascript as well:
// open
this.attachShadow({mode: open});
// closed
this.attachShadow({mode: closed});
// cloneable
this.attachShadow({cloneable: true});
// delegateFocus
this.attachShadow({delegatesFocus: true});
// serialized
this.attachShadow({serializable: true});
// Manually assign an element to a slot
this.attachShadow({slotAssignment: “manual”});
About that last one, it says we have to manually insert the elements in JavaScript:
This WILL render in shadow DOM but not automatically.
Examples
Scott spent a great deal of time sharing examples that demonstrate different sorts of things you might want to do with the Shadow DOM when working with web components. I’ll rapid-fire those in here.
Get an array of element nodes in a slot
this.shadowRoot.querySelector(‘slot’)
.assignedElements();
// get an array of all nodes in a slot, text too
this.shadowRoot.querySelector(‘slot’)
.assignedNodes();
When did a slot’s nodes change?
let slot = document.querySelector(‘div’)
.shadowRoot.querySelector(“slot”);
slot.addEventListener(“slotchange”, (e) => {
console.log(`Slot “${slot.name}” changed`);
// > Slot “saying” changed
})
Combining imperative Shadow DOM with templates
Back to this example:
available
Let’s get that string out of our JavaScript with reusable imperative shadow HTML:
available
This item is currently:
Slightly better as it grabs the component’s name programmatically to prevent name collisions:
available
This item is currently:
Forms with Shadow DOM
Long story, cut short: maybe don’t create custom form controls as web components. We get a lot of free features and functionalities — including accessibility — with native form controls that we have to recreate from scratch if we decide to roll our own.
In the case of forms, one of the oddities of encapsulation is that form submissions are not automatically connected. Let’s look at a broken form that contains a web component for a custom input:
This input’s value won’t be in the submission! Also, form validation and states are not communicated in the Shadow DOM. Similar connectivity issues with accessibility, where the shadow boundary can interfere with ARIA. For example, IDs are local to the Shadow DOM. Consider how much you really need the Shadow DOM when working with forms.
Element internals
The moral of the last section is to tread carefully when creating your own web components for form controls. Scott suggests avoiding that altogether, but he continued to demonstrate how we could theoretically fix functional and accessibility issues using element internals.
Let’s start with an input value that will be included in the form submission.
Now let’s slot this imperatively:
The value is not communicated yet. We’ll add a static formAssociated variable with internals attached:
Then we’ll set the form value as part of the internals when the input’s value changes:
Here’s how we set states with element internals:
// add a checked state
this.internals.states.add(“checked”);
// remove a checked state
this.internals.states.delete(“checked”);
Let’s toggle a “add” or “delete” a boolean state:
Let’s refactor this for ARIA improvements:
Phew, that’s a lot of work! And sure, this gets us a lot closer to a more functional and accessible custom form input, but there’s still a long way’s to go to achieve what we already get for free from using native form controls. Always question whether you can rely on a light DOM form instead.
Chapter 5: Styling Web Components
Styling web components comes in levels of complexity. For example, we don’t need any JavaScript at all to slap a few styles on a custom element.
- This is not encapsulated! This is scoped off of a single element just light any other CSS in the Light DOM.
- Changing the Shadow DOM mode from closed to open doesn’t change CSS. It allows JavaScript to pierce the Shadow DOM but CSS isn’t affected.
Let’s poke at it
Hi
Hi
- This is three stacked paragraphs, the second of which is in the shadow root.
- The first and third paragraphs are red; the second is not styled because it is in a , even if the shadow root’s mode is set to open.
Let’s poke at it from the other direction:
Hi
Hi
- The first and third paragraphs are still receiving the red color from the Light DOM’s CSS.
- The declarations in the are encapsulated and do not leak out to the other paragraphs, even though it is declared later in the cascade.
Same idea, but setting the color on the :
Hi
Hi
- Everything is red! This isn’t a bug. Inheritable styles do pass through the Shadow DOM barrier.
- Inherited styles are those that are set by the computed values of their parent styles. Many properties are inheritable, including color. The is the parent and everything in it is a child that inherits these styles, including custom elements.
Let’s fight with inheritance
We can target the paragraph in the style block to override the styles set on the . Those won’t leak back to the other paragraphs.
Hi
Hi
- This is protected, but the problem here is that it’s still possible for a new role or property to be introduced that passes along inherited styles that we haven’t thought to reset.
- Perhaps we could use all: initital as a defensive strategy against future inheritable styles. But what if we add more elements to the custom element? It’s a constant fight.
Host styles!
We can scope things to the shadow root’s :host selector to keep things protected.
Hi
Hi
New problem! What if the Light DOM styles are scoped to the universal selector instead?
Hi
Hi
This breaks the custom element’s styles. But that’s because Shadow DOM styles are applied before Light DOM styles. The styles scoped to the universal selector are simply applied after the :host styles, which overrides what we have in the shadow root. So, we’re still locked in a brutal fight over inheritance and need stronger specificity.
According to Scott, !important is one of the only ways we have to apply brute force to protect our custom elements from outside styles leaking in. The keyword gets a bad rap — and rightfully so in the vast majority of cases — but this is a case where it works well and using it is an encouraged practice. It’s not like it has an impact on the styles outside the custom element, anyway.
Hi
Hi
Special selectors
There are some useful selectors we have to look at components from the outside, looking in.
:host()
We just looked at this! But note how it is a function in addition to being a pseudo-selector. It’s sort of a parent selector in the sense that we can pass in the that contains the and that becomes the scoping context for the entire selector, meaning the !important keyword is no longer needed.
Hi
Hi
:host-context()
This targets the shadow host but only if the provided selector is a parent node anywhere up the tree. This is super helpful for styling custom elements where the layout context might change, say, from being contained in an versus being contained in a .
:defined
Defining an element occurs when it is created, and this pseudo-selector is how we can select the element in that initially-defined state. I imagine this is mostly useful for when a custom element is defined imperatively in JavaScript so that we can target the very moment that the element is constructed, and then set styles right then and there.
Minor note about protecting against a flash of unstyled content (FOUC)… or unstyled element in this case. Some elements are effectively useless until JavsScript has interacted with it to generate content. For example, an empty custom element that only becomes meaningful once JavaScript runs and generates content. Here’s how we can prevent the inevitable flash that happens after the content is generated:
Warning zone! It’s best for elements that are empty and not yet defined. If you’re working with a meaningful element up-front, then it’s best to style as much as you can up-front.
Styling slots
This does not style the paragraph green as you might expect:
The Shadow DOM cannot style this content directly. The styles would apply to a paragraph in the that gets rendered in the Light DOM, but it cannot style it when it is slotted into the .
Slots are part of the Light DOM. So, this works:
This means that slots are easier to target when it comes to piercing the shadow root with styles, making them a great method of progressive style enhancement.
We have another special selected, the ::slotted() pseudo-element that’s also a function. We pass it an element or class and that allows us to select elements from within the shadow root.
Unfortunately, ::slotted() is a weak selected when compared to global selectors. So, if we were to make this a little more complicated by introducing an outside inheritable style, then we’d be hosed again.
This is another place where !important could make sense. It even wins if the global style is also set to !important. We could get more defensive and pass the universal selector to ::slotted and set everything back to its initial value so that all slotted content is encapsulated from outside styles leaking in.
Styling :parts
A part is a way of offering up Shadow DOM elements to the parent document for styling. Let’s add a part to a custom element:
Without the part attribute, there is no way to write styles that reach the paragraph. But with it, the part is exposed as something that can be styled.
We can use this to expose specific “parts” of the custom element that are open to outside styling, which is almost like establishing a styling API with specifications for what can and can’t be styled. Just note that ::part cannot be used as part of a complex selector, like a descendant selector:
A bit in the weeds here, but we can export parts in the sense that we can nest elements within elements within elements, and so on. This way, we include parts within elements.
Styling states and validity
We discussed this when going over element internals in the chapter about the Shadow DOM. But it’s worth revisiting that now that we’re specifically talking about styling. We have a :state pseudo-function that accepts our defined states.
We also have access to the :invalid pseudo-class.
Cross-barrier custom properties
Custom properties cross the Shadow DOM barrier!
Adding stylesheets to custom elements
There’s the classic ol’ external way of going about it:
This one’s in the shadow Dom.
Slotted Element
It might seem like an anti-DRY approach to call the same external stylesheet at the top of all web components. To be clear, yes, it is repetitive — but only as far as writing it. Once the sheet has been downloaded once, it is available across the board without any additional requests, so we’re still technically dry in the sense of performance.
CSS imports also work:
This one’s in the shadow Dom.
Slotted Element
One more way using a JavaScript-based approach. It’s probably better to make CSS work without a JavaScript dependency, but it’s still a valid option.
We have a JavaScript module and import CSS into a string that is then adopted by the shadow root using shadowRoort.adoptedStyleSheets . And since adopted stylesheets are dynamic, we can construct one, share it across multiple instances, and update styles via the CSSOM that ripple across the board to all components that adopt it.
Container queries!
Container queries are nice to pair with components, as custom elements and web components are containers and we can query them and adjust things as the container changes.
In this example, we’re setting styles on the :host() to define a new container, as well as some general styles that are protected and scoped to the shadow root. From there, we introduce a container query that updates the unordered list’s layout when the custom element is at least 50em wide.
Next up…
How web component features are used together!
Chapter 6: HTML-First Patterns
In this chapter, Scott focuses on how other people are using web components in the wild and highlights a few of the more interesting and smart patterns he’s seen.
Let’s start with a typical counter
It’s often the very first example used in React tutorials.
Reef
Reef is a tiny library by Chris Ferdinandi that weighs just 2.6KB minified and zipped yet still provides DOM diffing for reactive state-based UIs like React, which weighs significantly more. An example of how it works in a standalone way:
This sets up a “signal” that is basically a live-update object, then calls the component() method to select where we want to make the update, and it injects a template literal in there that passes in the variables with the markup we want.
So, for example, we can update those values on setTimeout:
We can combine this sort of library with a web component. Here, Scott imports Reef and constructs the data outside the component so that it’s like the application state:
It’s the virtual DOM in a web component! Another approach that is more reactive in the sense that it watches for changes in attributes and then updates the application state in response which, in turn, updates the greeting.
If the attribute changes, it only changes that instance. The data is registered at the time the component is constructed and we’re only changing string attributes rather than objects with properties.
HTML Web Components
This describes web components that are not empty by default like this:
This is a “React” mindset where all the functionality, content, and behavior comes from JavaScript. But Scott reminds us that web components are pretty useful right out of the box without JavaScript. So, “HTML web components” refers to web components that are packed with meaningful content right out of the gate and Scott points to Jeremy Keith’s 2023 article coining the term.
[…] we could call them “HTML web components.” If your custom element is empty, it’s not an HTML web component. But if you’re using a custom element to extend existing markup, that’s an HTML web component.
Jeremy cites something Robin Rendle mused about the distinction:
[…] I’ve started to come around and see Web Components as filling in the blanks of what we can do with hypertext: they’re really just small, reusable chunks of code that extends the language of HTML.
The “React” way:
The props look like HTML but they’re not. Instead, the props provide information used to completely swap out the tag with the JavaScript-based markup.
Web components can do that, too:
Same deal, real HTML. Progressive enhancement is at the heart of an HTML web component mindset. Here’s how that web component might work:
class UserAvatar extends HTMLElement {
connectedCallback() {
const src = this.getAttribute(“src”);
const name = this.getAttribute(“name”);
this.innerHTML = `
`;
}
}
customElements.define(‘user-avatar’, UserAvatar);
But a better starting point would be to include the directly in the component so that the markup is immediately available:
This way, the image is downloaded and ready before JavaScript even loads on the page. Strive for augmentation over replacement!
resizeasaurus
This helps developers test responsive component layouts, particularly ones that use container queries.
Drop any HTML in here to test.
lite-youtube-embed
This is like embedding a YouTube video, but without bringing along all the baggage that YouTube packs into a typical embed snippet.
Play Video: Keynote (Google I/O ’18)
Hello, ${this.name}!`;
}
}
customElements.define(‘simple-greeting’, SimpleGreeting);
]]>
ProsConsEcosystemNo official SSR story (but that is changing)CommunityFamiliar ergonomicsLightweightIndustry-proven
webc
This is part of the 11ty project. It allows you to define custom elements as files, writing everything as a single file component.
This is inside the element
ProsConsCommunityGeared toward SSGSSG progressive enhancementStill in early stagesSingle file component syntaxZach Leatherman!
Enhance
This is Scott’s favorite! It renders web components on the server. Web components can render based on application state per request. It’s a way to use custom elements on the server side.
ProsConsErgonomicsStill in early stagesProgressive enhancementSingle file component syntaxFull-stack stateful, dynamic SSR components
Chapter 8: Web Components Libraries Tour
This is a super short module simply highlighting a few of the more notable libraries for web components that are offered by third parties. Scott is quick to note that all of them are closer in spirit to a React-based approach where custom elements are more like replaced elements with very little meaningful markup to display up-front. That’s not to throw shade at the libraries, but rather to call out that there’s a cost when we require JavaScript to render meaningful content.
Spectrum
Use Spectrum Web Component buttons
- This is Adobe’s design system.
- One of the more ambitious projects, as it supports other frameworks like React
- Open source
- Built on Lit
Most components are not exactly HTML-first. The pattern is closer to replaced elements. There’s plenty of complexity, but that makes sense for a system that drives an application like Photoshop and is meant to drop into any project. But still, there is a cost when it comes to delivering meaningful content to users up-front. An all-or-nothing approach like this might be too stark for a small website project.
FAST
Checkbox
- This is Microsoft’s system.
- It’s philosophically like Spectrum where there’s very little meaningful HTML up-front.
- Fluent is a library that extends the system for UI components.
- Microsoft Edge rebuilt the browser’s Chrome using these components.
Shoelace
Click Me
- Purely meant for third-party developers to use in their projects
- The name is a play on Bootstrap. 🙂
- The markup is mostly a custom element with some text in it rather than a pure HTML-first approach.
- Acquired by Font Awesome and they are creating Web Awesome Components as a new era of Shoelace that is subscription-based
Chapter 9: What’s Next With Web Components
Scott covers what the future holds for web components as far as he is aware.
Declarative custom elements
Define an element in HTML alone that can be used time and again with a simpler syntax. There’s a GitHub issue that explains the idea, and Zach Leatherman has a great write-up as well.
Cross-root ARIA
Make it easier to pair custom elements with other elements in the Light DOM as well as other custom elements through ARIA.
Container Queries
How can we use container queries without needing an extra wrapper around the custom element?
HTML Modules
This was one of the web components’ core features but was removed at some point. They can define HTML in an external place that could be used over and over.
External styling
This is also known as “open styling.”
DOM Parts
This would be a templating feature that allows for JSX-string-literal-like syntax where variables inject data.
Email: {email}
And the application has produced a template with the following content:
Email: {{}}
Scoped element registries
Using variations of the same web component without name collisions.