DEV Community

Cover image for Lets Build Web Components! Part 3: Vanilla Components
Benny Powers ๐Ÿ‡ฎ๐Ÿ‡ฑ๐Ÿ‡จ๐Ÿ‡ฆ
Benny Powers ๐Ÿ‡ฎ๐Ÿ‡ฑ๐Ÿ‡จ๐Ÿ‡ฆ

Posted on • Updated on • Originally published at bennypowers.dev

Lets Build Web Components! Part 3: Vanilla Components

Component-based UI is all the rage these days. Did you know that the web has its own native component module that doesn't require the use of any libraries? True story! You can write, publish, and reuse single-file components that will work in any* good browser and in any framework (if that's your bag).

In our last post, we learned about the JavaScript polyfills that let us ship components to browsers which don't support the specs.

Today, we're getting practical ๐Ÿ‘ทโ€โ™‚๏ธ, we'll build a single-file web component without any library or framework code. We're going to write an element which lazy-loads images so that the browser only fetches then when they appear (or are about to appear) on screen. We'll make our element accessible, and leverage web APIs like IntersectionObserver to make it lightweight and performant. We might even add in some extra bells and whistles if we feel like it.

Let's get started! Crack open your editor and create a file called lazy-image.js This file will contain our component.

The Custom Element Class

Just like we saw in our first post on the web components standards, our first step will be to initialize and register a custom element class, and provide it with a basic template. We'll improve on the template later, adding our custom behaviours.

const tagName = 'lazy-image';
const template = document.createElement('template');
template.innerHTML = `<img id="image"/>`;

class LazyImage extends HTMLElement {
  connectedCallback() {
    if (!this.shadowRoot) {
      this.attachShadow({mode: 'open'});
      this.shadowRoot.appendChild(template.content.cloneNode(true));
    }
  }
}

const register = () => customElements.define(tagName, LazyImage);
window.WebComponents ? window.WebComponents.waitFor(register) : register();
Enter fullscreen mode Exit fullscreen mode

Alrighty. If you've been following along with our previous posts, this should all seem familiar, but a little review is in order:

  1. We create a template element and define our element's shadow DOM inside of it.
  2. We define our custom element's behaviour in a class.
  3. Our element's connectedCallback method creates a shadow root and stamps the template into it.

Plop that into your document and giv'er:

<!doctype html>
<html lang="en">
  <head>
    <script src="https://unpkg.com/@webcomponents/webcomponentsjs/webcomponents-loader.js"></script>
    <script type="module" src="./lazy-image.js"></script>
  </head>
  <body>
    <lazy-image></lazy-image>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Exciting, right? Ok, it's a humble beginning but at least it works. If we inspect our element with dev tools, we can see that it contains our shadow DOM, and is associated with our custom element class.

Dev Tools DOM inspector showing our custom element with a 'custom' badge next to it, and the shadow root containing the img element

That little custom badge is Firefox's way of telling us it's a custom element. If you click on the badge, the debugger will pop open on your element's definition. Well done, Firefox Dev Tools team!

In the next section we'll really start cooking.

Lifecycle Callbacks

Custom elements have four special instance methods which will run at different times:

  1. connectedCallback,
  2. attributeChangedCallback,
  3. disconnectedCallback,
  4. adoptedCallback,

All defined as null by default. These, as well as the constructor, are the custom element lifecycle callbacks.

The constructor

The first of them is the constructor. It runs whenever an element is created, before the element is attached to the document.

// CustomElement's constructor runs
const el = document.createElement('custom-element');
Enter fullscreen mode Exit fullscreen mode

A custom element's constructor must not have any parameters, and it must call super() on the first line of its body in order to delegate behaviours to HTMLElement, Node, etc.; and to bind this to the element instance. The constructor shouldn't return any value other than undefined or this;

// Don't do this
class BustedElement extends HTMLElement {
  constructor(bar) {
    this.foo = bar;
    return bar;
  }
}

// Do This
class DecentElement extends HTMLElement {
  constructor() {
    super();
    if (!window.bar) return;
    this.foo = window.bar;
  }
}
Enter fullscreen mode Exit fullscreen mode

You might want to access your element's attributes parentNode, children, etc. in the constructor, but don't give in to temptation: if your element is not connected (i.e. attached) to the DOM tree, it wont have been upgraded yet, meaning it won't yet have any children or attributes. Your code will work in a case where the element is already defined in the document before the element is defined, but will fail in a case where JavaScript creates the element.

It's also fine to attach the shadow root in the constructor and append elements to it. But since the polyfills have to add classes to the light DOM, and the element might not have connected yet, we'll be doing it throughout this tutorial in the connectedCallback,

For these reasons, it's best to limit the constructor's activity to setting up internal state, including default values, and when using the polyfills, to attach the shadow root and call styleElement in connectedCallback. Just make sure to check if the shadowRoot already exists, or an error will throw the next time your element connects (e.g. via document.body.append(myLazyImage)).

// Don't do this
class BustedImage extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({mode: 'open'});
    this.shadowRoot.appendChild(template.content.cloneNode(true));
    this.shadowImage = this.shadowRoot.getElementById('image');
    // OOPS! Light DOM attributes may not yet exist!
    this.shadowImage.src = this.getAttribute('src');
  }
}

// Do This
class LazyImage extends HTMLElement {
  constructor() {
    super();
    // Set default values of properties, as needed.
    this.src = '';
    // In order to work well with the polyfill,
    // We'll set up the DOM later on, when the element connects.
  }
}
Enter fullscreen mode Exit fullscreen mode

The connectedCallback

connectedCallback is fired every time your element connects to the DOM, including the first time it is upgraded. It's an opportune moment to set up shadow children and attributes.

const lazyImage = document.createElement('lazy-image'); // constructor runs
document.appendChild(lazyImage); // connectedCallback runs

const container = document.getElementById('container');
container.appendChild(lazyImage); // connectedCallback runs again
Enter fullscreen mode Exit fullscreen mode
class LazyImage extends HTMLElement {
  constructor() {
    super();
    this.src = '';
    this.alt = '';
  }

  connectedCallback() {
    // Initialize properties that depend on light DOM
    this.src = this.getAttribute('src') || this.src;
    this.alt = this.getAttribute('alt') || this.alt;
    // Check if shadowRoot exists first
    if (!this.shadowRoot) {
      this.attachShadow({mode: 'open'});
      this.shadowRoot.appendChild(template.content.cloneNode(true));
      this.shadowImage = this.shadowRoot.getElementById('image')
    }
    // Set the shadow img attributes.
    this.shadowImage.src = this.src;
    this.shadowImage.alt = this.alt;
  }
}
Enter fullscreen mode Exit fullscreen mode

Well, this is encouraging. We've set up our shadow DOM and effected some basic plumbing that sets our internal img element's src and alt attributes according to the ones found on our element when it was upgraded.

We want our shadowImage's src attribute to be synced with our element's, and we also want those attributes to be synced with the src DOM property. With the help of attributeChangedCallback and some class setters, we'll make it happen.

The attributeChangedCallback

When you change the src attribute of a plain <img/> element, the browser responds by fetching and displaying the new image URL. Similarly, when you use JavaScript to set the src property on that element's DOM object, the new value is reflected in the attribute. We want our element to behave the same way. The HTML specification provides the attributeChangedCallback for these kinds of uses.

Any time your element's attributes change, the callback will run with the attribute name, old value, and new value as arguments. But the browser won't observe just any attributes. You have to specify in advance which attributes you want to react to by defining a list of attribute names in a static property called observedAttributes:

static get observedAttributes() {
  return ['src', 'alt'];
}
Enter fullscreen mode Exit fullscreen mode

With this defined, your element's attributeChangedCallback will run whenever any of the src or alt attributes change. For now we'll just forward values as properties.

attributeChangedCallback(name, oldVal, newVal) {
  this[name] = newVal
}
Enter fullscreen mode Exit fullscreen mode

We also want our element to react to property changes by updating it's shadowImage, and by reflecting the new value to an attribute. We'll use setters for that:

class LazyImage extends HTMLElement {
  /**
   * Guards against loops when reflecting observed attributes.
   * @param  {String} name Attribute name
   * @param  {any} value
   * @protected
   */
  safeSetAttribute(name, value) {
    if (this.getAttribute(name) !== value) this.setAttribute(name, value);
  }

  /**
   * Image URI.
   * @type {String}
   */
  set src(value) {
    this.safeSetAttribute('src', value);
    // Set image src
    if (this.shadowImage) this.shadowImage.src = value;
  }

  get src() {
    return this.getAttribute('src')
  }

  /**
   * Image Alt tag.
   * @type {String}
   */
  set alt(value) {
    this.safeSetAttribute('alt', value);
    // Set image alt
    if (this.shadowImage) this.shadowImage.alt = value;
  }

  get alt() {
    return this.getAttribute('alt')
  }

  static get observedAttributes() {
    return ['src', 'alt'];
  }

  connectedCallback() {
    this.src = this.getAttribute('src');
    this.alt = this.getAttribute('alt');
    if (!this.shadowRoot) {
      this.attachShadow({mode: 'open'});
      this.shadowRoot.appendChild(template.content.cloneNode(true));
      this.shadowImage = this.shadowRoot.getElementById('image');
    }
  }

  attributeChangedCallback(name, oldVal, newVal) {
    this[name] = newVal;
  }
}
Enter fullscreen mode Exit fullscreen mode

Pushing the button updates the src and alt properties and attributes on the custom element as well as it's shadow child.

inspector showing synchronized attributes

Our element now transparently exposes the main functionality of the native <img> element. The next step is to add in our lazy-loading feature. But before we do that let's briefly discuss the last two lifecycle callbacks in the spec.

The disconnectedCallback

Whenever your element needs to do any clean up work before being removed from the DOM, define a disconnectedCallback that handles your clean-up work.

disconnectedCallback() {
  /* do cleanup stuff here */
}
Enter fullscreen mode Exit fullscreen mode

This will be handy for us later on when we create an IntersectionObserver for each instance of our element. For now, we'll leave it as a stub.

The adoptedCallback

Custom elements also have an adoptedCallback which runs whenever you call adoptNode on a custom element that's inside another document or document fragment. In that case, first the element's disconnectedCallback will run when it disconnects from its original document, then the adoptedCallback, and finally the connectedCallback when it connects to your document.

giant ๐Ÿคทโ€โ™‚๏ธ emoji

I think this was mostly intended for the defunct HTML Imports spec. It may well become more relevant if either the HTML Modules proposals are adopted. If you have any ideas for use cases, we'll see you in the comments section.

The Page Lifecycle

Your page lifecycle therefore might look something like this:

  1. Fetch critical resources, including polyfill
  2. Construct DOM
  3. Fetch defered scripts and modules, including lazy-image.js
  4. DOMContentLoaded - document is finished parsing
  5. Polyfills finish setup, WebComponents.waitFor calls its callback
  6. Custom elements are upgraded - each instance of <lazy-image> in the document is upgraded to a custom element. constructor and connectedCallback run.
  7. If JavaScript creates an instance of <lazy-image>, the constructor will run. When the instance is connected to the DOM tree, the connectedCallback will run.
  8. If JavaScript removes an instance of <lazy-image> from the DOM, the disconnectedCallback will run.

Lazy Loading

We'll use the IntersectionObserver API for lazy-loading. When the image intersects with a rectangle slightly larger than the screen, we'll begin loading it, and Hopefully it will be fully loaded by the time the image scrolls into view. connectedCallback is as good a place as any to do that work.

First, let's define a quick predicate at the root of our module's scope:

// isIntersecting :: IntersectionObserverEntry -> Boolean
const isIntersecting = ({isIntersecting}) => isIntersecting
Enter fullscreen mode Exit fullscreen mode

Then we can set up the observer when our element instantiates:

constructor() {
  super();
  // Bind the observerCallback so it can access the element with `this`.
  this.observerCallback = this.observerCallback.bind(this);
}

connectedCallback() {
  // initialize pre-upgrade attributes
  this.src = this.getAttribute('src')
  this.alt = this.getAttribute('alt')
  // Set up shadow root.
  if (!this.shadowRoot) {
    this.attachShadow({mode: 'open'});
    this.shadowRoot.appendChild(template.content.cloneNode(true));
    this.shadowImage = this.shadowRoot.getElementById('image');
  }
  // If IntersectionObserver is available, initialize it.
  // otherwise, simply load the image.
  if ('IntersectionObserver' in window) this.initIntersectionObserver()
  else this.intersecting = true
}

/**
 * Sets the `intersecting` property when the element is on screen.
 * @param  {[IntersectionObserverEntry]} entries
 * @protected
 */
observerCallback(entries) {
  // The observer simply sets a property
  if (entries.some(isIntersecting)) this.intersecting = true
}

/**
 * Initializes the IntersectionObserver when the element instantiates.
 * @protected
 */
initIntersectionObserver() {
  if (this.observer) return;
  // Start loading the image 10px before it appears on screen
  const rootMargin = '10px';
  this.observer =
    new IntersectionObserver(this.observerCallback, { rootMargin });
  this.observer.observe(this);
}
Enter fullscreen mode Exit fullscreen mode

When the observer triggers and sets the intersecting property, let's reflect it as an attribute, and start loading the image. Since this observer only needs to fire once, we can disconnect and unload it once it's done.

/**
 * Whether the element is on screen.
 * @type {Boolean}
 */
set intersecting(value) {
  if (value) {
    this.shadowImage.src = this.src;
    this.setAttribute('intersecting', '');
    this.disconnectObserver();
  } else {
    this.removeAttribute('intersecting')
  }
}

get intersecting() {
  return this.hasAttribute('intersecting')
}

/**
 * Disconnects and unloads the IntersectionObserver.
 * @protected
 */
disconnectObserver() {
  this.observer.disconnect();
  this.observer = null;
  delete this.observer;
}
Enter fullscreen mode Exit fullscreen mode

We'll want to unload our observer if the element is removed from the DOM, otherwise we might leak memory. We can use the disconnectedCallback for that.

disconnectedCallback() {
  this.disconnectObserver()
}
Enter fullscreen mode Exit fullscreen mode

Styling Our Component

Now we have enough to lazily load up our image once it appears on screen, but we want our element to also provide a nice UX by, for example, loading a placeholder image inline. To do that, we'll style our component by adding a <style> tag into our element's shadow root.

const tagName = 'lazy-image';
const template = document.createElement('template');
template.innerHTML = `
  <style>
    :host {
      position: relative;
    }

    #image,
    #placeholder ::slotted(*) {
      position: absolute;
      top: 0;
      left: 0;
      transition: opacity 0.3s ease;
    }

    #placeholder ::slotted(*),
    :host([intersecting]) #image {
      opacity: 1;
    }

    #image,
    :host([intersecting]) #placeholder ::slotted(*) {
      opacity: 0;
    }
  </style>

  <div id="placeholder">
    <slot name="placeholder"></slot>
  </div>

  <img id="image"/>
`;

window.ShadyCSS && window.ShadyCSS.prepareTemplate(template, tagName);
Enter fullscreen mode Exit fullscreen mode

:host and <slot>

Ooooh! New goodies! The :host CSS selector refers to the shadow host i.e. the <lazy-image> element itself. This is not just a pseudoelement, but also a function, as we see with :host([intersecting]) which is equivalent to lazy-image[intersecting], if it was selected from outside of the shadow-root.

The <slot> element, and it's related ::slotted() CSS function are parts of the spec that let us pass bits of DOM from the light tree into the shadow tree. You use <slot> inside a shadow tree like we saw just above. Then you pass down content from the light DOM like the shadow tree like so:

<!-- light DOM -->
<svg>
  <defs>
    <g id="placeholder-svg">
      <!-- ... -->
    </g>
  </defs>
</svg>

<lazy-image alt="Picture of a cat" src="https://placekitten.com/400/200">
  <svg slot="placeholder"><use xlink:href="#placeholder-svg"/></svg>
</lazy-image>
Enter fullscreen mode Exit fullscreen mode

Notice here how we kept in mind the limitations of the polyfill and wrapped our <slot> in a <div>, then selected for children of that <div> in our CSS.

<slot> doesn't actually move or append slotted elements, it just displays them as if they were in the shadow root. So styles that apply to slotted content from the outer document will still apply when it is slotted. Your element can add its own styles to slotted content with the help of the ::slotted() CSS function.

::slotted(svg) {
  /* applies to any slotted svg element */
}

::slotted(img) {
  /* applies to any slotted img element */
}
Enter fullscreen mode Exit fullscreen mode

NOTE WELL: ::slotted(*) selects for elements only, not text nodes. It also selects for top-level nodes only, not children:

/* Don't do this */
.wrapper ::slotted(.outer .inner) { /*...*/ }
.wrapper ::slotted(.inner) { /*...*/ }

/* Do this */
.wrapper ::slotted(.outer) { /*...*/ }
Enter fullscreen mode Exit fullscreen mode

That's a browser performance optimization, and it can be annoying to work around in some cases, but with creative DOM work and smart app factoring, it can be dealt with.

Slots can be named or anonymous. Name a slot by giving it a name="slotname" attribute in shadow DOM, and use it by specifying <div slot="slotname"></div> in the light DOM. Named slots are helpful if you want to provide multiple specific customizable features. In our case we're using a named <slot name="placeholder"></slot> for explicitness' sake, but we could just as easily have used an anonymous <slot></slot>.

<!-- shadow DOM template -->

<style>
  #title-container ::slotted(*) {
    /* styles for title element */
  }
  #content-container ::slotted(*) {
    /* styles for body content */
  }
</style>
<article>
  <div id="title-container">
    <!-- named slot -->
    <slot name="title"></slot>
  </div>

  <div id="content-container">
    <!-- anonymous slot -->
    <slot></slot>
  </div>
</article>

<!-- light DOM -->
<super-article>
  <h2 slot="title">I'm the article title</h2>
  <p>I'm the article content</p>
  <p>I get slotted into the anonymous slot, too</p>
</super-article>
Enter fullscreen mode Exit fullscreen mode

Now that we've passed our light DOM placeholder into our shadow tree, let's update our class' methods to handle the placeholder:

set intersecting(value) {
  if (value) {
    // Wait to apply the `intersecting` attribute until the image
    // finishes loading, then update the styles for polyfill browsers
    this.shadowImage.onload = this.setIntersecting;
    this.shadowImage.src = this.src;
    this.disconnectObserver();
  } else {
    this.removeAttribute('intersecting');
  }
}

constructor() {
  super();
  this.setIntersecting = this.setIntersecting.bind(this);
}

/**
 * Sets the intersecting attribute and reload styles if the polyfill is at play.
 * @protected
 */
setIntersecting() {
  this.setAttribute('intersecting', '');
  this.updateShadyStyles();
}

connectedCallback() {
  this.updateShadyStyles();
  /* etc. */
}

/**
 * When the polyfill is at play, ensure that styles are updated.
 * @protected
 */
updateShadyStyles() {
  window.ShadyCSS && window.ShadyCSS.styleElement(this);
}
Enter fullscreen mode Exit fullscreen mode

๐Ÿ˜Ž Nice! Our autonomous, reusable, single-file custom element loads an image when on screen then fades to it from a slotted placeholder.

By the way, this is a great opportunity to see how the polyfills work up close. If you load this page on a supporting browser, you'll see a style tag in the element's shadow tree, but if you load it on a polyfilled browser like Edge or Firefox 62, you won't see any styles, because the ShadyCSS polyfill lifts shadow styles up to the document's head.

Polyfilled Native
the shady tree on a polyfilled browser, containing no style element and generated classes for shadow content the shadow tree on a supporting browser, containing a style tag and no generated class names

CSS Custom Properties

Shadow DOM keeps our styles isolated from the rest of the document, but that means it's harder for our users to customize our component. Lucky for us, CSS Custom Properties pierce the shadow boundary, so we can use them to expose customizable styles on our elements.

We'll do that simply by defining our styles with custom properties. The syntax of Custom Properties lets use declare variables while assigning default values:

.selector {
  rule: var(--custom-property-name, default);
}
Enter fullscreen mode Exit fullscreen mode

So we can style our element with sensible defaults while still affording the user some flexibility:

#image,
#placeholder ::slotted(*) {
  position: absolute;
  top: 0;
  left: 0;
  transition:
    opacity
    var(--lazy-image-fade-duration, 0.3s)
    var(--lazy-image-fade-easing, ease);
  object-fit: var(--lazy-image-fit, contain);
  width: var(--lazy-image-width, 100%);
  height: var(--lazy-image-height, 100%);
}
Enter fullscreen mode Exit fullscreen mode

Then we can tweak those styles either globally or on a specific element by defining those variables in our document styles:

/* applies to the whole document. */
html {
  --lazy-image-width: 400px;
  --lazy-image-height: 200px;
}

/* applies to specific elements */
lazy-image:last-of-type {
  width: 400px;
  height: 200px;
  --lazy-image-width: 100%;
  --lazy-image-height: 100%;
  --lazy-image-fade-duration: 2s;
  --lazy-image-fade-easing: linear;
}
Enter fullscreen mode Exit fullscreen mode

Accessibility

Before we publish our component, let's make sure that it treats all of our users with respect. You wouldn't serve delicious barbecued short ribs (anyone else hungry?) without trimming the excess hanging on bits and gristle. No one wants to chew on that! Let's trim the fat off our component's a11y tree.

Extending Built-In Elements

The custom elements spec provides for customizing built-in elements. For reference, customized built-in elements look like this:

<script>
  customElements.define(
    'lazy-image',
    class LazyImage extends HTMLImageElement {/*...*/},
    { extends: 'img' }
  );
</script>

<img is="lazy-image"/>
Enter fullscreen mode Exit fullscreen mode

This looks awesome and would solve so many accessibility-related problems, but Apple's official position as of this writing is that they won't implement it, so we will be writing autonomous custom elements for the time being.

Accessible Autonomous Elements

Since our component wraps the <img> element, instead of extending it, we should try to make all of our wrapping DOM transparent to screen readers. First we'll update our starting markup so that the placeholder is shown to the a11y tree, but not the image.

<div id="placeholder" aria-hidden="false" role="presentation">
  <slot name="placeholder"></slot>
</div>

<img id="image" aria-hidden="true"/>
Enter fullscreen mode Exit fullscreen mode

Next, we'll set the presentation role so that our element's wrapper is ignored in favour of its contents by screenreaders.

connectedCallback() {
  // Remove the wrapping `<lazy-image>` element from the a11y tree.
  this.setAttribute('role', 'presentation');
  /* etc. */
  this.shadowPlaceholder = this.shadowRoot.getElementById('placeholder');
}
Enter fullscreen mode Exit fullscreen mode

And last, we'll swap the aria-hidden attributes on our shadow image and placeholders once the image loads.

setIntersecting() {
  /* etc. */
  this.shadowImage.setAttribute('aria-hidden', 'false')
  this.shadowPlaceholder.setAttribute('aria-hidden', 'true')
}
Enter fullscreen mode Exit fullscreen mode

Now our a11y tree is nice and tidy, our screen reader users won't be bothered with extraneous DOM.

accessibility tree screenshot showing one button and two graphics

Killer. Here's our complete module:

const isIntersecting = ({isIntersecting}) => isIntersecting;

const tagName = 'lazy-image';
const template = document.createElement('template');
template.innerHTML = `
  <style>
    :host {
      position: relative;
    }

    #image,
    #placeholder ::slotted(*) {
      position: absolute;
      top: 0;
      left: 0;
      transition:
        opacity
        var(--lazy-image-fade-duration, 0.3s)
        var(--lazy-image-fade-easing, ease);
      object-fit: var(--lazy-image-fit, contain);
      width: var(--lazy-image-width, 100%);
      height: var(--lazy-image-height, 100%);
    }

    #placeholder ::slotted(*),
    :host([intersecting]) #image {
      opacity: 1;
    }

    #image,
    :host([intersecting]) #placeholder ::slotted(*) {
      opacity: 0;
    }
  </style>
  <div id="placeholder" aria-hidden="false">
    <slot name="placeholder"></slot>
  </div>
  <img id="image" aria-hidden="true"/>
`;

window.ShadyCSS && window.ShadyCSS.prepareTemplate(template, tagName);

class LazyImage extends HTMLElement {
  /**
   * Guards against loops when reflecting observed attributes.
   * @param  {String} name Attribute name
   * @param  {any} value
   * @protected
   */
  safeSetAttribute(name, value) {
    if (this.getAttribute(name) !== value) this.setAttribute(name, value);   
  }

  static get observedAttributes() {
    return ['src', 'alt'];
  }

  /**
   * Image URI.
   * @type {String}
   */
  set src(value) {
    this.safeSetAttribute('src', value);
    if (this.shadowImage && this.intersecting) this.shadowImage.src = value;
  }

  get src() {
    return this.getAttribute('src');
  }

  /**
   * Image alt-text.
   * @type {String}
   */
  set alt(value) {
    this.safeSetAttribute('alt', value);
    if (this.shadowImage) this.shadowImage.alt = value;
  }

  get alt() {
    return this.getAttribute('alt');
  }

  set intersecting(value) {
    if (value) {
      this.shadowImage.onload = this.setIntersecting;
      this.shadowImage.src = this.src;
      this.disconnectObserver();
    } else {
      this.removeAttribute('intersecting');
    }
  }

  /**
   * Whether the element is on screen.
   * @type {Boolean}
   */
  get intersecting() {
    return this.hasAttribute('intersecting');
  }

  constructor() {
    super();
    this.observerCallback = this.observerCallback.bind(this);
    this.setIntersecting = this.setIntersecting.bind(this);
  }

  connectedCallback() {
    this.setAttribute('role', 'presentation');
    this.updateShadyStyles();
    if (!this.shadowRoot) {
      this.attachShadow({mode: 'open'});
      this.shadowRoot.appendChild(template.content.cloneNode(true));
      this.shadowImage = this.shadowRoot.getElementById('image');
      this.shadowPlaceholder = this.shadowRoot.getElementById('placeholder');
      this.src = this.getAttribute('src');
      this.alt = this.getAttribute('alt');
      this.placeholder = this.getAttribute('placeholder');
    }
    if ('IntersectionObserver' in window) this.initIntersectionObserver();
    else this.intersecting = true;
  }

  attributeChangedCallback(name, oldVal, newVal) {
    this[name] = newVal;
  }

  disconnectedCallback() {
    this.disconnectObserver();
  }

  /**
   * When the polyfill is at play, ensure that styles are updated.
   * @protected
   */
  updateShadyStyles() {
    window.ShadyCSS && window.ShadyCSS.styleElement(this);
  }

  /**
   * Sets the intersecting attribute and reload styles if the polyfill is at play.
   * @protected
   */
  setIntersecting(event) {
    this.shadowImage.removeAttribute('aria-hidden');
    this.shadowPlaceholder.setAttribute('aria-hidden', 'true');
    this.setAttribute('intersecting', '');
    this.updateShadyStyles();
  }

  /**
   * Sets the `intersecting` property when the element is on screen.
   * @param  {[IntersectionObserverEntry]} entries
   * @protected
   */
  observerCallback(entries) {
    if (entries.some(isIntersecting)) this.intersecting = true;
  }

  /**
   * Initializes the IntersectionObserver when the element instantiates.
   * @protected
   */
  initIntersectionObserver() {
    if (this.observer) return;
    // Start loading the image 10px before it appears on screen
    const rootMargin = '10px';
    this.observer = new IntersectionObserver(this.observerCallback, { rootMargin });
    this.observer.observe(this);
  }


  /**
   * Disconnects and unloads the IntersectionObserver.
   * @protected
   */
  disconnectObserver() {
    this.observer.disconnect();
    this.observer = null;
    delete this.observer;
  }
}

const register = () => customElements.define(tagName, LazyImage);
window.WebComponents ? window.WebComponents.waitFor(register) : register();
Enter fullscreen mode Exit fullscreen mode

You can use <lazy-image> in your projects by installing from npm or loading from unpkg.

npm i -S @power-elements/lazy-image
Enter fullscreen mode Exit fullscreen mode
<script type="module" src="https://unpkg.com/@power-elements/lazy-image/lazy-image.js"></script>
Enter fullscreen mode Exit fullscreen mode

Contributions are welcome on GitHub.

Conclusions

We've accomplished our goal of writing a slick, reusable, accessible, dependency-free, single-file, lazy-loading image component. And it's only 1.94kb compressed, 4.50kb total. What have we learned?

Vanilla Components Pros and Cons

Pros Cons
No dependencies needed. Your code is future-proof because it rests on web standards instead of library churn. You'll need to provide your own helpers. Syncing properties with attributes might become cumbersome.
Small loading footprint since no extra roundtrips for library code are necessary 0-dep components don't leverage mixins or helper libraries to reduce filesizes in large projects.
No non-standard APIs to learn, maintain, or adapt to. It's just the web. Low level web primitives can sometimes be cumbersome.
Low-level power gives you control and flexibility. You can factor your components however you want. You have to go out of your way to support polyfill browsers, whereas with the libraries, polyfill limitations and known issues are abstracted away.

There are definitely advantages and disadvantages to rolling your own. It seems that we can roughly settle on this general rule: if you're building a simple, reusable, independent custom element to expose some specific functionality; vanilla is a fine choice; but for larger projects and teams, the benefits of a library (ready-made or bespoke) quickly accrue.

One thing to consider is that some frameworks enforce uniformity. On some teams that's an advantage, however the component model allows break-away teams to work independently on smaller abstractions, while hiding those sorts of implementation details from the larger team. In any large project, these kinds of things will have to be considered when choosing the appropriate level of abstraction to take on for a component or set of components.

In our next few posts, we'll be exploring some libraries, tools, and optimization strategies which can streamline your web-component development process and app performance. And we're starting with the OG web components library: Polymer.

See you then ๐Ÿ•ต๏ธโ€โ™‚๏ธ๐Ÿ•ต๏ธโ€โ™€๏ธ

Would you like a one-on-one mentoring session on any of the topics covered here? Contact me on Codementor

Acknowledgements

Thanks in no particular order to John Teague, Westbrook Johnson, @ruphin, Matt Gawarecki, and Daniel Turner for their suggestions and corrections.

Errata

Check out the next article in the series

Top comments (16)

Collapse
 
jimisdrpc profile image
jimisdrpc

Two questions: 1 - Since you added "unpkg.com/@webcomponents/webcompon..." doesn't it mean you are using polifills and then not really using Vanilla Components? By Vanilla Components I understand you are reling on on native Browser features to create a reuseable web components. 2 - What is the best approach in your opinion to work with Vanilla Components and consume rest service? I guess you would recommend add some third library (eg. React, Angular, Redux ou Lit-html) if I want to consume some NodeJs backend. If so, which one you recommend that I will not face surprises for most general cases.

Collapse
 
bennypowers profile image
Benny Powers ๐Ÿ‡ฎ๐Ÿ‡ฑ๐Ÿ‡จ๐Ÿ‡ฆ • Edited

The webcomponents-loader.js polyfill will only load the code that's needed, so most of your users will not have to load any polyfill code and will just run the native APIs. For users on older browsers, the polyfills will load and simulate the native behaviour so that your site will be accessible to more users. See my post on the polyfills for more information.

To fetch data from a REST API, I recommend the browser-built-in Fetch API

For more complicated apps you can use a state container like redux if you like. Lately I've been moving away from that towards app shell architectures.

If you're using graphql, why not check out lit-apollo

Collapse
 
jimisdrpc profile image
jimisdrpc

Hi Benny. I have been doing several POCs (Prove of Concept) regard Vanilla Web-Components. One scenario is an alife dashboard created as Vanilla Webcomponent wich will connect via SSE to either a NodeJS or SpringFlux service that basically consume a Kafka. So it is WebComponent <- SSE Service <- Kafka topic or Kafka Stream. Now I am trying to unit test my webcomponent and I am failling on it. After read around, I am trying to use WCT but I am woondering if I am using the correct tool. Since I am coding a Vanilla WebComponent and WCT is provided by Polymer I am not sure I am in the right path. How arer you unit testing your vanilla webcomponents? You can find my whole POC in github.com/jimisdrpc/simplest-webc... and its backend in github.com/jimisdrpc/simplest-kafk.... If you want to see the error I am getting with WCT is well described in stackoverflow.com/questions/569084.... PS. it will be my pleasure chat with you via stackoverflow.

Thread Thread
 
bennypowers profile image
Benny Powers ๐Ÿ‡ฎ๐Ÿ‡ฑ๐Ÿ‡จ๐Ÿ‡ฆ

Doesn't get much better that open-wc.org/testing

Thread Thread
 
jimisdrpc profile image
jimisdrpc

Benny, I have been studding open-wc but I am wondering if they are promoving really vanilla webcomponents or they are somehow promoving lit-html which I understand it is kind of new flavour of polymer from Google. See their vanilla example importing lit-html from github.com/open-wc/example-vanilla.... I am not saying is bad or worst, I am just interested to really learn webcomponent deepelly and avoid frameworks at least for now. What is your opinion? Is the code bellow really a vanilla webcomponent? I see extending from HTMLElement (vanilla) but importing lit-html.

import { html, render } from 'lit-html';
export default class ExampleVanilla extends HTMLElement {...

Thread Thread
 
bennypowers profile image
Benny Powers ๐Ÿ‡ฎ๐Ÿ‡ฑ๐Ÿ‡จ๐Ÿ‡ฆ • Edited

Puts on open-wc hat ๐ŸŽฉ

At the moment open-wc certainly recommends lit-element. I don't think that's likely to change in the future, but if course it may. Surely, for now we think it's the best way to go.

However, while we think you'll have the best DX and performance with lit-element, our tools support any web components - after all web components are about interoperability.

Takes off open-wc hat ๐Ÿฆฒ

I totally relate to your concerns about framework lock. Don't worry though: custom elements and the DOM provide strong encapsulation for your components' APIs. At work, were slowly migrating our front end away from angularjs. It's painful mostly because of the lack of DOM support. We're making it work though, with things like ng-custom-element to enable us to pass objects from the angular scope to the DOM.

Inside components we've already migrated to lit-element, however, it's a different story. We are free to (and do) bring in components from other libraries as needed.

An approach I take is to select by id in my shadow CSS rather than tag name, in case we decide to swap out a component's implementation later on.

- my-button {
+ #submit-button {

Is it the best idea to load multiple web component libraries on the front end? Probably not - we don't need the extra bundle bloat, it's nice to align around one interface on a team. We need to move fast though. If the datepicker we need is implemented with polymer instead of lit-element, that's fine and dandy. We'll bring in the polymer version today, and if we find the time later, maybe will implement it ourselves with lit, or swap it for a newer 3rd party version. And WRT bundle size, we could fit 10 lit-elements in the bundle space of one angularjs, so we're already winning.

So I don't think if lit-element as a "framework" per-se. They only non-standards APIs it gives are templating, converting attributes, and observed properties, and all if those are provided as mixins to HTMLElement. If we work the whole thing with some shiny new API like the very-cool hybrids or haunted, it wouldn't be so easy to replace a component's base class (or more broadly, helper library). If we opted for something magical and bespoke like svelte, all the moreso.

Putting it another way, if you're starting from scratch, much of the knowledge you'll gain learning lit-element will apply to other web component libraries and frameworks, where the reverse might not be true.

tl;dr: lit-element stays "closer to the metal" while patching some features missing from the browser and smoothing over some rough APIs that are there. That's the level of abstraction that my team has found most helpful.

Thread Thread
 
jimisdrpc profile image
jimisdrpc

Hello Benny. Have you tried code a WebComponent splitted in two files (javascript and html)? If so, how do you uit test it? Maybe you could try give your opinion how to fix github.com/open-wc/open-wc/issues/730 or stackoverflow.com/questions/575024.... The problem is I didn't find some way to unit test when I have a Vanilla WebComponent separated in two files, html and javascript. In github.com/jimisdrpc/skyscanner-op... you find a webcomponent composed by two files: src/skyscanner-flight-search/skyscanner-flight-search.html and src/skyscanner-flight-search/skyscanner-flight-search.js. If you try my unit test test/skyscanner-flight-search.test.js you will see that window.customElements.whenDefined('skyscanner-flight-search') will never be resolved. Anyl trick how uniit test a Vanilla WebComponent splitted int two files will be appreciatted.

Thread Thread
 
bennypowers profile image
Benny Powers ๐Ÿ‡ฎ๐Ÿ‡ฑ๐Ÿ‡จ๐Ÿ‡ฆ

Hello,

I didn't look too deeply into your code, it looks like you've solved most of the problems already. It did seem like you were having a bit of trouble with one of your unit tests, though.

Try this:

describe('skyscanner flight search', function() {
  it('show div', async function() {
    const el = await fixture(html`<skyscanner-flight-search></skyscanner-flight-search>`);
    console.debug('before promise');
    await window.customElements.whenDefined('skyscanner-flight-search')
    console.debug('after promise');
    expect(el).to.exist;
  });
});
Thread Thread
 
jimisdrpc profile image
jimisdrpc

Thanks for you promptly answer. Well, you removed the most relevant part of my test: el.shadowRoot.querySelector('#firstdiv2');. Basically, I want to check if there is a div with id firstdiv2 and it must fail since the correct id is firstdiv. Your suggestion will pass but, as fafr as I ccan see, you are just checking if the fixture works; you aren't checking anything from the webcomponent html.

Thread Thread
 
jimisdrpc profile image
jimisdrpc

Based on your suggestion, I found a solution:

import { html, fixture, expect } from '@open-wc/testing';

import '../src/skyscanner-flight-search/skyscanner-flight-search.js';

describe('skyscanner flight search', () => {
it('show div', async() => {
const el = await fixture(html
<skyscanner-flight-search></skyscanner-flight-search>
);
await window.customElements.whenDefined('skyscanner-flight-search')
expect(el.shadowRoot.querySelector('#firstdiv')).to.exist;

});

it('show input for session key', async() => {
    const el = await fixture(html `
  <skyscanner-flight-search></skyscanner-flight-search>
`);
    await window.customElements.whenDefined('skyscanner-flight-search')
    expect(el.shadowRoot.querySelector('#inputSessionKey')).to.exist;
});

});

Thread Thread
 
jimisdrpc profile image
jimisdrpc

What is your opinion about how I am testing? IN few words, it is based on Karma + Mocha and depending on fixture approaches.

Thread Thread
 
bennypowers profile image
Benny Powers ๐Ÿ‡ฎ๐Ÿ‡ฑ๐Ÿ‡จ๐Ÿ‡ฆ • Edited

if your goal is to test that the shadow root renders the way you expect, I suggest using the open-wc package semantic-dom-diff, which is built-in to open-wc's testing setup, like so:

import { expect, fixture } from `@open-wc/testing`;

describe('skyscanner flight search', function() {
  it('should render the correct Shadow DOM', async function() {
    const el = await fixture(`<skyscanner-flight-search></skyscanner-flight-search>`);
    await window.customElements.whenDefined('skyscanner-flight-search')
    expect(el).shadowDom.to.equal(`
      <!-- this dom string will be semantically compared to the real dom -->
      <!-- comments will be stripped out -->
      <!-- and you'll get a helpful diff as otuput if you use open-wc's testing setup -->
      <div id="firstDiv"></div>
      <input id="inputSessionKey"/>
    `);
  });
});
Collapse
 
ld75 profile image
ld75

Thanks for the article. Did you manage to test your components with jest? It seems not to support custom-elements. The way I could make it work was to replace jsdom by jest-electron but then console.log doesn't write anything in the test terminal.

Collapse
 
bennypowers profile image
Benny Powers ๐Ÿ‡ฎ๐Ÿ‡ฑ๐Ÿ‡จ๐Ÿ‡ฆ

You're correct. Until jsdom supports more of the DOM API, testing with jest is not the best.

Take a look at open-wc.org/testing

Collapse
 
daviddalbusco profile image
David Dal Busco

I was looking to add a property to my vanilla web components and through your examples noticed the get syntax. Thanks for the tips ๐Ÿ‘.

Collapse
 
bennypowers profile image
Benny Powers ๐Ÿ‡ฎ๐Ÿ‡ฑ๐Ÿ‡จ๐Ÿ‡ฆ

Nice!