Head Toward the Light DOM

Simon MacDonald’s avatar

by Simon MacDonald
@macdonst@mastodon.online
on

measuring tap Photo by Jayy Torres

Recently, there has been a spate of articles that talk about using the Light DOM in Web Components, including Blinded By the Light DOM by Eric Meyer, Step into the light (DOM) by Adam Stoddard, and Using Web Components on My Icon Galleries Websites by Jim Nielsen. Anyone slightly familiar with Begin and Enhance knows we are extremely pro-Light DOM web components as it sidesteps many of the issues with Shadow DOM based web components, including:

  1. Flash of Unstyled Custom Element (FOUCE)
  2. Requiring developers to use the ElementInternals and Form Associated Custom Elements APIs to have your web components participate in forms.
  3. Issues with style encapsulation as outlined by this great post by Manuel Matuzović.
  4. The shadow DOM introduces problems with accessibility. For more info, read this thoughtful post from Nolan Lawson.

That doesn’t mean you should never use the Shadow DOM. It means you should carefully consider your use case before reaching for the Shadow DOM by default. We are not the only ones who have this opinion.

“Where does the shadow DOM come into all of this? It doesn’t. And that’s okay. I’m not saying it should be avoided completely, but it should be a last resort. See how far you can get with the composability of regular HTML first.”

HTML Web Components by Jeremy Keith

However, we have heard some criticism about Enhance’s approach to expanding custom elements on the server side:

“Enhance elements are not “real” web components.”

“These components are not portable.”

“CodePen, or it didn’t happen!”

Respectfully, we disagree with this limited way of looking at web components, but we have heard your feedback, and we’ve responded in the best way we know how, with code.

@enhance/custom-element

Version 1.2.0 of @enhance/custom-element introduces new client-side functionality that makes your Enhance web components more portable.

  1. Light DOM slotting
  2. Style scoping

Light DOM slotting

One of the main reasons folks reach for the Shadow DOM when building web components is the ability to compose larger web components out of smaller ones by using slots. Until now, it wasn’t possible to use slots with the Light DOM. With the latest release of @enhance/custom-element we’ve enabled client-side slotting without the Shadow DOM.

In the spirit of “show, don’t tell,” let’s look at our version of Eric Meyer’s super-slider component written with @enhance/custom-element.

First, let’s look at the HTML for creating a super-slider:

<super-slider unit="em" target=".preview h1">
    <label slot="label" for="title-size">Title font size</label>
    <input slot="input" id="title-size" type="range" min="0.5" max="4" step="0.1" value="2" />
</super-slider>

Both the label and input elements are designated to be slotted into the appropriate spot in our web component.

Before we get to the JavaScript code listings, let’s call out a few things to look at:

  1. Our SuperSlider class extends the Enhance CustomElement class, which in turn extends HTMLElement like all other web components.
  2. We are using connectedCallback to add interactivity to our component. It’s not shown, but our CustomElement supports all the additional web component lifecycle methods like disconnectedCallback, adoptedCallback, and attributeChangedCallback.
  3. A render method is used to insert the contents of our web component into the Light DOM after our slotting algorithm runs.
import CustomElement from 'https://unpkg.com/@enhance/custom-element@1.2.0/dist/index.js?module=true"'

class SuperSlider extends CustomElement {
  connectedCallback() {
    let targetEl = document.querySelector(this.getAttribute("target"))
    let unit = this.getAttribute("unit")
    let slider = this.querySelector('input[type="range"]');
    let label = this.querySelector('label')
    let readout = this.querySelector('span')
    let resetter = this.querySelector('button')

    slider.addEventListener("input", (e) => {
      targetEl.style.setProperty("font-size", slider.value + unit);
      readout.textContent = slider.value + unit;
    });

    let reset = slider.getAttribute("value");
    resetter.setAttribute("title", reset + unit);
    resetter.addEventListener("click", (e) => {
      slider.value = reset;
      slider.dispatchEvent(
        new MouseEvent("input", { view: window, bubbles: false })
      );
    });
    readout.textContent = slider.value + unit
    if (!label.getAttribute("for") && slider.getAttribute("id")) {
      label.setAttribute("for", slider.getAttribute("id"));
    }
    if (label.getAttribute("for") && !slider.getAttribute("id")) {
      slider.setAttribute("id", label.getAttribute("for"));
    }
    if (!label.getAttribute("for") && !slider.getAttribute("id")) {
      let connector = label.textContent.replace(" ", "_");
      label.setAttribute("for", connector);
      slider.setAttribute("id", connector);
    }
  }

  render({ html, state  }) {
    const { attrs={} } = state
    const { unit='' } = attrs
    return html`
        <style>
          :host {
            display: flex;
            align-items: center;
            margin-block: 1em;
          }
          :host input[type="range"] {
            margin-inline: 0.25em 1px;
          }
          :host .readout {
            width: 3em;
            margin-inline: 0.25em;
            padding-inline: 0.5em;
            border: 1px solid #0003;
            background: #EEE;
            font: 1em monospace;
            text-align: center;
          }
        </style>
        <slot name="label"></slot>
        <span class="readout">${unit}</span>
        <slot name="input"></slot>
        <button title="${unit}"></button>
      `
  }
}

customElements.define('super-slider', SuperSlider)

Here’s a CodePen to show the component in action.

Light DOM slotting for the win, folks! But what about style encapsulation?

Style Scoping

Eagle-eyed readers may have noticed that the render method included a style tag. We perform a style transformation when the render method is run. This style transformation does a few important things:

A. It removes the style tag from the output that is being added to the DOM.

B. It rewrites CSS rules from the style tag to scope the CSS to this component. Rules like:

:host {
    display: flex;
    align-items: center;
    margin-block: 1em;
}

Becomes:

super-slider {
    display: flex;
    align-items: center;
    margin-block: 1em;
}

C. It adopts this style sheet so the rules are applied.

Now, we can have the same style encapsulation but without the Shadow DOM.

@enhance/element

The release of @enhance/element version 1.4.0 uses the new @enhance/custom-element under the hood, so it gives you the same functionality but with less boilerplate code. Check out our version of super-slider using @enhance/element.

Frequently Asked Questions

What about server-side rendering?

Don’t worry, we haven’t abandoned server-side rendering. Components written using the @enhance/custom-element package, are server-side renderable by default. When these components are dropped into your Enhance application, the render method is used to expand your custom element on the server and the working HTML is sent down the wire to the client. This has the advantage of avoiding that flash of unstyled custom element on the client side.

Does this mean the render method is run twice? Once on the server and again on the client?

No, when an Enhance component is server-side rendered it is “enhanced” with an attribute to indicate that the slotting algorithm and style transform have already been run. What does that attribute look like, well I’m glad you asked:

<super-slider enhanced=”✨”></super-slider> \

The client-side code will look for this attribute and only run if your component hasn’t already been “enhanced”.

Doesn’t Declarative Shadow DOM (DSD) solve these same problems?

Yes, and no. We remain cautiously optimistic on DSD. It will help solve the problem of server side rendering web components, as the browser will be able to instantiate the shadow root based on the template provided. However, it still runs into many of the same issues that the non-declarative version of the Shadow DOM has including form controls and accessibility. We are also waiting to see the impact of DSD on performance due to the inability to share templates between components of the same type. Once DSD is supported on all evergreen browsers, we will re-visit our experiments with DSD.

Next Steps

  • Try out the new @enhance/custom-element and @enhance/element releases and share with us some components you’ve written.
  • Follow Axol, the Enhance Mascot on Mastodon…
  • Join the Enhance Discord and share what you’ve built, or ask for help.