Mighty Morphin’ Web Components

Written by Tyler Sticka on

We’ve recently helped design and craft multiple design systems that include a library of web components. Going into these projects, I was excited to apply our team’s experience with Vue and Preact frameworks to a web standard. What I didn’t expect was how custom elements would disrupt the structure of our components, encouraging us to broaden the scope of patterns we once considered foundational.

Traditionally atomic

For almost a decade now, we’ve designed modular, self-contained components in the spirit of Nicole Sullivan’s Object Oriented CSS or Jonathan Snook’s SMACSS, each fitting neatly within Brad Frost’s Atomic Design metaphor: Foundational patterns (“atoms”) that combine into slightly more complicated patterns (“molecules”) that combine into even more complex patterns (“organisms”) and so on.

Let’s consider a pattern that may seem straightforward on its surface: A text input. If we were delivering an HTML/CSS/JS framework, we’d add a class to the native HTML element:

<input class="c4-input" type="text" value="..." />

…which we’d style with some CSS…

.c4-input {
  appearance: none;
  /* etc. */
}

…before distributing alongside documentation of design considerations and usage guidelines. For example:

It’s critically important for accessibility that inputs always have an accompanying label.

And with that, our pattern would be ready for implementation.

As the project went along, more and more patterns would emerge, often with greater complexity…

<div class="c-form-group is-required">
  <label class="c-label c-form-group__label" for="example">
    Label text...
  </label>
  <input
    class="c-input c-form-group__input"
    id="example"
    aria-describedby="example-help"
    type="text"
    value="..." />
  <p class="c-form-group__help-text" id="example-help">
    Instructional text...
  </p>
</div>

…but the “atoms” therein remain distinct and unchanged.

Let’s table-flip HTML

At first, defining a web component feels pretty similar. You’ll still want to write documentation, your CSS may change if you choose to use shadow DOM, and you’ll probably define your markup in a render method if you’re using a helpful framework like Stencil or Lit to streamline development:

// Lit example
render() {
  return html`<input type="text" value=${this.value} />`;
}

But instead of interacting with that markup directly, template designers will use our custom HTML element1 with whatever name and attributes we define:

<c4-input value="..."></c4-input>

Pulling this off for the first time feels incredibly exciting. We made a new HTML element! How cool is that? And look how simple it is!

But that simplicity has a cost: By obscuring the native HTML, we’ve lost some features we usually take for granted.

For example: How do we label this thing?

We might be tempted to try this…

<!-- Does not work -->
<label for="example">Label text...</label>
<c4-input id="example" value="..."></c4-input>

…but that will associate the label with the c4-input element, not the inner text input.

We can’t dynamically move the id attribute from the outer to the inner element via JavaScript without breaking any links to its fragment identifier. And we shouldn’t require wrapping the element in the label, because that will put significant restrictions on the layouts we can achieve semantically.

So we probably need to create a new and separate attribute for specifying the inner input’s id:

<label for="example">Label text</label>
<c4-input input-id="example" value="..."></c4-input>

That’s fine, I guess, but it feels a bit disappointing. Up to this point, our custom element felt simpler to implement than the native equivalent. Now, maintainers will have to remember this slight variation on a common attribute any time they provide a label (which they should always do).

Or maybe we’ve gone about this all wrong?

It’s morphin’ time!

Up till now, we’ve been thinking of our c4-input element and the text input pattern as one and the same. But really, c4-input is a messenger: An HTML-based API that tells us, the component’s creators, what the template designer wants to accomplish.

If the request is straightforward, then the patterns we render can be, too:

<!-- Markup: -->
<c4-input value="..."></c4-input>

<!-- Rendered result: -->
<c4-input ...>
  <input type="text" value="..."/>
</c4-input>

But there’s no reason we can’t output something more complex where needed.

Let’s think back to that troublesome id attribute. Why was it important? Because accessible labels are so important. Well if we were going to invent a new attribute anyway, why not address that issue directly? If we add support for a label attribute, we can take care of the markup and associations ourselves behind the scenes:

<!-- Markup: -->
<c4-input label="Label text"></c4-input>

<!-- Rendered result: -->
<c4-input ...>
  <label for="c4-input-{unique_id}">
    Label text
  </label>
  <input
    type="text"
    id="c4-input-{unique_id}" />
</c4-input>

And why stop there? We could also support instructional text, a password visibility toggle, validation states that avoid common UX issues, text-based affordances, a better numeric input type, polite masking… whatever features our users would benefit from that makes intuitive sense for this custom element to handle.2

If that sounds like a lot, you aren’t wrong. Thinking about web components less as self-contained “atoms” or “molecules” and more as HTML-based APIs for serving those patterns adds a layer of complexity. But in my experience so far, the time spent designing smarter components is quickly made up by faster integration. I’m now convinced that this adaptability of web components3 is just as big of a selling point as their ability to encapsulate HTML, CSS and JavaScript. So far, I’m a fan!


  1. Alternatively, you could extend the built-in element, but this isn’t supported in the frameworks I’ve used, and it may be a bad idea for non-trivial elements
  2. If you have no unique features to add, you may want to consider whether a web component is even necessary. No reason you can’t deliver a library of web components and CSS patterns! 
  3. Although these takeaways apply conceptually to any component-based JavaScript framework, for some reason it feels more real to me for honest-to-goodness in-browser custom elements than it does for proprietary abstractions that eventually boil down to divs and spans. Perhaps that’s my web standards bias showing! 
Tyler Sticka

Tyler Sticka is Cloud Four’s VP of Design, allowing him to think about design systems every day. When he isn’t directing his team, sketching on sticky notes or nitpicking CSS, he enjoys reading comics, making video games and listening to weird music. He tweets as @tylersticka.

Never miss an article!

Get Weekly Digests


Leave a Comment

Please be kind, courteous and constructive. You may use simple HTML or Markdown in your comments. All fields are required.


Let’s discuss your project! Email Us