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…
- 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.
<!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>
// 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.
- 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.
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 selectlist.
- 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 its innerHTML 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.
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.
- 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.
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.
- 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 the greeting property and attribute. Try placing breakpoints in theattributeChangedCallback()
.
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);
- 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.
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. To eliminate boilerplate, you can build your own helper library as I teach in my course, or you can use a library like FAST or Lit.
- Leveraging Constructible StyleSheets and Adopted StyleSheets, create a
CSSStyleSheet
instance and callreplaceSync
to set its CSS text. - In your element constructor, push your custom styles into the existing
adoptedStyleSheets
of theshadowRoot
. - Refresh your browser to see a fully styled component.
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));
}
`);
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.shadowRoot.adoptedStyleSheets.push(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. Read on to go a bit deeper and discover some additional learning resources and libraries.
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 the --
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 styles can also leverage special selectors, such as the :host
, 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.
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;
}
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:
Callback | Description |
---|---|
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 current document into a new document via a call to the adoptNode(...) API. |
We've only just scratched the surface of Web Components in this little tutorial. To learn more, please consider enrolling in my Web Component Engineering course.
Join me as I explore UI Engineering through the application of a broad range of modern Web Standards. With Web Components as its anchor, this course covers dozens of topics that professional UI Engineers and Designers can use on a daily basis. From DOM APIs and Web Components to modern CSS, Accessibility, Forms, Design Systems, Applications, Tools and more. Regardless of your level of experience, this course is the Web Platform resource you've been looking for. Group/team rates and PPP discounts available upon request.