Should you use custom elements in 2022?

August 22, 2022 Written by: Sebastien Filion
A rectangular cutout shape with HTML written in the middle, meant to represent a web browser.

It has been a little over four years now that the custom elements specification has been part of all major browsers. Furthermore, most popular rendering frameworks like React and Vue now treat custom elements as first-class citizens. Because of the exceptional scripting cost difference between a custom element and most patching algorithms, I believe it is time to embrace the technology and learn its strengths and weaknesses.

What is a good component?

Some of the praises of custom elements I read around the web include: declarative, composable, reusable, extensible, interoperable and accessible. I agree with that statement! But you know what else is all of these things? HTML and CSS. When I consult with teams and the conversation turns to custom elements, I try to remind them of the power of age-old tools. Take the example of a content card; we see them everywhere. Here's a snippet of code from a popular component library:

html
<x-card>
  <x-img
    alt="A photo of a yellow lemon"
    height="200px"
    src="https://upload.wikimedia.org/wikipedia/commons/8/8f/Citrus_x_limon_-_Köhler–s_Medizinal-Pflanzen-041.jpg"
  />
  <x-card-title>List of my favorite lemons</x-card-title>
  <x-card-subtitle>Number 5 will surprise you!</x-card-subtitle>
  <x-card-actions>
    <x-btn>Read More</x-btn>
  </x-card-actions>
</x-card>

Can you see the number of necessary components used to render this straightforward card? There is a scripting cost to all of these elements. From a library sand-point, I could ship this as a simple CSS rule-set.

html
<section class="card">
  <img
    alt="A photo of a yellow lemon"
    src="https://upload.wikimedia.org/wikipedia/commons/8/8f/Citrus_x_limon_-_Köhler–s_Medizinal-Pflanzen-041.jpg"
  />
  <h2>List of my favorite lemons</h2>
  <h3>Number 5 will surprise you!</h3>
  <div class="card__action">
    <button>Read More</button>
  </div>
</section>
css
.card {
  background-color: hsl(0, 0%, 100%);
  border-radius: 8px;
  box-shadow: 0 3px 1px -2px hsla(0, 0%, 0%, 0.3);
  max-width: 350px;
}
.card img {
  object-fit: cover;
  height: 200px;
  width: 100%;
}
.card h2,
.card h3 {
  margin: 0;
}
.card h2 {
  font-size: 1em;
  padding: 1.5em;
}
.card h3 {
  font-size: 0.75em;
  padding-block: 0.75em;
  padding-inline: 1.5em;
}
.card h2 + h3 {
  padding-top: 0;
}
.card__action {
  padding: 1.5em;
}

As a library maker, you can bring this CSS rule set to the next level by using another technology that browser-makers shipped before custom elements: custom properties (CSS variables)!

In the following example, we use three variables to define the height and fit of the image and the overall spacing of the elements within the card.

html
<section class="card" style="--card-image-fit: contain; --card-image-height: 150px">...</section>
css
.card {
  --card-spacing: 1.5rem;
  background-color: hsl(0, 0%, 100%);
  border-radius: 0.5rem;
  box-shadow: 0 3px 1px -2px hsla(0, 0%, 0%, 0.3);
  max-width: 350px;
  overflow: hidden;
}
.card img {
  object-fit: var(--card-image-fit, cover);
  height: var(--card-image-height, 200px);
  width: 100%;
}
.card h2,
.card h3 {
  margin: 0;
}
.card h2 {
  font-size: 1em;
  padding: var(--card-spacing);
}
.card h3 {
  font-size: 0.75em;
  padding-block: calc(var(--card-spacing) / 2);
  padding-inline: var(--card-spacing);
}
.card h2 + h3 {
  padding-top: 0;
}
.card__action {
  padding: var(--card-spacing);
}

I hesitated to add any variable to overwrite the background color, the border radius and the box shadow of the card because these values are all at the top-level of the "component" and therefore can be modified with the same ease.

html
<section class="card" style="border-radius: 0;">...</section>

There are advantages to using custom properties to overwrite top-level properties exists. For example, you want to be able to overwrite a section of a page without having to create more CSS.

html
<div style="--card-border-radius: 0">
  <!-- The border radius of these cards is 0 -->
  <section class="card">...</section>
  <section class="card">...</section>
  <section class="card">...</section>
</div>
<!-- The border radius of this card is still 8px -->
<section class="card">...</section>
css
.card {
  ...
  border-radius: var(--card-border-radius, 8px);
  ...
}

Once you understand these simple rules, you can easily create an alternate. In the following example, I:

  • overwrite the spacing throughout the component by setting the custom property
  • set a new font size which will affect all of the children
  • set the border-radius, just because I can

The new component is definitely...

  1. declarative: it is a card -- a simple class name that describes how it will affect our document.
  2. composable: you can drop it anywhere, it's responsive, and it can receive any element.
  3. reusable: yup. I can use it anywhere I need a card because it's a well-defined CSS class
  4. extensible: yupper. I have already shown how easy it is to extend the class, ie: .big
  5. interoperable: yuppest: The class can be dropped on any website and will act just as expected.
  6. accessible: I mean... yes.

At this point, we have a pretty simple but flexible component. If you are attentive, you probably noticed that I didn't define anything for the font style or the button. It would be best to leave these things to a stylesheet responsible for the theme.

Custom elements and slot

Something that I see people often do is treat everything on their app as a component. If everything is a component, nothing is a component. In my opinion, a page is not a component, generating a list of cards from an array is not a component, and laying out 12 columns isn't a component.

So there are things like a card which is arguably a component but, as I just demonstrated, doesn't need to be a custom element. What needs to be a custom element are things that require encapsulated interactivity. Straightforward examples are an accordion drawer or tabs layout.

In the following code snippet, I added a new element to the card action; a new custom element named x-accordion. This component needs two elements: a trigger and a panel.

The renderer will slot these two elements into the shadow root of the new custom element. But as "slotted elements," they remain children of the original document. I will get back to the subject later.

html
<section class="card">
  <img
    alt="A photo of a yellow lemon"
    src="https://upload.wikimedia.org/wikipedia/commons/8/8f/Citrus_x_limon_-_Köhler–s_Medizinal-Pflanzen-041.jpg"
  />
  <h2>List of my favorite lemons</h2>
  <h3>Number 5 will surprise you!</h3>
  <div class="card__action">
    <x-accordion>
      <button slot="trigger">Read More</button>
      <div>
        <ol>
          <li>Avalon Lemons</li>
          <li>Interdonato Lemons</li>
          <li>Eureka Lemons</li>
          <li>Lisbon Lemons</li>
          <li>Buddha’s Hand Lemons</li>
        </ol>
      </div>
    </x-accordion>
  </div>
</section>

If I were to leave things here, we'd have something still usable because the renderer would display the button and the text to the user. I believe this is important for accessibility and in case the user turned off JavaScript.

I will first create a simple custom element and add two slots under its shadow root. The first slot will be for the trigger, the button that opens the accordion and the second will be for every other child of the accordion. The renderer will hide the default slot at the beginning -- It's worth mentioning that the hidden elements are no longer accessible to screen readers. Then we can bind an event on a click of the trigger to toggle in and out the panel.

js
class XAccordion extends HTMLElement {
  connectedCallback() {
    this.attachShadow({ mode: 'open' })
    const t = document.createElement('slot')
    const p = document.createElement('slot')
    t.setAttribute('name', 'trigger')

    p.style.display = 'none'

    this.shadowRoot.append(t, p)

    // The trigger is still a children of the original
    // document event if it appears to be moved within
    // the shadow root
    const b = this.querySelector('[slot="trigger"]')

    if (b)
      b.addEventListener('click', () => {
        if (p.style.display === 'none') {
          p.style.display = 'block'
        } else {
          p.style.display = 'none'
        }
      })
  }
}

window.customElements.define('x-accordion', XAccordion)

We could choose to add descriptive attributes and emit events on the targeted elements to enable more customizations.

At any rate, I believe that this is an example of a good component. It is:

  1. declarative: The component only requires a clear trigger; the script manages everything under the hood.
  2. composable: you can use it within or with any other component.
  3. reusable: you can use it anywhere you need to toggle something into view.
  4. extensible: I could do some more work to make it more extensible, but with the trigger event alone, we can react to the internal state.
  5. interoperable: works on most modern browsers, and for the cases where it doesn't, the user can still read the information.
  6. accessible: I could make more efforts on this subject, but this article isn't about accessibility.
js
...
  if (b.hasAttribute('data-active')) {
    b.removeAttribute('data-active');
    b.dispatchEvent(new CustomEvent('tigger'));
    p.style.display = 'none';
  } else {
    b.setAttribute('data-active', '');
    p.style.display = 'block';
  }
...

Using functions to generate content

Up to this point, I have described what I believe is a good component. I also alluded to what I think is a poorly designed component. Popular rendering frameworks normalized using "component" for about everything, for example rendering a page or generating lists of content. When thinking about designing a custom element, I advise staying away from that kind of practice. You'll first notice that a custom element with a shadow root will not adopt the stylesheets from the document. In other words, if your custom element has a button within its shadow root -- except for a slotted button -- it will not adopt the same style as other buttons on the page.

To demonstrate this, take the following rules defined in the top-level document or an adopted stylesheet:

css
button {
  background-color: lightgray;
  border: none;
  border-radius: 0.5em;
  outline: none;
  padding-block: 0.5em;
  padding-inline: 0.75em;
}

A custom element like the one I previously presented that adopts the button within a slot will display as expected, but the following example will display a button with the default style of the browser:

html
<x-button>Click me</x-button>
js
class XButton extends HTMLElement {
  connectedCallback() {
    this.attachShadow({ mode: 'open' })

    const b = document.createElement('button')
    // Moves all of the childs to the button
    b.append(...this.childNodes)

    this.shadowRoot.append(b)
  }
}

window.customElements.define('x-button', XButton)

The simplest work around this problem would be to import a button-specific stylesheet within the shadow of the new custom element. Then you'd see consistent styling across the board. Otherwise, I do not suggest encapsulating the styles within a custom element as a solution because that's how you end up with the first example from the popular component library.

TLDR; avoid creating custom elements for complex structures like pages or lists

If you're using a rendering library like React or Vue with a hybrid of your well-designed custom elements and proprietary components, you can discard what I will say next.

It would be best to use a function to generate pages or lists. I can think of one or two ways to solve this problem:

Interacting directly with the DOM

html
<main id="articles"></main>
js
const removeAllChildren = (e) => {
  while (e.firstElementChild) {
    e.removeChild(e.firstElementChild)
  }
}

const renderArticles = (e, articles) => {
  // Always clean up the element so you can reuse the function
  removeAllChildren(e)
  e.append(
    ...articles.map(({ description, title }) => {
      const a = document.createElement('article')
      const t = document.createElement('h2')
      const p = document.createElement('p')
      t.textContent = title
      p.textContent = description
      a.append(t, p)
      return a
    })
  )
}

renderArticles(document.querySelector('#articles'), [
  {
    title: 'List of my favorite lemons',
    description: 'Number 5 will surprise you!',
  },
  {
    title: 'What I think about cantaloupes',
    description: "That's a very silly name, is it even a real thing?",
  },
])

Leveraging content templates

The second approach involves using the content template technology that the browser makers developed to facilitate creating custom elements. Templates can be used for more complex structures and have the advantage that the HTML parser can process the code once and then can only clone the structure around.

html
<template id="article-template">
  <article>
    <!-- The attribute is used to select the element -->
    <h2 data-title></h2>
    <p data-description></p>
  </article>
</template>
<main id="articles"></main>
js
const renderArticles = (e, t, articles) => {
  removeAllChildren(e);
  e.append(...articles.map(({ description, title }) => {
    // `a` is a document fragment
    const a = t.content.cloneNode(true);
    const dt = a.querySelector('[data-title]');
    const dp = a.querySelector('[data-description]');
    dt.textContent = title;
    dp.textContent = description;
    return a;
  }));
}

renderArticles(
  document.querySelector('#articles'),
  document.querySelector('#article-template'),
  [...]
);

Styling custom elements

Aaaand this brings me to the last topic of this article. As I've illustrated already, custom elements can be challenging to style while remaining extensible. I have three bits of advice:

Leave any variant to a slot

So essentially, anything that is user-defined or can be changed should be left inserted as a slotted element. This will avoid having to create too many component-specific styles. Less styles, less problems.

Do not over-style and inherit

When creating a custom element with a shadow root, you might want to give the :host -- the selector that refers to the document fragment representing the shadow root -- a display property, the default is block. If the display property does not affect your element, you can leave it out. You can style the property from "outside". If it's unnecessary, do not style the font family, font size, color or background color etc. You can style all of these values from "outside". When it makes sense, use em to define the size of everything, it will allow the user of your component to scale it consistently by defining the font-size property. Another fun trick on the subject, currentColor is a special value that refers to the current colorwithin the cascade. Using this value means that if a user defines a color on your custom element, you can use currentColor for the border's colour as an example.

Use custom properties as a last resort

Finally, when you've exhausted the power of inheritance, you can define custom properties at the level of the :host to offer a styling-API. Children of the shadow root can use global custom properties, but a custom element should expose its custom properties to be "definable" by a user from an external stylesheet. Otherwise, define defaults.

css
:host {
  /* Declare all custom properties */
  --square-dimension: 100px;
}

.square {
  /* Define a default value for other global properties */
  background-color: var(--primary-color, currentColor);
  height: var(--square-dimension);
  width: var(--square-dimension);
}

Conclusion

In this article, I've tried to explain how to best leverage the various technologies available to us in a modern browser. There are pitfalls and potential misuses nurtured by the last decade of front-end engineering. I believe that the teams who are responsible for designing our browsers were able to learn from the best of the paradigms that the community created over the years. I conclude that we should start leveraging more of the technologies available to us out-of-box and reduce the arsenal of packages we bring onto all of our projects.