Eunomia v1.0.0-beta.2
AllAngularReactNext.jsJavaScriptVue
EunomiaSalmonForestVioletOceanGoldFireCustom…
🇬🇧 English🇫🇷 Français

Web components

Eunomia ships as standard web components, built with Lit. Those are two different things: "web component" is a set of browser-native APIs, "Lit" is a small library that makes writing them more convenient. This page covers both: first the platform features on their own, with no library involved, then Lit specifically.

What is a web component?

A web component isn't a framework, a package, or a syntax you learn. It's a name for three browser APIs used together: Custom Elements, Shadow DOM, and HTML Templates. All three ship natively in every current browser. Nothing to install, nothing to compile.

Custom elements

The Custom Elements API lets you define your own HTML tag, backed by a JavaScript class, and register it with the browser:

class HelloBanner extends HTMLElement {
  connectedCallback() {
    this.textContent = `Hello, ${this.getAttribute("name") || "world"}!`;
  }
}

customElements.define("hello-banner", HelloBanner);
<hello-banner name="Ada"></hello-banner>

The moment <hello-banner> appears anywhere in the page (in markup that was already there, injected later, or rendered by React, Vue, or Angular), the browser instantiates the class and calls its lifecycle methods. There's no runtime to bootstrap and no root to mount into, since the tag itself is the integration point. That's what "framework-agnostic" actually means here: it isn't that the component happens to work everywhere, it's that the browser, not a framework, is what wires it up.

Pick a framework in the toolbar above to see exactly what dropping hello-banner into each one looks like, with nothing beyond the plain customElements.define call from above

<script type="module" src="./hello-banner.js"></script>

<hello-banner name="Ada"></hello-banner>

Loading the script once, anywhere on the page, registers the tag with the browser. From that point on, writing <hello-banner> in any markup, however it got onto the page, is enough.

A custom element class extends HTMLElement and overrides a small set of lifecycle callbacks the browser calls automatically:

Callback Called when
connectedCallback() the element is inserted into the DOM
disconnectedCallback() the element is removed from the DOM
attributeChangedCallback(name, old, new) one of its observedAttributes changes
adoptedCallback() the element is moved into a new document

Shadow DOM

Attaching a shadow root gives an element its own private DOM subtree: its own markup and, critically, its own style scope.

class HelloBanner extends HTMLElement {
  connectedCallback() {
    const shadow = this.attachShadow({ mode: "open" });
    shadow.innerHTML = `
      <style>
        p { color: rebeccapurple; font-weight: 600; }
      </style>
      <p>Hello, ${this.getAttribute("name") || "world"}!</p>
    `;
  }
}

That <style> block only ever affects the <p> inside this shadow root: it can't leak out and clash with the page's own CSS, and the page's CSS can't reach in and break the component either. This is what makes a component library safe to drop into any codebase regardless of its existing CSS setup: no !important wars, no CSS Modules, no naming convention to avoid collisions. The only way outside styles get in is through CSS custom properties the component explicitly reads, an opt-in API surface instead of an accident of the cascade.

HTML templates

The <template> element holds inert markup, parsed by the browser but not rendered or executed until you clone it into the document. It's the platform's answer to "define some HTML once, stamp it out many times":

<template id="row-template">
  <li class="row"><slot></slot></li>
</template>
const template = document.getElementById("row-template");
shadow.appendChild(template.content.cloneNode(true));

Combined with a <slot> inside a shadow root, a custom element can accept light-DOM children from whoever uses it and project them into a specific spot in its internal template, which is how <eun-button>Confirm</eun-button> gets to render "Confirm" inside its internal <button> even though "Confirm" was never part of the component's own markup.

Putting it together, with no library

Here's a complete, dependency-free web component (a counter button) using nothing but the three APIs above:

class CounterButton extends HTMLElement {
  static observedAttributes = ["label"];

  #count = 0;

  connectedCallback() {
    this.attachShadow({ mode: "open" });
    this.#render();
    this.shadowRoot.querySelector("button").addEventListener("click", () => {
      this.#count += 1;
      this.#render();
    });
  }

  attributeChangedCallback() {
    this.#render();
  }

  #render() {
    const label = this.getAttribute("label") || "Clicked";
    this.shadowRoot.innerHTML = `
      <style>
        button {
          font: inherit;
          padding: 8px 14px;
          border-radius: 6px;
          border: 1px solid #ccc;
          cursor: pointer;
        }
      </style>
      <button>${label}: ${this.#count}</button>
    `;
  }
}

customElements.define("counter-button", CounterButton);
<counter-button label="Clicks"></counter-button>

That's it: no build step, no framework runtime, no virtual DOM. Drop that script tag on any page and <counter-button> works, in React, in Vue, in a static HTML file, forever, because it's built on APIs the browser itself maintains rather than a library someone has to keep updating.

Why reach for a library at all

Nothing above is wrong, but at real component-library scale it gets repetitive fast: hand-writing attributeChangedCallback/property sync, manually diffing and re-rendering innerHTML on every state change, wiring up observedAttributes for every prop. None of that is a limitation of web components, since it's just boilerplate the platform doesn't automate for you. That's the gap libraries like Lit fill, without stepping outside the platform: a Lit component is a custom element with a shadow root, and Lit just writes the repetitive parts for you.

Lit Element

Lit is a ~5KB layer over the exact APIs from Part 1, adding reactive properties, an efficient template renderer, and a css tagged template for scoped styles, without hiding the underlying platform. A LitElement is still a custom element you register with customElements.define, and Lit just removes the manual wiring.

The counter button from above, rewritten in Lit:

import { LitElement, html, css } from "lit";
import { property, state } from "lit/decorators.js";

export class CounterButton extends LitElement {
  static styles = css`
    button {
      font: inherit;
      padding: 8px 14px;
      border-radius: 6px;
      border: 1px solid #ccc;
      cursor: pointer;
    }
  `;

  @property() label = "Clicked";

  @state() private count = 0;

  render() {
    return html`
      <button @click=${() => (this.count += 1)}>
        ${this.label}: ${this.count}
      </button>
    `;
  }
}

customElements.define("counter-button", CounterButton);

Compare it to the vanilla version: same tag, same behavior, same shadow DOM underneath, but:

  • @property() replaces observedAttributes + attributeChangedCallback: declaring label as a property automatically reflects the label attribute, keeps both in sync, and triggers a re-render when either changes.
  • @state() marks internal reactive state that isn't exposed as an attribute (count here), and changing it schedules a re-render the same way a property change does.
  • render() + the html tag replace manually rebuilding innerHTML: Lit diffs the template against the previous render and only touches the DOM nodes that actually changed, instead of re-parsing the whole string every time.
  • static styles + the css tag replace the inline <style> string, giving the same shadow-scoped result, but with syntax highlighting, and Lit shares one CSSStyleSheet across every instance of the component instead of duplicating a <style> tag per instance.

Every Eunomia component is a LitElement subclass exactly like this. See Button for the real one this site runs, including its full source.

Browser support

Custom elements, shadow DOM, HTML templates, and CSS custom properties are supported natively in every current browser, with no polyfills needed. Lit compiles down to plain JavaScript that runs on top of those same APIs, so anywhere the platform features work, a Lit-based component works too.

See also