Web Components Make More Sense When They Enhance HTML

A Web Component can add behaviour to HTML that is already useful. The custom element supplies a lifecycle and a boundary for the script; the form, field, or disclosure underneath keeps its native job.

For a content site, I prefer that starting point because a failed script leaves fewer broken promises on the page. A newsletter form should still be a form while its JavaScript is on the way.

Chris Ferdinandi describes this approach in Why wrap native elements in a Web Component?. The interesting question is which part of the interaction actually needs enhancement.

Let Web Components enhance a real form

A wrapper can surround complete native markup:

<ajax-form>
  <form action="/subscribe/" method="post">
    <label for="email">Email</label>
    <input id="email" name="email" type="email" required>
    <button type="submit">Subscribe</button>
  </form>
</ajax-form>

The server must implement /subscribe/ for that form to work. The wrapper alone neither supplies an endpoint nor turns submission into an asynchronous request.

Before a custom element is registered, its children are still part of the document. Native form submission and the input’s validation remain available without a wrapper script.

Do not call preventDefault() and leave a comment where submission should happen. That removes the working interaction before its replacement exists. A complete asynchronous version also needs to handle responses and failures, which deserves its own example.

Give a textarea an optional word count

A smaller enhancement makes the boundary easier to see:

<word-count>
  <label for="message">Message</label>
  <textarea id="message" name="message" rows="6"></textarea>
  <output for="message" hidden></output>
</word-count>

<script type="module" src="/js/word-count.js"></script>

Without the script, the textarea remains editable. The empty count is hidden instead of displaying a permanent and misleading “0 words”.

The output element represents the calculated result. Its for attribute identifies the field used in that calculation.

Save the behaviour as /js/word-count.js:

class WordCount extends HTMLElement {
  #ready = false;

  connectedCallback() {
    if (this.#ready) return;

    const input = this.querySelector("textarea");
    const output = this.querySelector("output");
    if (!input || !output) return;

    const update = () => {
      const text = input.value.trim();
      const count = text ? text.split(/\s+/).length : 0;
      output.value = `${count} ${count === 1 ? "word" : "words"}`;
    };

    input.addEventListener("input", update);
    update();
    output.hidden = false;
    this.#ready = true;
  }
}

customElements.define("word-count", WordCount);

The initial update handles text already in the field. Later input events update the same output, and each wrapper queries its own descendants.

This counter treats whitespace-separated groups as words. That is a modest rule for a small writing aid, not a language-aware definition suitable for every script or editorial requirement.

Account for connection and parsing

The custom element lifecycle calls connectedCallback() when an element is connected. Moving an element through removal and reinsertion can trigger setup again.

The private flag prevents duplicate listeners when this wrapper reconnects with the same children. It does not make the component support replacing its textarea dynamically; that would need a separate update policy.

The non-async module runs after the document has been parsed, so the children in this static HTML are available during setup. Module-script timing explains that assumption and its exceptions.

Registering the component before the parser reaches its children requires more care. A connected parent is not a promise that all its future child markup has arrived.

Keep light DOM when it suits the job

The counter does not attach a shadow root. Its label and textarea stay in the page’s DOM, and ordinary page CSS can style them:

word-count {
  display: block;
}

word-count output {
  margin-inline-start: 0.5rem;
}

Shadow DOM is an available encapsulation tool, rather than a requirement for every custom element. Introduce it when isolation solves a problem the component actually has.

Choose a boundary with useful behaviour

A custom element earns its place when it coordinates a recognisable piece of the page. Password visibility, an optional counter, or a complete form enhancement can fit that description; a paragraph with a colour class usually does not.

The password-toggle example takes the same approach with a different failure state: the field remains, while a JavaScript-only button appears after setup.

Review the baseline as deliberately as the enhanced version. For the counter, losing JavaScript should mean losing a convenience, while keeping the message field available to the reader.

Filed under on

← Back to all articles