Supercharging Built-In Elements With Web Components “is” Easier Than You Think

Avatar of John Rhea
John Rhea on

Take your JavaScript to the next level at Frontend Masters.

We’ve already discussed how creating web components is easier than you think, but there’s another aspect of the specification that we haven’t discussed yet and it’s a way to customize (nay, supercharge) a built-in element. It’s similar to creating fully custom or “autonomous” elements — like the <zombie-profile> element from the previous articles—but requires a few differences.

Customized built-in elements use an is attribute to tell the browser that this built-in element is no mild-mannered, glasses-wearing element from Kansas, but is, in fact, the faster than a speeding bullet, ready to save the world, element from planet Web Component. (No offense intended, Kansans. You’re super too.)

Supercharging a mild-mannered element not only gives us the benefits of the element’s formatting, syntax, and built-in features, but we also get an element that search engines and screen readers already know how to interpret. The screen reader has to guess what’s going on in a <my-func> element, but has some idea of what’s happening in a <nav is="my-func"> element. (If you have func, please, for the love of all that is good, don’t put it in an element. Think of the children.)

It’s important to note here that Safari (and a handful of more niche browsers) only support autonomous elements and not these customized built-in elements. We’ll discuss polyfills for that later.

Until we get the hang of this, let’s start by rewriting the <apocalyptic-warning> element we created back in our first article as a customized built-in element. (The code is also available in the CodePen demo.)

The changes are actually fairly simple. Instead of extending the generic HTMLElement, we’ll extend a specific element, in this case the <div> element which has the class HTMLDivElement. We’ll also add a third argument to the customElements.defines function: {extends: 'div'}.

customElements.define(
  "apocalyptic-warning",
  class ApocalypseWarning extends HTMLDivElement {
    constructor() {
      super();
      let warning = document.getElementById("warningtemplate");
      let mywarning = warning.content;

      const shadowRoot = this.attachShadow({ mode: "open" }).appendChild(
        mywarning.cloneNode(true)
      );
    }
  },
  { extends: "div" }
);

Lastly, we’ll update our HTML from <apocalyptic-warning> tags to <div> tags that include an is attribute set to “apocalyptic-warning” like this:

<div is="apocalyptic-warning">
  <span slot="whats-coming">Undead</span>
</div>

Reminder: If you’re looking at the below in Safari, you won’t see any beautiful web component goodness *shakes fist at Safari*

Only certain elements can have a shadow root attached to them. Some of this is because attaching a shadow root to, say, an <a> element or <form> element could have security implications. The list of available elements is mostly layout elements, such as <article>, <section>, <aside>, <main>, <header>, <div>, <nav>, and <footer>, plus text-related elements like <p>, <span>, <blockquote>, and <h1><h6>. Last but not least, we also get the body element and any valid autonomous custom element.

Adding a shadow root is not the only thing we can do to create a web component. At its base, a web component is a way to bake functionality into an element and we don’t need additional markup in the shadows to do that. Let’s create an image with a built-in light box feature to illustrate the point.

We’ll take a normal <img> element and add two attributes: first, the is attribute that signifies this <img> is a customized built-in element; and a data attribute that holds the path to the larger image that we’ll show in the light box. (Since I’m using an SVG, I just used the same URL, but you could easily have a smaller raster image embedded in the site and a larger version of it in the light box.)

<img is="light-box" src="https://assets.codepen.io/1804713/ninja2.svg" data-lbsrc="https://assets.codepen.io/1804713/ninja2.svg" alt="Silent but Undeadly Zombie Ninja" />

Since we can’t do a shadow DOM for this <img>, there’s no need for a <template> element, <slot> elements, or any of those other things. We also won’t have any encapsulated styles.

So, let’s skip straight to the JavaScript:

customElements.define(
  "light-box",
  class LightBox extends HTMLImageElement {
    constructor() {
      super();
      // We’re creating a div element to use as the light box. We’ll eventually insert it just before the image in question.
      let lb = document.createElement("div");
      // Since we can’t use a shadow DOM, we can’t encapsulate our styles there. We could add these styles to the main CSS file, but they could bleed out if we do that, so I’m setting all styles for the light box div right here
      lb.style.display = "none";
      lb.style.position = "absolute";
      lb.style.height = "100vh";
      lb.style.width = "100vw";
      lb.style.top = 0;
      lb.style.left = 0;
      lb.style.background =
        "rgba(0,0,0, 0.7) url(" + this.dataset.lbsrc + ") no-repeat center";
      lb.style.backgroundSize = "contain";

      lb.addEventListener("click", function (evt) {
        // We’ll close our light box by clicking on it
        this.style.display = "none";
      });
      this.parentNode.insertBefore(lb, this); // This inserts the light box div right before the image
      this.addEventListener("click", function (evt) {
        // Opens the light box when the image is clicked.
        lb.style.display = "block";
      });
    }
  },
  { extends: "img" }
);

Now that we know how customized built-in elements work, we need to move toward ensuring they’ll work everywhere. Yes, Safari, this stink eye is for you.

WebComponents.org has a generalized polyfill that handles both customized built-in elements and autonomous elements, but because it can handle so much, it may be a lot more than you need, particularly if all you’re looking to do is support customized built-in elements in Safari.

Since Safari supports autonomous custom elements, we can swap out the <img> with an autonomous custom element such as <lightbox-polyfill>. “This will be like two lines of code!” the author naively said to himself. Thirty-seven hours of staring at a code editor, two mental breakdowns, and a serious reevaluation of his career path later, he realized that he’d need to start typing if he wanted to write those two lines of code. It also ended up being more like sixty lines of code (but you’re probably good enough to do it in like ten lines).

The original code for the light box can mostly stand as-is (although we’ll add a new autonomous custom element shortly), but it needs a few small adjustments. Outside the definition of the custom element, we need to set a Boolean.

let customBuiltInElementsSupported = false;

Then within the LightBox constructor, we set the Boolean to true. If customized built-in elements aren’t supported, the constructor won’t run and the Boolean won’t be set to true; thus we have a direct test for whether customized built-in elements are supported.

Before we use that test to replace our customized built-in element, we need to create an autonomous custom element to be used as a polyfill, namely <lightbox-polyfill>.

customElements.define(
  "lightbox-polyfill", // We extend the general HTMLElement instead of a specific one
  class LightBoxPoly extends HTMLElement { 
    constructor() {
      super();

      // This part is the same as the customized built-in element’s constructor
      let lb = document.createElement("div");
      lb.style.display = "none";
      lb.style.position = "absolute";
      lb.style.height = "100vh";
      lb.style.width = "100vw";
      lb.style.top = 0;
      lb.style.left = 0;
      lb.style.background =
        "rgba(0,0,0, 0.7) url(" + this.dataset.lbsrc + ") no-repeat center";
      lb.style.backgroundSize = "contain";

      // Here’s where things start to diverge. We add a `shadowRoot` to the autonomous custom element because we can’t add child nodes directly to the custom element in the constructor. We could use an HTML template and slots for this, but since we only need two elements, it's easier to just create them in JavaScript.
      const shadowRoot = this.attachShadow({ mode: "open" });

      // We create an image element to display the image on the page
      let lbpimg = document.createElement("img");

      // Grab the `src` and `alt` attributes from the autonomous custom element and set them on the image
      lbpimg.setAttribute("src", this.getAttribute("src"));
      lbpimg.setAttribute("alt", this.getAttribute("alt"));

      // Add the div and the image to the `shadowRoot`
      shadowRoot.appendChild(lb);
      shadowRoot.appendChild(lbpimg);

      // Set the event listeners so that you show the div when the image is clicked, and hide the div when the div is clicked.
      lb.addEventListener("click", function (evt) {
        this.style.display = "none";
      });
      lbpimg.addEventListener("click", function (evt) {
        lb.style.display = "block";
      });
    }
  }
);

Now that we have the autonomous element ready, we need some code to replace the customized <img> element when it’s unsupported in the browser.

if (!customBuiltInElementsSupported) {
  // Select any image with the `is` attribute set to `light-box`
  let lbimgs = document.querySelectorAll('img[is="light-box"]');
  for (let i = 0; i < lbimgs.length; i++) { // Go through all light-box images
    let replacement = document.createElement("lightbox-polyfill"); // Create an autonomous custom element

    // Grab the image and div from the `shadowRoot` of the new lighbox-polyfill element and set the attributes to those originally on the customized image, and set the background on the div.
    replacement.shadowRoot.querySelector("img").setAttribute("src", lbimgs[i].getAttribute("src"));
    replacement.shadowRoot.querySelector("img").setAttribute("alt", lbimgs[i].getAttribute("alt"));
    replacement.shadowRoot.querySelector("div").style.background =
      "rgba(0,0,0, 0.7) url(" + lbimgs[i].dataset.lbsrc + ") no-repeat center";

    // Stick the new lightbox-polyfill element into the DOM just before the image we’re replacing
    lbimgs[i].parentNode.insertBefore(replacement, lbimgs[i]);
    // Remove the customized built-in image
    lbimgs[i].remove();
  }
}

So there you have it! We not only built autonomous custom elements, but customized built-in elements as well — including how to make them work in Safari. And we get all the benefits of structured, semantic HTML elements to boot including giving screen readers and search engines an idea of what these custom elements are.

Go forth and customize yon built-in elements with impunity!