Published: January 29, 2024
Nesting web components in vanilla JavaScript
I don’t think web components get the kudos they deserve. But as we start to see a shift towards writing more vanilla CSS and JavaScript, I think they are going to become more popular in the coming years.
Originally posted on the Log Rocket blog.
Sure, you may not need them now because your current JavaScript framework can handle componentizing for you, but imagine if we lived in a world where you didn’t have to install half the internet via Node to get a reusable component!
Benefits of using web components
Web components are fantastic for what we talked about above. They are native components, work across all browsers, and do not rely on any build tools to be generated. If you’re new to web components, I recommend checking out this article on how they compare to React; if you want more context for their history, see this post summarizing their existence.
However, if you are new to web components, you will need to do a little reading up on some web component-specific ideas such as the shadow DOM, custom elements, and HTML templates. Going through these items in detail is outside the scope of this article, but MDN gives web components a great overview.
In this post, we’ll cover how to create a common, reusable card component using nothing but web components and vanilla JavaScript.
Creating a card component
Our card has a few items in it: an image, a title, some descriptive text, and a link. The HTML for it looks something like this:
<article class="card">
<div class="card__image">
<img
src="https://assets.codepen.io/437758/internal/avatars/users/default.png"
alt="Mark Conroy's avatar from CodePen.io" />
</div>
<div class="card__content">
<h2 class="card__title">This is a card component</h2>
<div class="card__text">
<p>We are reading a tutorial about how to create web components. More specifically, how to create nested web components.</p>
</div>
<div class="card__cta">
<a class="card__link" href="https://blog.logrocket.com">Read more</a>
</div>
</div>
</article>
If this was a web component, we’d use a custom HTML element called component-card
and the HTML would look like this:
<component-card
data-title="This is a card component"
data-image="https://assets.codepen.io/437758/internal/avatars/users/default.png"
data-image-alt="Mark Conroy's avatar from CodePen.io"
data-description="We are reading a tutorial about how to create web components. More specifically, how to create nested web components."
data-cta-href="https://blog.logrocket.com/"
data-cta-text="Read more"
>
</component-card>
In this component, we are using data-*
attributes to set the values for each item in the card.
Here’s the code we might use to create this as a web component. Don’t worry, this is the most complex component we’ll look at. The complexity is only because of the number of items in this card — title, image, text, link, etc. We’ll go through it line-by-line afterwards:
// Create a class for the element
class Card extends HTMLElement {
constructor() {
super();
this.setAttribute("role", "article");
this.classList.add("card");
// Title
const title = document.createElement("h2");
title.classList.add("card__title");
title.textContent = this.getAttribute("data-title");
// Description
const description = document.createElement("div");
description.classList.add("card__description");
description.innerHTML = this.getAttribute("data-description");
// Image
let imageWrapper;
if (this.hasAttribute("data-image")) {
const image = document.createElement("img");
image.classList.add("card__image");
image.src = this.getAttribute("data-image");
if (this.hasAttribute("data-image-alt")) {
image.alt = this.getAttribute("data-image-alt");
}
imageWrapper = document.createElement("div");
imageWrapper.classList.add("card__image-wrapper");
imageWrapper.appendChild(image);
this.appendChild(imageWrapper);
}
// CTA
let ctaContainer;
if (this.hasAttribute('data-cta-href')) {
const cta = document.createElement("a");
cta.classList.add("card__link");
cta.href = this.getAttribute("data-cta-href");
cta.textContent = this.getAttribute("data-cta-text");
ctaContainer = document.createElement("div");
ctaContainer.classList.add('card__cta');
ctaContainer.appendChild(cta);
}
const contentContainer = document.createElement("div");
contentContainer.classList.add("card__content");
contentContainer.appendChild(title);
contentContainer.appendChild(description);
if (ctaContainer !== 'undefined') {
contentContainer.appendChild(ctaContainer);
}
// Create some CSS to apply to the shadow dom
const style = document.createElement("style");
style.textContent = `
:host {
display: block;
background: #eaeaea;
border-radius: 10px;
border: 1px solid black;
max-width: 300px;
color: #333;
}
.card__content {
padding: 1rem;
}
.card__title {
color: #333;
margin-top: 0;
line-height: 1.1;
}
img {
width: 100%;
display: block;
border-radius: 10px 10px 0 0;
}
.card__cta {
margin-top: 1.5rem;
}
.card__cta a {
color: #eaeaea;
display: block;
background-color: #333;
padding: 0.25rem 1rem;
border-radius: 5px;
text-align: center;
}
`;
// Attach the created elements to the shadow dom
const shadowRoot = this.attachShadow({ mode: "closed" });
shadowRoot.appendChild(style);
if (imageWrapper !== undefined) {
shadowRoot.appendChild(imageWrapper);
}
shadowRoot.appendChild(contentContainer);
}
}
// Define the new element
customElements.define("component-card", Card);
Fetching title and description data
First up, we create a class called Card
, which is a generic HTML element that has the same properties as a div
. In our example, however, we want our card to have the same properties as an article
element, so we gave it the role of article
and added a class to it for .card
.
Next, we need to get the values for our card title and card description. For the title, we create an h2
element, add a class of .card__title
to it, and set the contents of this h2
to equal the value of the data-title
attribute.
We do basically the same for the card description, except we use .innerHTML
instead of .textContent
in case our card description has some formatting in it, such as bolding (<b>
) or italics (<em>
).
Adding images, margins, and padding
Once we have those items in place, we can look at getting the data for the image. This is a little bit more complex, only because I want to add a div
with a class of .card__image
around the actual img
element. This is handy if you want to add margins or padding to this area.
The first thing we do is create a variable called imageWrapper
. Then, we check whether there is a value for the data-image
attribute, and if there is, we set imageWrapper
to be a div
with a class of .card__image
and create a new img
element. We then get the value for data-image
and use that as the src
attribute on our image tag. Finally, we get the value of data-image-alt
as the alt
text for our image.
When we have all this in place, we append the image we created to the imageWrapper
and then append the imageWrapper
to this
. In this case, this
refers to our web component itself, so it will be the first element rendered in our web component.
Adding links
After that, we need to see if there’s any call to action or link for the card. Similar to what we did with the image, we first create a ctaContainer
variable, then we check if data-cta-href
has a value. If so, we use that value along with the value of the data-cta-text
to construct an a
element. We then append the cta
to the ctaContainer
, but we do not append that to this
.
Creating image and content containers
The reason we didn’t append the ctaContainer
to the web component just yet is that we want to put the title, description, and CTA all into one container div
— then we can append that to the web component. This makes sure things are rendered in the order we want them to be.
To do this, we create a variable called contentContainer
and append our title, description, and CTA to that. We then append this contentContainer
to this
. We now have two things appended to the component: an image container and an content container.
Adding styling
To make sure our card looks pretty, we create a style element, add the CSS that we need into it, and then append that style element to the card. The great thing about this is that any CSS that is in here will only be available to this component and will not leak out and affect other parts of your page.
The last piece in this part of the puzzle is to create a shadow DOM, append the style
element to it, and then append it to the imageWrapper
and contentContainer
. When we have that done, we can define our new component.
Naming our web component
We can call it whatever we want; I’ve gone for component-card
and told it to use the Card
class. All custom HTML properties must have at least two words in them, separated by a hyphen. This is to distinguish them from native HTML properties, which are always just one word.
Now, we can add this component to any page by making sure we have a script element that loads this file,adding a <component-card>
element to the page, and setting data-*
attributes for the values we want to use. Here’s an example of it in action:
Creating a container component
Now that we have our card component, let’s create a container and place multiple cards inside it. This will mean we can later set properties on the container to say how many cards we want in a row, or how much spacing we want between each card in the container.
For our container component, we want to end up with HTML resembling something like this:
<div class="container">
<p>Things in a container</p>
</div>
<div class="container container--large container--brand">
<p>Things in a container</p>
</div>
<div class="container container--small container--accent">
<p>Things in a container</p>
</div>
In the code above, we can see three types of containers: a default container, a “large” variant and a “small” variant.
For the purposes of this tutorial, let say that they should all be centered on the page. The default one should be 60em
wide (960px
), the large one should be 80em
wide (1280px
) and the small one should be 48em
wide (768px
). We can use CSS to do this:
.container {
width: 100%;
max-width: 60em;
margin-inline: auto;
}
.container--large {
max-width: 80em;
}
.container--small {
max-width: 48em;
}
.container--brand {
background-color: pink;
}
.container--accent {
background-color: lightgreen;
}
With a web component, we can set these widths via data-attributes
, providing ourselves values for “small” and “large” and the same for the background colors. We’ll call the component <layout-container>
. Here’s what this might look like when we render it:
<layout-container>
<p>Things in a container</p>
</layout-container>
<layout-container data-width="small" data-bg-color="brand">
<p>Things in a container</p>
</layout-container>
<layout-container data-width="large" data-bg-color="accent">
<p>Things in a container</p>
</layout-container>
<layout-container data-bg-color="accent-contrast">
<p>Things in a container</p>
</layout-container>
To create this as a web component, here’s what we will need:
class containerComponent extends HTMLElement {
constructor() {
super();
// Width
let width;
const width_small = "48em";
const width_medium = "60em";
const width_large = "80em";
// Other properties
let bgColor = "white";
const colorBrand = "pink";
const colorAccent = "lightgreen";
const colorAccentContrast = "darkgreen";
if (this.hasAttribute("data-width")) {
let widthValue = this.getAttribute("data-width");
widthValue === "small" ? width = width_small : "";
widthValue === "medium" ? width = width_medium : "";
widthValue === "large" ? width = width_large : "";
} else {
width = width_medium;
}
if (this.hasAttribute("data-bg-color")) {
let bgColorValue = this.getAttribute("data-bg-color");
bgColorValue === "brand" ? bgColor = colorBrand : "";
bgColorValue === "accent" ? bgColor = colorAccent : "";
bgColorValue === "accent-contrast" ? bgColor = colorAccentContrast : "";
}
const containerTemplate = document.createElement("template");
containerTemplate.innerHTML = `
<slot></slot>
`;
const style = document.createElement("style");
style.textContent = `
:host {
display: block;
width: 100%;
max-width: ${width};
margin-left: auto;
margin-right: auto;
background-color: ${bgColor};
}
`;
const shadowRoot = this.attachShadow({ mode: "closed" });
shadowRoot.appendChild(style);
shadowRoot.appendChild(containerTemplate.content.cloneNode(true));
}
}
customElements.define("layout-container", containerComponent);
In this element, we initially set variables for our widths. I like to have specific definitions here, so we do not have content editors putting random widths into data attributes like 732px
, which may break the rules set in our brand and style guidelines.
I also set a default background color of “white” for the containers. This is just so we can use a second data attribute to set background colors later.
After setting these variables, we then check if the component has data attributes set for data-width
. If so, we use that value to decide how wide the container should be. We can also add more data attributes if we wanted to set the container to be aligned to the left of the screen, or to have small/medium/large amounts of padding, etc., by using the same approach.
Next up, we check if the container has a data-bg-color
set. If so, we use this value to set the background color of the container. Similar to the width variables, we only provide options for defined colors — brand, accent, accent-contrast — to make sure editors cannot choose random colors outside of our brand guidelines.
Once this is done, we create a template
element that we have called containerTemplate
and set the innerHTML
of this template to be an empty <slot></slot>
element.
This is the magic of nested web components. An empty slot
means that anything put between the opening and closing <layout-container>
tags will be rendered as normal. This is where we will place our cards later.
Almost there! We now create a style
component and put the styles that our <layout-container>
needs inside it, using variables for the ${width}
and ${bgColor}
properties and anything else we’d like, such as margins, paddings, etc.
Creating the shadowRoot
Now we can create our shadowRoot
, append the style
to it, and then append the containerTemplate
to it as well, using the cloneNode(true)
method.
Remember, this is a template
element, so it will not be visible on the page unless we clone
it into position. A template.content
method only contains a fragment
, so it will only be able to render once (meaning we can only have one <layout-container>
on the page). By using the clone
method, we can have as many as we want on the page.
Finally, we define
our component by telling it to use the class containerComponent
for its definition:
Nesting our web components
Okay, so now we have a <component-card>
and also a <layout-container>
. Since the <layout-container>
just renders an empty slot, we can put whatever we want in it.
Let’s place some cards into it, and also extend our <layout-container>
to accept data attributes for layout style (so we can choose grid
) and layout spacing (so we can set some gap between items).
First, here’s what happens when we add three cards to our <layout-container>
in its current state. An outline of the code for this is
<layout-container> <component-card></component-card> <component-card></component-card> <component-card></component-card> <component-card></component-card> </layout-container>
As we can see, our cards are vertically stacked, one on top of the other. If we extend our <layout-container>
to allow an attribute for layout style and spacing, we’ll see this taking much better effect.
Updating our outline code, this now looks like this:
<layout-container data-size="small" data-bg-color="brand" data-layout-style="grid" data-spacing="small" data-columns="4"> <component-card></component-card> <component-card></component-card> <component-card></component-card> <component-card></component-card> </layout-container>
Now we have a nice example of a <layout-container>
with four horizontal <component-card>
items inside it, and the container has values for:
- The layout style (in our case,
grid
) - How much spacing we want between items (
small
in this case) - How many items we want per row (
4
in this case)
What else can we do with nested web components?
We can break out parts of our card into their own components, where we’d have a component called <preview-content>
, for example, and use that component for the title, summary, and CTA in the card. We could also use that strategy if we created other preview types, such as a teaser or search result. Our card title could use a <heading-title>
component with attributes for heading level, style, alignment, and so on.
Getting more granular like this (or atomic, in design system terms) would allow us to create a very effective design system without the need for something as fully-fledged as Storybook.
Browser support
This will work in all browsers for the foreseeable future. Currently, caniuse.com reports ≥97 percent browser compatibility, including basically everything except Opera Mini and IE11.
We can also use web components without a build system or any dependencies, which means no massive node_modules
directory or the fun of wrangling your way out of dependency hell.
Web components are available right now via native APIs from our beloved browsers, and are getting more powerful with each new browser iteration.
Conclusion
So, what have we done? Let’s recap:
- We created a single web component — a card, in our case
- Then, we created another web component for a layout container
- We also placed multiple instances of our card component into our container component
- Finally, we added attributes to our container to control the layout of the items inside it
Though our examples were familiar for the tutorial, the possibilities for web components are endless — I’m just waiting for the frontend world to realize this!