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 <tasty-pizza>
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.
<my-fancy-heading>
Hey, I'm Fancy
</my-fancy-heading>
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.
<template>
<li class="user">
<h2 class="name"></h2>
<p class="bio"></p>
</li>
</template>
Much more on this in the third module.
Shadow DOM
The DOM is queryable.
document.querySelector("h1");
// <h1>Hello, World</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 <slot>
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:
<!-- Nope -->
<ul>
<my-list-item></my-list-item>
<!-- etc. -->
</ul>
<!-- Yep -->
<ul>
<li>
<my-list-item></my-list-item>
</li>
</ul>
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:
<super-component>
<a->
<a-4->
<card-10.0.1>
<card-♠️>
Just remember that there are some reserved names for MathML and SVG elements, like <font-face>
. Also, they cannot be void elements, e.g. <my-element />
, 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 <my-button>
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>My Element</my-element>
<script>
customElements.define("my-element", class extends HTMLElement );
</script>
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);
// <body>
// <my-element></my-element>
// </body>
// Or simply pull it into a page
// Don't need to `export default` but it doesn't hurt to leave it
// <my-element>My Element</my-element>
// <script type="module" src="my-element.js"></script>
It’s possible to define a custom element by extending a specific HTML element. The specification documents this, but Scott is focusing on the primary way.
class WordCount extends HTMLParagraphElement
customElements.define("word-count", WordCount, extends: "p" );
// <p is="word-count">This is a custom paragraph!</p>
Scott says do not use this because WebKit is not going to implement it. We would have to polyfill it forever, or as long as WebKit holds out. Consider it a dead end.
The lifecycle
A component has various moments in its “life” span:
- Constructed (
constructor
) - Connected (
connectedCallback
) - Adopted (
adoptedCallback
) - Attribute Changed (
attributeChangedCallback
) - Disconnected (
disconnectedCallback
)
We can hook into these to define the element’s behavior.
class myElement extends HTMLElement
constructor()
connectedCallback()
adoptedCallback()
attributeChangedCallback()
disconnectedCallback()
customElements.define("my-element", MyElement);
constructor()
class myElement extends HTMLElement
constructor()
// provides us with the `this` keyword
super()
// add a property
this.someProperty = "Some value goes here";
// add event listener
this.addEventListener("click", () => );
customElements.define("my-element", MyElement);
“When the constructor is called, do this…” We don’t have to have a constructor when working with custom elements, but if we do, then we need to call super()
because we’re extending another class and we’ll get all of those properties.
Constructor is useful, but not for a lot of things. It’s useful for setting up initial state, registering default properties, adding event listeners, and even creating Shadow DOM (which Scott will get into in a later module). For example, we are unable to sniff out whether or not the custom element is in another element because we don’t know anything about its parent container yet (that’s where other lifecycle methods come into play) — we’ve merely defined it.
connectedCallback()
class myElement extends HTMLElement
// the constructor is unnecessary in this example but doesn't hurt.
constructor()
super()
// let me know when my element has been found on the page.
connectedCallback()
console.log(`$this.nodeName was added to the page.`);
customElements.define("my-element", MyElement);
Note that there is some strangeness when it comes to timing things. Sometimes isConnected
returns true
during the constructor. connectedCallback()
is our best way to know when the component is found on the page. This is the moment it is connected to the DOM. Use it to attach event listeners.
If the <script>
tag comes before the DOM is parsed, then it might not recognize childNodes
. This is not an uncommon situation. But if we add type="module"
to the <script>
, then the script is deferred and we get the child nodes. Using setTimeout
can also work, but it looks a little gross.
disconnectedCallback
class myElement extends HTMLElement
// let me know when my element has been found on the page.
disconnectedCallback()
console.log(`$this.nodeName was removed from the page.`);
customElements.define("my-element", MyElement);
This is useful when the component needs to be cleaned up, perhaps like stopping an animation or preventing memory links.
adoptedCallback()
This is when the component is adopted by another document or page. Say you have some iframes on a page and move a custom element from the page into an iframe, then it would be adopted in that scenario. It would be created, then added, then removed, then adopted, then added again. That’s a full lifecycle! This callback is adopted automatically simply by picking it up and dragging it between documents in the DOM.
Custom elements and attributes
Unlike React, HTML attributes are strings (not props!). Global attributes work as you’d expect, though some global attributes are reflected as properties. You can make any attribute do that if you want, just be sure to use care and caution when naming because, well, we don’t want any conflicts.

Avoid standard attributes on a custom element as well, as that can be confusing particularly when handing a component to another developer. Example: using type as an attribute which is also used by <input>
elements. We could say data-type
instead. (Remember that Chris has a comprehensive guide on using data attributes.)
Examples
Here’s a quick example showing how to get a greeting
attribute and set it on the custom element:
class MyElement extends HTMLElement
get greeting()
return this.getAttribute('greeting');
// return this.hasAttribute('greeting');
set greeting(val)
if(val)
this.setAttribute('greeting', val);
// this setAttribute('greeting', '');
else
this.removeAttribute('greeting');
customElements.define("my-element", MyElement);
Another example, this time showing a callback for when the attribute has changed, which prints it in the element’s contents:
<my-element greeting="hello">hello</my-element>
<!-- Change text greeting when attribite greeting changes -->
<script>
class MyElement extends HTMLElement
static observedAttributes = ["greeting"];
attributeChangedCallback(name, oldValue, newValue)
if (name === 'greeting' && oldValue && oldValue !== newValue)
console.log(name + " changed");
this.textContent = newValue;
customElements.define("my-element", MyElement);
</script>
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><button>My Element</button></my-element>
<script>
customElements.define("my-element", class extends HTMLElement
connectedCallback()
const btn = this.firstElementChild;
btn.addEventListener("click", this.handleClick)
handleClick()
console.log(this);
);
</script>
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 <say-hi>
that displays the text “Hi, World!” when added to the page:
Enhance the element to accept a name
attribute, displaying "Hi, [Name]!"
instead:
Chapter 3: HTML Templates
The <template>
element is not for users but developers. It is not exposed visibly by browsers.
<template>The browser ignores everything in here.</template>
Templates are designed to hold HTML fragments:
<template>
<div class="user-profile">
<h2 class="name">Scott</h2>
<p class="bio">Author</p>
</div>
</template>
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.
<template>
<p>Hi</p>
</template>
<script>
const myTmpl = documenty.querySelector("template").content;
console.log(myTmpl);
</script>
Using a Document Fragment without a <template>
const myFrag = document.createDocumentFragment();
myFrag.innerHTML = "<p>Test</p>"; // Nope
const myP = document.createElement("p"); // Yep
myP.textContent = "Hi!";
myFrag.append(myP);
// use the fragment
document.body.append(myFrag);
Clone a node
<template>
<p>Hi</p>
</template>
<script>
const myTmpl = documenty.querySelector("template").content;
console.log(myTmpl);
// Oops, only works one time! We need to clone it.
</script>
Oops, the component only works one time! We need to clone it if we want multiple instances:
<template>
<p>Hi</p>
</template>
<script>
const myTmpl = document.querySelector("template").content;
document.body.append(myTmpl.cloneNode(true)); // true is necessary
document.body.append(myTmpl.cloneNode(true));
document.body.append(myTmpl.cloneNode(true));
document.body.append(myTmpl.cloneNode(true));
</script>
A more practical example
Let’s stub out a template for a list item and then insert them into an unordered list:
<template id="tmpl-user"><li><strong></strong>: <span></span></li></template>
<ul id="users"></ul>
<script>
const usersElement = document.querySelector("#users");
const userTmpl = document.querySelector("#tmpl-user").content;
const users = [name: "Bob", title: "Artist", name: "Jane", title: "Doctor"];
users.forEach(user =>
let thisLi = userTmpl.cloneNode(true);
thisLi.querySelector("strong").textContent = user.name;
thisLi.querySelector("span").textContent = user.title;
usersElement.append(thisLi);
);
</script>
The other way to use templates that we’ll get to in the next module: Shadow DOM
<template shadowroot=open>
<p>Hi, I'm in the Shadow DOM</p>
</template>
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.

Using the Shadow DOM
We covered the <template>
element in the last chapter and determined that it renders in the Shadow DOM without getting displayed on the page.
<template shadowrootmode="closed">
<p>This will render in the Shadow DOM.</p>
</template>

In this case, the <template>
is rendered as a #shadow-root
without the <template>
element’s tags. It’s a fragment of code. So, while the paragraph inside the template is rendered, the <template>
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.

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.
<div>
<template shadowrootmode="open">
<p>This will render in the Shadow DOM.</p>
</template>
</div>
Now we can query the div
that contains the <template>
and select the #shadow-root
:
document.querySelector("div").shadowRoot
// #shadow-root (open)
// <p>This will render in the Shadow DOM.</p>
We need that <div>
in there so we have something to query in the DOM to get to the paragraph. Remember, the <template>
is not actually rendered at all.
Additional shadow attributes
<!-- should this root stay with a parent clone? -->
<template shadowrootcloneable>
<!-- allow shadow to be serialized into a string object — can forget about this -->
<template shadowrootserializable>
<!-- click in element focuses first focusable element -->
<template shadowrootdelegatesfocus>
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!
<div>
<template shadowroot="closed">
<slot></slot>
<p>I'm a sibling of a shadow root, and I am visible.</p>
</template>
</div>
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:
<my-element>
<template shadowroot="open">
<p>This will render in the Shadow DOM.</p>
</template>
</my-element>
<script>
customElements.define('my-element', class extends HTMLElement
constructor()
super();
// attaches a shadow root node
this.attachShadow(mode: "open");
// inserts a slot into the template
this.shadowRoot.innerHTML = '<slot></slot>';
);
</script>
Another example:
<my-status>available</my-status>
<script>
customElements.define('my-status', class extends HTMLElement
constructor()
super();
this.attachShadow(mode: "open");
this.shadowRoot.innerHTML = '<p>This item is currently: <slot></slot></p>';
);
</script>
So, is it better to be declarative or imperative? Like the weather where I live, it just depends.

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 <slot>
elements in JavaScript:
<my-element>
<p>This WILL render in shadow DOM but not automatically.</p>
</my-element>
<script>
customElements.define('my-element', class extends HTMLElement
constructor()
super();
this.attachShadow(
mode: "open",
slotAssignment: "manual"
);
this.shadowRoot.innerHTML = '<slot></slot>';
connectedCallback()
const slotElem = this.querySelector('p');
this.shadowRoot.querySelector('slot').assign(slotElem);
);
</script>
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:
<my-status>available</my-status>
<script>
customElements.define('my-status', class extends HTMLElement
constructor()
super();
this.attachShadow(mode: "open");
this.shadowRoot.innerHTML = '<p>This item is currently: <slot></slot></p>';
);
</script>
Let’s get that string out of our JavaScript with reusable imperative shadow HTML:
<my-status>available</my-status>
<template id="my-status">
<p>This item is currently:
<slot></slot>
</p>
</template>
<script>
customElements.define('my-status', class extends HTMLElement
constructor()
super();
this.attachShadow(mode: 'open');
const template = document.getElementById('my-status');
this.shadowRoot.append(template.content.cloneNode(true));
);
</script>
Slightly better as it grabs the component’s name programmatically to prevent name collisions:
<my-status>available</my-status>
<template id="my-status">
<p>This item is currently:
<slot></slot>
</p>
</template>
<script>
customElements.define('my-status', class extends HTMLElement
constructor()
super();
this.attachShadow(mode: 'open');
const template = document.getElementById( this.nodeName.toLowerCase() );
this.shadowRoot.append(template.content.cloneNode(true));
);
</script>
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:
<form>
<my-input>
<template shadowrootmode="open">
<label>
<slot></slot>
<input type="text" name="your-name">
</label>
</template>
Type your name!
</my-input>
<label><input type="checkbox" name="remember">Remember Me</label>
<button>Submit</button>
</form>
<script>
document.forms[0].addEventListener('input', function()
let data = new FormData(this);
console.log(new URLSearchParams(data).toString());
);
</script>
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.
<form>
<my-input name="name"></my-input>
<button>Submit</button>
</form>
Now let’s slot this imperatively:
<script>
customElements.define('my-input', class extends HTMLElement
constructor()
super();
this.attachShadow(mode: 'open');
this.shadowRoot.innerHTML = '<label><slot></slot><input type="text"></label>'
);
</script>
The value is not communicated yet. We’ll add a static formAssociated
variable with internals attached:
<script>
customElements.define('my-input', class extends HTMLElement
static formAssociated = true;
constructor()
super();
this.attachShadow(mode: 'open');
this.shadowRoot.innerHTML = '<label><slot></slot><input type="text"></label>'
this.internals = this.attachedInternals();
);
</script>
Then we’ll set the form value as part of the internals when the input’s value changes:
<script>
customElements.define('my-input', class extends HTMLElement
static formAssociated = true;
constructor()
super();
this.attachShadow(mode: 'open');
this.shadowRoot.innerHTML = '<label><slot></slot><input type="text"></label>'
this.internals = this.attachedInternals();
this.addEventListener('input', () =>
this-internals.setFormValue(this.shadowRoot.querySelector('input').value);
);
);
</script>
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:
<form>
<my-check name="remember">Remember Me?</my-check>
</form>
<script>
customElements.define('my-check', class extends HTMLElement
static formAssociated = true;
constructor()
super();
this.attachShadow(mode: 'open');
this.shadowRoot.innerHTML = '<slot></slot>';
this.internals = this.attachInternals();
let addDelete = false;
this.addEventListener("click", ()=>
addDelete = !addDelete;
this.internals.states[addDelete ? "add" : "delete"]("checked");
);
);
</script>
Let’s refactor this for ARIA improvements:
<form>
<style>
my-check display: inline-block; inline-size: 1em; block-size: 1em; background: #eee;
my-check:state(checked)::before content: "[x]";
</style>
<my-check name="remember" id="remember"></my-check><label for="remember">Remember Me?</label>
</form>
<script>
customElements.define('my-check', class extends HTMLElement
static formAssociated = true;
constructor()
super();
this.attachShadow(mode: 'open');
this.internals = this.attachInternals();
this.internals.role = 'checkbox';
this.setAttribute('tabindex', '0');
let addDelete = false;
this.addEventListener("click", ()=>
addDelete = !addDelete;
this.internals.states[addDelete ? "add" : "delete"]("checked");
this[addDelete ? "setAttribute" : "removeAttribute"]("aria-checked", true);
);
);
</script>

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.
<my-element theme="suave" class="priority">
<h1>I'm in the Light DOM!</h1>
</my-element>
<style>
/* Element, class, attribute, and complex selectors all work. */
my-element
display: block; /* custom elements are inline by default */
.my-element[theme=suave]
color: #fff;
.my-element.priority
background: purple;
.my-element h1
font-size: 3rem;
</style>
- 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
toopen
doesn’t change CSS. It allows JavaScript to pierce the Shadow DOM but CSS isn’t affected.
Let’s poke at it
<style>
p color: red;
</style>
<p>Hi</p>
<div>
<template shadowrootmode="open">
<p>Hi</p>
</template>
</div>
<p>Hi</p>
- 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
<template>
, even if the shadow root’s mode is set toopen
.
Let’s poke at it from the other direction:
<style>
p color: red;
</style>
<p>Hi</p>
<div>
<template shadowrootmode="open">
<style> p color: blue; </style>
<p>Hi</p>
</template>
</div>
<p>Hi</p>
- The first and third paragraphs are still receiving the red color from the Light DOM’s CSS.
- The
<style>
declarations in the<template>
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 <body>
:
<style>
body color: red;
</style>
<p>Hi</p>
<div>
<template shadowrootmode="open">
<p>Hi</p>
</template>
</div>
<p>Hi</p>
- 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<body>
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 <template>
style block to override the styles set on the <body>
. Those won’t leak back to the other paragraphs.
<style>
body
color: red;
font-family: fantasy;
font-size: 2em;
</style>
<p>Hi</p>
<div>
<template shadowrootmode="open">
<style>
/* reset the light dom styles */
p
color: initial;
font-family: initial;
font-size: initial;
</style>
<p>Hi</p>
</template>
</div>
<p>Hi</p>
- 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.
<style>
body
color: red;
font-family: fantasy;
font-size: 2em;
</style>
<p>Hi</p>
<div>
<template shadowrootmode="open">
<style>
/* reset the light dom styles */
:host all: initial;
</style>
<p>Hi</p>
<a href="#">Click me</a>
</template>
</div>
<p>Hi</p>
New problem! What if the Light DOM styles are scoped to the universal selector instead?
<style>
*
color: red;
font-family: fantasy;
font-size: 2em;
</style>
<p>Hi</p>
<div>
<template shadowrootmode="open">
<style>
/* reset the light dom styles */
:host all: initial;
</style>
<p>Hi</p>
<a href="#">Click me</a>
</template>
</div>
<p>Hi</p>
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.
<style>
*
color: red;
font-family: fantasy;
font-size: 2em;
</style>
<p>Hi</p>
<div>
<template shadowrootmode="open">
<style>
/* reset the light dom styles */
:host all: initial; !important
</style>
<p>Hi</p>
<a href="#">Click me</a>
</template>
</div>
<p>Hi</p>
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 <div>
that contains the <template>
and that becomes the scoping context for the entire selector, meaning the !important
keyword is no longer needed.
<style>
*
color: red;
font-family: fantasy;
font-size: 2em;
</style>
<p>Hi</p>
<div>
<template shadowrootmode="open">
<style>
/* reset the light dom styles */
:host(div) all: initial;
</style>
<p>Hi</p>
<a href="#">Click me</a>
</template>
</div>
<p>Hi</p>
:host-context()
<header>
<my-element>
<template shadowrootmode="open">
<style>
:host-context(header) ... /* matches the host! */
</style>
</template>
</my-element>
</header>
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 <article>
versus being contained in a <header>
.
: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.
<style>
simple-custom:defined display: block; background: green; color: #fff;
</style>
<simple-custom></simple-custom>
<script>
customElements.define('simple-custom', class extends HTMLElement
constructor()
super();
this.attachShadow(mode: 'open');
this.shadowRoot.innerHTML = "<p>Defined!</p>";
);
</script>
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:
<style>
js-dependent-element:not(:defined)
visibility: hidden;
</style>
<js-dependent-element></js-dependent-element>
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:
<div>
<template shadowrootmode="open">
<style>
p color: green;
</style>
<slot></slot>
</template>
<p>Slotted Element</p>
</div>
The Shadow DOM cannot style this content directly. The styles would apply to a paragraph in the <template>
that gets rendered in the Light DOM, but it cannot style it when it is slotted into the <template>
.
Slots are part of the Light DOM. So, this works:
<style>
p color: green;
</style>
<div>
<template shadowrootmode="open">
<slot></slot>
</template>
<p>Slotted Element</p>
</div>
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.
<div>
<template shadowrootmode="open">
<style> ::slotted(p) color: red; </style>
<slot></slot>
</template>
<p>Slotted Element</p>
</div>
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.
<style>
/* global paragraph style... */
p color: green;
</style>
<div>
<template shadowrootmode="open">
<style>
/* ...overrides the slotted style */
::slotted(p) color: red;
</style>
<slot></slot>
</template>
<p>Slotted Element</p>
</div>
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.
<style>
/* global paragraph style... */
p color: green;
</style>
<div>
<template shadowrootmode="open">
<style>
/* ...can't override this important statement */
::slotted(*) all: initial !important;
</style>
<slot></slot>
</template>
<p>Slotted Element</p>
</div>
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:
<div>
<template shadowrootmode="open">
<p part="hi">Hi there, I'm a part!</p>
</template>
</div>
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.
<style>
::part(hi) color: green;
::part(hi) b color: green; /* nope! */
</style>
<div>
<template shadowrootmode="open">
<p part="hi">Hi there, I'm a <b>part</b>!</p>
</template>
</div>
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.
<my-component>
<!-- exposes three parts to the nested component -->
<nested-component exportparts="part1, part2, part5"></nested-component>
</my-component>
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.
<script>
this.internals.states.add("checked");
</script>
<style>
my-checkbox:state(checked)
/* ... */
</style>
We also have access to the :invalid
pseudo-class.
Cross-barrier custom properties
<style>
:root
--text-primary: navy;
--bg-primary: #abe1e1;
--padding: 1.5em 1em;
p
color: var(--text-primary);
background: var(--bg-primary);
padding: var(--padding);
</style>
Custom properties cross the Shadow DOM barrier!
<my-elem></my-elem>
<script>
customElements.define('my-elem', class extends HTMLElement
constructor()
super();
this.attachShadow(mode: 'open');
this.shadowRoot.innerHTML = `
<style>
p
color: var(--text-primary);
background: var(--bg-primary);
padding: var(--padding);
</style>
<p>Hi there!</p>`;
)
</script>

Adding stylesheets to custom elements
There’s the classic ol’ external <link>
way of going about it:
<simple-custom>
<template shadowrootmode="open">
<link rel="stylesheet" href="../../assets/external.css">
<p>This one's in the shadow Dom.</p>
<slot></slot>
</template>
<p>Slotted <b>Element</b></p>
</simple-custom>
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:
<style>
@import url("../../assets/external.css");
</style>
<simple-custom>
<template shadowrootmode="open">
<style>
@import url("../../assets/external.css");
</style>
<p>This one's in the shadow Dom.</p>
<slot></slot>
</template>
<p>Slotted <b>Element</b></p>
</simple-custom>
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.
<my-elem></my-elem>
<script type="module">
import sheet from '../../assets/external.css' with type: 'css' ;
customElements.define('my-elem', class extends HTMLElement
constructor()
super();
this.attachShadow(mode: 'open');
this.shadowRoot.innerHTML = '<p>Hi there</p>';
this.shadowRoot.adoptedStyleSheets = [sheet];
)
</script>
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.
<div>
<template shadowrootmode="open">
<style>
:host
container-type: inline-size;
background-color: tan;
display: block;
padding: 2em;
ul
display: block;
list-style: none;
margin: 0;
li
padding: .5em;
margin: .5em 0;
background-color: #fff;
@container (min-width: 50em)
ul
display: flex;
justify-content: space-between;
gap: 1em;
li
flex: 1 1 auto;
</style>
<ul>
<li>First Item</li>
<li>Second Item</li>
</ul>
</template>
</div>
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.
<counter-element></counter-element>
<script type="module">
customElements.define('counter-element', class extends HTMLElement
#count = 0;
connectedCallback()
this.innerHTML = `<button id="dec">-</button><p id="count">$this.#count</p><button id="inc">+</button>`;
this.addEventListener('click', e => this.update(e) );
update(e)
if( e.target.nodeName !== 'BUTTON' ) return
this.#count = e.target.id === 'inc' ? this.#count + 1 : this.#count - 1;
this.querySelector('#count').textContent = this.#count;
);
</script>
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:
<div id="greeting"></div>
<script type="module">
import signal, component from '.../reef.es..min.js';
// Create a signal
let data = signal(
greeting: 'Hello',
name: 'World'
);
component('#greeting', () => `<p>$data.greeting, $data.name!</p>`);
</script>
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
:
<div id="greeting"></div>
<script type="module">
import signal, component from '.../reef.es..min.js';
// Create a signal
let data = signal(
greeting: 'Hello',
name: 'World'
);
component('#greeting', () => `<p>$data.greeting, $data.name!</p>`);
setTimeout(() =>
data.greeting = '¡Hola'
data,name = 'Scott'
, 3000)
</script>
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:
<my-greeting></my-greeting>
<script type="module">
import signal, component from 'https://cdn.jsdelivr.net/npm/reefjs@13/dist/reef.es.min.js';
window.data = signal(
greeting: 'Hi',
name: 'Scott'
);
customElements.define('my-greeting', class extends HTMLElement
connectedCallback()
component(this, () => `<p>$data.greeting, $data.name!</p>` );
);
</script>
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.
<my-greeting greeting="Hi" name="Scott"></my-greeting>
<script type="module">
import signal, component from 'https://cdn.jsdelivr.net/npm/reefjs@13/dist/reef.es.min.js';
customElements.define('my-greeting', class extends HTMLElement
static observedAttributes = ["name", "greeting"];
constructor()
super();
this.data = signal(
greeting: '',
name: ''
);
attributeChangedCallback(name, oldValue, newValue)
this.data[name] = newValue;
connectedCallback()
component(this, () => `<p>$this.data.greeting, $this.data.name!</p>` );
);
</script>
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:
<my-greeting></my-greeting>
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:
<UserAvatar
src="https://example.com/path/to/img.jpg"
alt="..."
/>
The props look like HTML but they’re not. Instead, the props provide information used to completely swap out the <UserAvatar />
tag with the JavaScript-based markup.
Web components can do that, too:
<user-avatar
src="https://example.com/path/to/img.jpg"
alt="..."
></user-avatar>
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 = `
<div>
<img src="$src" alt="Profile photo of $name" width="32" height="32" />
<!-- Markup for the tooltip -->
</div>
`;
customElements.define('user-avatar', UserAvatar);
But a better starting point would be to include the <img>
directly in the component so that the markup is immediately available:
<user-avatar>
<img src="https://example.com/path/to/img.jpg" alt="..." />
</user-avatar>
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.
<resize-asaurus>
Drop any HTML in here to test.
</resize-asaurus>
<!-- for example: -->
<resize-asaurus>
<div class="my-responsive-grid">
<div>Cell 1</div> <div>Cell 2</div> <div>Cell 3</div> <!-- ... -->
</div>
</resize-asaurus>

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.
<lite-youtube videoid="ogYfd705cRs" style="background-image: url(...);">
<a href="https://youtube.com/watch?v=ogYfd705cRs" class="lyt-playbtn" title="Play Video">
<span class="lyt-visually-hidden">Play Video: Keynote (Google I/O '18)</span>
</a>
</lite-youtube>
<link rel="stylesheet" href="./src.lite-yt-embed.css" />
<script src="./src.lite-yt-embed.js" defer></script>
It starts with a link which is a nice fallback if the video fails to load for whatever reason. When the script runs, the HTML is augmented to include the video <iframe>
.
Chapter 7: Web Components Frameworks Tour
Lit
Lit extends the base class and then extends what that class provides, but you’re still working directly on top of web components. There are syntax shortcuts for common patterns and a more structured approach.
The package includes all this in about 5-7KB:
- Fast templating
- Reactive properties
- Reactive update lifecycle
- Scoped styles
<simple-greeting name="Geoff"></simple-greeting>
<script>
import html, css, LitElement from 'lit';
export class SimpleGreeting extends LitElement
state styles = css`p color: blue `;
static properties =
name: type = String,
;
constructor()
super();
this.name = 'Somebody';
render()
return html`<p>Hello, $this.name!</p>`;
customElements.define('simple-greeting', SimpleGreeting);
</script>
Pros | Cons |
---|---|
Ecosystem | No official SSR story (but that is changing) |
Community | |
Familiar ergonomics | |
Lightweight | |
Industry-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.
<!-- starting element / index.html -->
<my-element></my-element>
<!-- ../components/my-element.webc -->
<p>This is inside the element</p>
<style>
/* etc. */
</style>
<script>
// etc.
</script>
Pros | Cons |
---|---|
Community | Geared toward SSG |
SSG progressive enhancement | Still in early stages |
Single file component syntax | |
Zach 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.
Pros | Cons |
---|---|
Ergonomics | Still in early stages |
Progressive enhancement | |
Single file component syntax | |
Full-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
<sp-button variant="accent" href="components/button">
Use Spectrum Web Component buttons
</sp-button>
- 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
<fast-checkbox>Checkbox</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
<sl-button>Click Me</sl-button>
- 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.
<section>
<h1 id="name">name</h1>
Email: <a id="link" href="mailto:email">email</a>
</section>
And the application has produced a template with the following content:
<template>
<section>
<h1 id="name"></h1>
Email: <a id="link" href=""></a>
</section>
</template>
Scoped element registries
Using variations of the same web component without name collisions.
Web Components Demystified originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.