Nesting web components in vanilla JavaScript
Tags: javascript, webdev
Published: 2023-12-13 16:30:51

**Written by [Mark Conroy](https://blog.logrocket.com/author/markconroy/)✏️**

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. 

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](https://blog.logrocket.com/web-components-vs-react/); if you want more context for their history, [see this post summarizing their existence](https://blog.logrocket.com/what-happened-to-web-components/). 

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](https://blog.logrocket.com/building-web-components-with-webc-vanilla-javascript/#web-component-specifications), 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](https://developer.mozilla.org/en-US/docs/Web/API/Web_components). 

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:

```html
<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:

```html
<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:

```javascript
// 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:

See this [example on CodePen](https://codepen.io/markconroy/pen/gOqwXYr).

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

```html
<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:

```css
.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:

```html
<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:

```javascript
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:

See this [example on CodePen](https://codepen.io/markconroy/pen/XWONWPQ).

## 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

```html
<layout-container>
  <component-card></component-card>
  <component-card></component-card>
  <component-card></component-card>
  <component-card></component-card>
</layout-container>
```

See this [example on CodePen](https://codepen.io/markconroy/pen/OJdbPZB).

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:

```html
<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>
```

See this [example on CodePen](https://codepen.io/markconroy/pen/dyaOPwJ).

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](http://g-atomic-design-next-js-project/), 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](https://caniuse.com/?search=web%20component) 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!

---

[LogRocket](https://lp.logrocket.com/blg/javascript-signup): Debug JavaScript errors more easily by understanding the context

Debugging code is always a tedious task. But the more you understand your errors, the easier it is to fix them.

[LogRocket](https://lp.logrocket.com/blg/javascript-signup) allows you to understand these errors in new and unique ways. Our frontend monitoring solution tracks user engagement with your JavaScript frontends to give you the ability to see exactly what the user did that led to an error.

[![LogRocket Signup](https://blog.logrocket.com/wp-content/uploads/2020/06/reproduce-javascript-errors.gif)](https://lp.logrocket.com/blg/javascript-signup)

LogRocket records console logs, page load times, stack traces, slow network requests/responses with headers + bodies, browser metadata, and custom logs. Understanding the impact of your JavaScript code will never be easier!

[Try it for free](https://lp.logrocket.com/blg/javascript-signup).