Hello Web Components
If you’ve heard about Web Components but have never built one yourself, you’ve come to the right place. The time has come! Let’s build a Web Component together!
When I have the opportunity to teach folks about Web Components, I usually like to start with something simple that many of us know from the “real world.” So, let’s build a Web Component “name tag” that looks something like this…
INFO: What is a Web Component? The term “Web Components” is an umbrella term that covers a collection of several W3C standards in HTML and CSS, that together enable the creation and sharing of web platform native, reusable HTML elements (components).
Step Zero
- Start with a basic HTML document that has a JS module script pointing to a (temporarily) empty JavaScript file.
- In the body of the document, add a
name-tag
element with the text “Web Components” as its content. This is the Web Component that we would eventually like to get working. - Start a web server and browse to your HTML file. You should see the text “Web Components” rendered in your browser.
index.html
<!DOCTYPE html>
<html>
<head>
<title>Hello Web Components</title>
</head>
<body>
<name-tag greeting="Hola">Web Components</name-tag>
<script type="module" src="index.js"></script>
</body>
</html>
index.js
// Nothing here yet. We'll add our Web Component code in the next step.
NOTE: If you aren’t sure what to use for a web server, you can use the http-server package for Node.js or something like the Live Server plugin for VS Code. Anything that can serve static content will work though.
Step One
- In your JavaScript file, declare the behavior for your
name-tag
custom element by creating a class namedNameTag
that extends fromHTMLElement
. All Web Components inherit fromHTMLElement
just like the built-in elements, such asdiv
,span
, andinput
. - Register your element with the browser by calling
customElements.define(...)
, providing your desired HTML tag name and theNameTag
class as arguments. - Refresh the browser and you should still see the same text as before. However, if you inspect the
name-tag
element, you will see that it is not only anHTMLElement
but that its constructor is nowNameTag
as well.
index.js
class NameTag extends HTMLElement {
}
customElements.define('name-tag', NameTag);
IMPORTANT: All Web Component tag names must include a hyphen. This functions as a lightweight mechanism for name spacing elements across libraries and also for preventing them from conflicting with any present or future built-in elements, such as the upcoming
selectmenu
.
Step Two
- Add a constructor to your class. After the call to
super()
, callthis.attachShadow(...)
to create a Shadow DOM for your custom element. A Shadow DOM is like having a private HTML document that only your component can render to. Pass the options{ mode: ‘open’ }
so that the shadow root and its internal elements are still accessible externally via JavaScript and via the developer tools. - Once the Shadow DOM is attached, you can access
this.shadowRoot
and set itsinnerHTML
property to the HTML of your choosing. - Refresh the browser and observe that your Shadow DOM
innerHTML
content is now rendering but that the content of the element itself, the text “Web Components”, is no longer being rendered. Where has it gone? - Open the inspector and verify that there’s a
#shadow-root
node that you can inspect to see what you provided asinnerHTML
. Note that your content is still in the DOM as well, even though it isn’t rendering. The reason it isn’t rendering is because the browser does not know how to compose the Light DOM content with the Shadow DOM. We’ll fix that next.
index.js
class NameTag extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = 'Rendering from Shadow DOM';
}
}
customElements.define('name-tag', NameTag);
NOTE: Using
open
mode is the standard practice for Shadow DOM but you can also useclosed
mode. Make sure you have a strong case forclosed
mode before choosing to go that way though.
Step Three
- Revise the HTML that is being placed into the shadow root so that it includes a
<slot>
element. This tells the browser how to compose your Light DOM and Shadow DOM together. The slot provides a location to “project” or render the Light DOM content into the Shadow DOM. The content still exists in the Light DOM, but it is rendered as if it were at the location of the slot. - Refresh your browser and observe that now both your Light and Shadow DOM content are properly composed together.
index.js
class NameTag extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.innerHTML = 'HELLO <slot></slot>';
}
}
customElements.define('name-tag', NameTag);
NOTE: Many don’t find the terms “Light” and “Shadow” DOM particularly intuitive or explanatory. Instead, it may be useful to think of the “Light” DOM as the “Semantic” DOM. The “Shadow” DOM can then be thought of as the “Render” DOM. Shadow DOM is a private document that describes how the element will render itself, without affecting semantics. Most native component models have similar concepts, e.g., Logical vs. Visual trees.
Step Four
- To enable our
greeting
attribute to work, we’ll need to tell the platform that there is agreeting
attribute we want to observe. Create a static getter namedobservedAttributes
that returns an array of attribute names for the platform to observe. The array should contain the single attribute name:['greeting']
. - Next, implement an
attributeChangedCallback()
that the platform can invoke whenever any of its observed attributes change. - Add a property getter/setter to provide property access to the attribute, since most HTML elements have both properties and attributes. This will ensure our custom element feels like anything else in the platform and that it works correctly with popular front-end frameworks that set both attributes and properties. The getter and setter can just delegate to the
getAttribute()
andsetAttribute()
APIs ofHTMLElement
. - Extract a
render
function that takes the component as input and call it from theattributeChangedCallback()
so that it can update its rendering when the element’s state changes. Remove the code that setinnerHTML
in the constructor. This is no longer needed. - We can also introduce a
connectedCallback().
The platform will call this when the element is connected to the document. We’ll use this to ensure that there is a default value forgreeting
if one wasn’t set by the time the element is connected. - Refresh the browser to see that the
greeting
attribute is now taking effect. Experiment by using the debug tools to set thegreeting
property and attribute. Try placing breakpoints in theattributeChangedCallback()
.
index.js
const render = x => `${x.greeting.toUpperCase()} <slot></slot>`;
class NameTag extends HTMLElement {
static get observedAttributes() {
return ['greeting'];
}
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
get greeting() {
return this.getAttribute('greeting');
}
set greeting(value) {
this.setAttribute('greeting', value);
}
connectedCallback() {
if (!this.greeting) {
this.greeting = 'Hello';
}
}
attributeChangedCallback(name, oldValue, newValue) {
this.shadowRoot.innerHTML = render(this);
}
}
customElements.define('name-tag', NameTag);
Step Five
- Let’s improve our
render
function so that it provides a more realistic structure. - Refresh the browser to ensure that the new structure is rendering properly.
index.js (updated render function)
const render = x => `
<div part="header" class="header">
<h3 part="greeting">${x.greeting.toUpperCase()}</h3>
<h4 part="message">my name is</h4>
</div>
<div part="body" class="body">
<slot></slot>
</div>
<div part="footer" class="footer"></div>
`;
NOTE: At this point you may be starting to see the amount of boilerplate involved when creating a Web Component. This is because the Web Component standards provide you with the low-level capabilities to create components, but otherwise have no opinions on how you should implement your component internally. See the bonus section for one way to address this.
Step Six
- Leveraging the new standards of Constructible StyleSheets and Adopted StyleSheets, create a
CSSStyleSheet
instance and callreplaceSync()
to set its CSS text. - In your element constructor, append your custom styles to the existing
adoptedStyleSheets
of theshadowRoot
. - Refresh your browser to see a fully styled component.
index.js (add styles)
const styles = new CSSStyleSheet();
styles.replaceSync(`
:host {
--default-color: red;
--default-radius: 6px;
--default-depth: 5px;
display: inline-block;
contain: content;
color: white;
background: var(--color, var(--default-color));
border-radius: var(--radius, var(--default-radius));
min-width: 325px;
text-align: center;
box-shadow: 0 0 var(--depth, var(--default-depth)) rgba(0,0,0,.5);
}
.header {
margin: 16px 0;
position: relative;
}
h3 {
font-weight: bold;
font-family: sans-serif;
letter-spacing: 4px;
font-size: 32px;
margin: 0;
padding: 0;
}
h4 {
font-family: sans-serif;
font-size: 18px;
margin: 0;
padding: 0;
}
.body {
background: white;
color: black;
padding: 32px 8px;
font-size: 42px;
font-family: cursive;
}
.footer {
height: 16px;
background: var(--color, var(--default-color));
border-radius: 0 0 var(--radius, var(--default-radius)) var(--radius, var(--default-radius));
}
`);
index.js (updated constructor)
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.adoptedStyleSheets = [
...this.shadowRoot.adoptedStyleSheets,
styles
];
}
Congratulations! You’ve created a W3C standard platform Web Component with Vanilla JS! It has an encapsulated Shadow DOM for HTML and CSS rendering, attribute reactivity, and lifecycle integration.
INFO: If you read this far and just want to try out the code without typing everything, you can find it in this GitHub repo.
Read on to go a bit deeper and discover some additional learning resources and libraries.
Going Deeper
CSS Custom Properties (aka CSS Variables)
A common way to enable custom elements to be styled is to base component styles on CSS Custom Properties (aka CSS Variables). Custom Properties are declared with a double dash prefix and referenced with the var(...)
function. When referencing a variable, you can also provide a fallback value, which itself can be another variable. You can see this technique used throughout the CSS above. To play with this, create several <name-tag>
elements on your page and then use the browser’s style inspector to set ——color
, ——depth
, and ——radius
properties on individual elements or on parent elements. Even though Shadow DOM encapsulates styles, CSS Custom Properties “pierce” the Shadow DOM boundary by default. This makes it possible to create a theming system that works across an entire component library or application. And remember, CSS Custom Properties can be used together with CSS calc(...)
for amazing effects.
Shadow DOM CSS Selectors
Shadow DOM styles can also leverage special selectors, such as the :host
selector which targets the element itself. It’s a best practice to set up host styles for the default display
and disabled
states. Check out the contain
CSS property for ways to improve component render performance as well. If you have special styles for elements placed inside the content of your element, you can specify those by using the ::slotted()
selector.
Adopted Style Sheets
Since adoptedStyleSheets
is not yet implemented in all browsers (I’m looking at you Safari!), for any production components you make, you’ll want to feature detect and fallback to style element injection as needed. This is something that many Web Component libraries (e.g., FAST) handle for you automatically.
CSS Parts
You may have noticed that several elements in the above shadow DOM have a part
attribute. This allows a web component developer to declare parts of the component that can be styled externally by consumers of the component. To try it out, create several <name-tag>
elements on your page, each with a different class
. Then create CSS that targets parts based on a class selector and adjusts the greeting styles. Here’s what that might look like:
.large-greeting::part(greeting) {
font-size: 64px;
}
The Web Component Lifecycle
In our NameTag
element, we used the connectedCallback(...)
, which is one of the standard Web Component lifecycle hooks. But it’s not the only one. Here’s a list of available lifecycle callbacks you can use in your components:
constructor()
— Runs when the element is created or upgraded.connectedCallback()
— Runs when the element is inserted into the DOM.disconnectedCallback()
— Runs when the element is removed from the DOM.attributedChangedCallback(attrName, oldValue, newValue)
— Runs any time one of the element’s custom attributes changes.adoptedCallback()
— Runs when the element is moved from its currentdocument
into a newdocument
via a call to theadoptNode(…)
API.
And much more…
We’ve only just scratched the surface of what can be done with Web Components today, not to mention what we’ll be able to do in the future. If you’re interested in exploring the full range of Web Component standards, beyond just the basics described here, check out my article “2023 State of Web Components”.
Bonus: A FAST NameTag
As mentioned earlier, the amount of boilerplate involved when creating a simple Web Component seems a bit much. This is because the Web Component standards provide the low-level capabilities only, with little to no opinions baked in. But what if a light set of optional opinions were introduced? Could that reduce the amount of code and provide other benefits? Many people building Web Component think so, and thus use some sort of small helper library. To conclude this post, I’d like to show you the same component, built with FAST, which streamlines the Web Component creation process and provides additional tools for building more complex solutions. Here’s the NameTag
Web Component implemented with FAST, using TypeScript:
import {
attr,
css,
customElement,
FASTElement,
html
} from "@microsoft/fast-element";
// Create a reactive template based on the element's state.
const template = html<NameTag>`
<div part="header" class="header">
<h3 part="greeting">${x => x.greeting.toUpperCase()}</h3>
<h4 part="message">my name is</h4>
</div>
<div part="body" class="body">
<slot></slot>
</div>
<div part="footer" class="footer"></div>
`;
// Create styles that automatically use adopted style sheets when present.
const styles = css`
:host {
--default-color: red;
--default-radius: 6px;
--default-depth: 5px;
display: inline-block;
contain: content;
color: white;
background: var(--color, var(--default-color));
border-radius: var(--radius, var(--default-radius));
min-width: 325px;
text-align: center;
box-shadow: 0 0 var(--depth, var(--default-depth)) rgba(0,0,0,.5);
}
.header {
margin: 16px 0;
position: relative;
}
h3 {
font-weight: bold;
font-family: sans-serif;
letter-spacing: 4px;
font-size: 32px;
margin: 0;
padding: 0;
}
h4 {
font-family: sans-serif;
font-size: 18px;
margin: 0;
padding: 0;
}
.body {
background: white;
color: black;
padding: 32px 8px;
font-size: 42px;
font-family: cursive;
}
.footer {
height: 16px;
background: var(--color, var(--default-color));
border-radius: 0 0 var(--radius, var(--default-radius)) var(--radius, var(--default-radius));
}
`;
// Define the element with a name, template, and styles.
@customElement({
name: 'name-tag',
template,
styles
})
class NameTag extends FASTElement { // Remove boilerplate w/ base class.
@attr greeting = 'Hello'; // A reactive HTML attribute with a default.
}
IMPORTANT: Before being able to run the above code, you would need to install
@microsoft/fast-element
from NPM and set up TypeScript with the module loader/bundler of your choosing.
Notice how all the boilerplate goes away? Here are a few things that FAST is doing for you:
- Templates — FAST provides a high performance, reactivity-based template engine, including support for advanced MVVM at scale.
- Styles— FAST automatically detects the presence of adopted style sheets and uses them if possible. It also caches and reuses style sheet instances across Web Component instances for improved performance and memory management.
- FASTElement — The base class automatically sets up the Shadow DOM and hooks into the lifecycle to handle rendering with the provided template. The decorator provides a declarative way to connect your template, styles, and class, while registering them with the platform using the provided name.
- Attributes — Instead of having to manually declare a getter/setter, setup the
observedAttributes
array, and handle default values and attribute change callbacks, you simply decorate a field with@attr
andFASTElement
handles that all for you.
This is only a small example of how FAST can help you build modern Web Components. It has much more to offer, especially if you want to build entire design systems, or full applications with routing, SSR, dependency injection, and advanced state management.
Interested in learning more or joining the growing FAST and Web Components community?
If you enjoyed this look into building a simple Web Component, you might want to check out my Web Component Engineering course. I’d also love it if you would subscribe to this blog, subscribe to my YouTube channel, or follow me on twitter. Your support greatly helps me continue writing and bringing this kind of content to the broader community. Thank you!