Web Components + Design Systems = 🀝

I've been using Lit for a few months now to build a design system with web components and it's πŸ”₯ lit πŸ”₯, as the kids would say. I was admittedly a bit skeptical at first, but I now see the advantages. I'll outline why I think web components make sense for products that are multi-framework. My opinions may change over time. If/When they do, I'll be sure to update this post or follow up in another article.

An image of some components

TL;DR

If you'd rather read my high-level opinions rather than my longer ramblings, here they are:

  • If you're building a design system for multiple frameworks, web components mean you build it once and can use them everywhere
  • Web Components provide encapsulation, which is really important in design systems
    • You have full control over the Component API and styling
    • This helps enforce rigidity versus flexibility in product design systems
  • Web components use the platform and feel like "getting back to the basics"
  • Interacting with web components in each framework differs and could be a bit of a mental model shift by your fellow developers
    • For example, using JS to attach event listeners, rather than a simple onClick property.

Web Components

An image of multiple components

Have you used an <iframe> before? Things don't leak out from the iframe and you don't get full access into it either. The iframe can dispatch events when certain things happen that consumers can listen for and react to. This is essentially a similar concept to web components. Users render your component on their page, they provide some attributes and add event listeners and that's about it. Other than that, the web component just does its thing.

The web component contains the full UI/UX, without exposing ways to modify anything internal to it. You have access to some knobs to adjust things via attributes, can call methods on the component to invoke some internal action, and get notified via event listeners when things happen, but that's about it. All you know is that you need a button element, so you render a <cool-button>. Maybe this separation of concerns is a good thing? It means you are signing up to use the component as is, and only get scoped access.

Web components are essentially an opaque box. You put some things in and get some things out. You don't get to look into the box! Contrast that to typical components, where you can adjust things like styling and modify the inner workings of a component if you really try. The encapsulation of web components offers a lot of advantages, in my opinion, which I'll share in a moment.

Multiple Frameworks

An image of a woman contemplating between multiple options.

Does your organization build UIs using multiple frameworks? It's a pain to share components between them! From my experience, you normally end up building the same thing in each respective framework. Double the work, but not double the fun. Maybe you end up trying to share CSS, since CSS is "easy" to share, but the problem remains: you're building the same thing for two or more distinct frameworks and writing JavaScript in different ways.

Instead, why not build the components once? That's what web components allow you to do. Web components work on all frameworks and are "framework agnostic". Say you have one app in Ember and another in React - they can both use the same exact web component. Take a <cool-button> component for example. The way you interact with this component may be different depending on the framework, but the markup to render the component is identical.

// Here's an example of a very simple and not
// fully-featured button component written in Lit.
import { html, css, LitElement } from 'lit';
import { customElement, property } from 'lit/decorators.js';

@customElement('cool-button')
export class CoolButton extends LitElement {
  static styles = css`
    button {
      background-color: #4095bf;
      border-radius: 0.375rem;
      color: #ffffff;
      border: none;
      padding-top: 0.5rem;
      padding-bottom: 0.5rem;
      padding-left: 0.75rem;
      padding-right: 0.75rem;
    }
  `;

  @property({ type: Boolean, reflect: true )
  disabled = false;

  @property() type: 'button' | 'submit' | 'reset' = 'button';

  render () {
    return html`
      <button ?disabled=${this.disabled} type=${this.type}>
        <slot></slot>
      </button>
    `;
  }
}
// In Ember
import 'some-path-where-cool-button-lives';
import Component from '@glimmer/component';
import { on } from '@ember/modifier';
import { action } from '@ember/object';

export default class SomeComponent extends Component {
  @action
  handleClick(event) {
    console.log('cicked')
  }

  <template>
    <cool-button {{on "click" this.handleClick}}>Button</cool-button>
  </template>
}
// In React
import 'some-path-where-cool-button-lives';
import { useEffect, useRef } from 'react';

const SomeComponent = () => {
  const buttonRef = useRef();

  useEffect(() => {
    const handleClick = (event) => {
      console.log('clicked');
    };

    buttonRef.current.addEventListener('click', handleClick);

    return () => {
      buttonRef.current.removeEventListener('click', handleClick);
    };
  }, []);

  return <cool-button ref={buttonRef}>Button</cool-button>;
};

You'll notice that in the React example, you're back to using addEventListener. You may be thinking "well this isn't very React like!" - that's true, but it is your favorite old friend, vanilla JavaScript. If you'd prefer more React-like syntax, Lit offers a React package that wraps your web components and offers a more friendly React-like syntax so you can ditch refs and event listeners and get back to using props.

The big advantage here is that you write a web component once and can use it anywhere. You render it like any other component and wire up attributes and events. Now you don't have to make <cool-button> twice! Nice πŸ‘.

The Platform

An image of web browser tabs

The best part of web components is they're part of "the platformβ„’". Similar to how you can build a web app using only HTML, JavaScript, and CSS, you can create web components using the tooling you already know and love. There are no external dependencies if you're writing custom elements yourself. Using only HTML, JavaScript, and CSS feels like getting back to the basics - and I'm loving it.

If you're building a design system in a repository separate from your main app repository, you will need a way to build your components and distribute them. We've been using esbuild and it seems great for this. Projects like Shoelace do something similar. In an organization, I think it's almost guaranteed you'll be using some existing tooling for things like builds, tests, etc., but the idea that the underlying components are "just HTML, JavaScript, and CSS" is pretty cool.

By being baked into the platform, it means there are a lot of protections around web components. Browsers know how to render and what to do with them - they're treated as first-class citizens. We shouldn't expect huge, breaking changes in the short term - contrast that to when you go to bump versions for your favorite JS dependency. Take a look at SpaceJam's 1996 website, this baby is still alive and kickin'! Web components will carry the same support far into the future due to being backed by browsers via the platform. It's similar to CSS - there will be new features added as time progresses, but what's there today is set in stone and it's unlikely they'll break or stop working as the years pass.

Whoa whoa whoa, Tony, you just mentioned above you're using Lit. Why do you keep harping on the platform when you're obviously using an abstraction on top of web components?

Lit is a light abstraction on top of the native APIs that increases developer experience and make our jobs a bit easier when it comes to writing components. The decorators are quite helpful and it removes a lot of boilerplate you'd write yourself for each component. I'd recommend folks read their docs and see what I'm talking about. Every Lit component is a native web component - under the hood they're using custom elements, so the browser treats them like built-in elements. To me, the benefits of using Lit are worth it.

Encapsulation

An image of a large shipping boat with many containers on top of it.

One of my favorite parts of web components is encapsulation. You get protections automatically when using the Shadow DOM. A big selling point of building design systems is ensuring consistency across your application(s). This can be really difficult when anyone can come in and target your elements to apply any styling they want. This leads to inconsistencies throughout your product and your customers will notice. Not with web components! With web components, there are only three ways to expose styling to consumers:

  1. CSS variables (aka custom properties)
  2. parts
  3. :host (I do not recommend going this route as it's not as clear in my opinion as options 1 and 2 above - I'll elaborate more.)

CSS variables

Using CSS variables for sharing styles across web components is really nice. After all, you probably don't want to repeat the same color across n number of components - it makes sense to leverage a variable instead. They can also be used for customization within web components. CSS variables pierce the Shadow DOM, meaning that a parent rendering a web component can override a style inside of a web component if it is exposed as a CSS variable.

You get to decide what consumers get to style by making it a public API, driven by CSS variables. Take a button for example, you may want to allow the consumer to support different color modes - maybe they should be able to adjust the background-color CSS property. When you define the background-color in your web component, you can expose this via a CSS variable to allow consumers to override the default.

button {
  /* Allows a consumer to adjust this! */
  background-color: var(--button-background-color);
}
:root {
  /* Elsewhere you define the variable, maybe at a global level */
  --button-background-color: #4095bf;
}

.cool-button-override {
  /* ...or maybe at the class-level */
  /* where you'd do something like <cool-button class="cool-button-override"> */
  --button-background-color: #4095bf;
}

If you want to protect a style from being overridden by consumers, keep the style hardcoded/local to the component itself. Maybe you defined the padding of buttons and decided that padding should not be modified at all by consumers. You want button padding to remain 100% consistent and not be adjusted. If that's the case, you can set those values directly in your web component. Since these values are not exposed via a CSS variable, it means consumers have no way of adjusting these values. Nice!

button {
  /* Consumers can't adjust these! */
  padding-top: 0.5rem;
  padding-bottom: 0.5rem;
  padding-left: 0.75rem;
  padding-right: 0.75rem;
}

Part

The second option to allow customization to consumers is via parts in CSS. You expose areas of your UI that consumers can target with CSS, which allows them to customize it to their needs. If a part is not exposed, the consumer does not get access to style that piece of your UI. Say for example you have a "card" component that you want to expose a header, body, and footer to consumers to style as they please. You'd expose a part for each.

<!-- From a `cool-custom-element` web component -->
<div class="card">
  <div class="header" part="header">
    <slot name="header"></slot>
  </div>

  <div class="body" part="body">
    <slot></slot>
  </div>

  <div class="footer" part="footer">
    <slot name="footer"></slot>
  </div>
</div>
cool-custom-element::part(header) {
  background-color: var(--my-custom-header-bg);
}

cool-custom-element::part(body) {
  padding: 4rem;
}

cool-custom-element::part(footer) {
  background-color: var(--my-custom-footer-bg);
}

Parts are a great way to allow customization of certain sections of your UI πŸ‘.

Host

I mentioned above how I don't recommend styling :host in your web components. By targeting :host, it means you're exposing all of the styles under it to consumers. This results in the ability for them to override whatever CSS properties are defined there. This may not be clear to folks new to web components and may not be your intention. I personally feel as though sticking with CSS variables and exposing parts is superior and feels more intentional. I don't target :host when styling my components, but instead use tag selectors or class names. This is a personal preference though! If :host works for you, have at it!

/* ❌ I don't recommend this! */
/*    Consumers can override via */
/*    <cool-button style="background-color: red", which */
/*    may not be your intention */
:host {
  background-color: #4095bf;
}

/* βœ… Instead, keep the background-color as a local CSS property */
/*    that is not able to be overriden */
.button {
  background-color: #4095bf;
}

/* βœ… If you *want* to allow consumers to override background-color, */
/*    target the button tag and use a CSS variable */
button {
  background-color: var(--button-background-color);
}

/* βœ… Or use a class name <button class="button" */
.button {
  background-color: var(--button-background-color);
}

Being able to decide where you draw the line when it comes to exposing style overrides in your components is a huge benefit when building a design system. With web components, you're in full control on what you expose and consumers have no way of working around the lines you draw. This is extremely powerful! Use CSS variables or expose parts - otherwise, everything is private!

Wish List

An image of a girl making some wishes

Like most things in the world, web components aren't perfect. Here are some things I wish web components supported.

Cross Shadow Root ARIA

Essentially allowing for connecting ID references across shadow roots. This is important for accessibility reasons, like associating labels to inputs, tooltips to other elements, etc. - this is huge for accessibility.

This issue is described in the Web Components 2024 Winter Update which is also worth a read. I'll be keeping my eye on this GitHub issue.

Spread

I was very spoiled using React and being able to spread not only properties, but attributes as well on components. This is extremely powerful and is nice for developer experience reasons. It obviously isn't perfect either though, as anything you pass in gets spread onto the underlying element; however, I think the risks are worth it. This makes life so much easier!

const CoolComponent = ({ label, ...rest }) => {
  return <button {...rest}>{label}</button>;
};

const AnotherComponent = () => {
  return <CoolComponent id="some-id" label="Button" />;
};

Being able to spread attributes onto a web component via similar syntax would be really nice. Instead, you have to manually map these things over, so there's a lot of repetition.

const stuff = { id: 'some-id', label: 'Button' };

// What I want!
<cool-button ${...stuff}></cool-button>

// What we have to do today - manually map things over
<cool-button id=${stuff.id} label=${stuff.label}></cool-button>

Are Web Components Right For You?

An image of a man contemplating their choices

It probably depends! I tried to outline a lot of the advantages I've found when using web components, but for me it still boils down to the question of: "are you building for multiple frameworks?". If the answer is "yes", I think it'd make a lot of sense to give web components and something like Lit a go. If the answer is "no", I'd probably just build in whatever framework you're already using. If the time comes when a cool new framework comes along and you need to support both, then maybe re-evaluate. Web components will only get better from here though! It's an exciting time to be a developer and web components seem to be on the rise - take a look at Reddit. They're now using Lit! Happy coding πŸ‘‹.