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.

Nesting web components in vanilla JavaScript

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.

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 divthen 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:

  1. We created a single web component — a card, in our case
  2. Then, we created another web component for a layout container
  3. We also placed multiple instances of our card component into our container component
  4. 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!

Filed Under:

  1. HTML
  2. CSS
  3. JavaScript
  4. Web Components