Web components
Eunomia ships as standard
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
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.
<script type="module" src="./hello-banner.js"></script>
<hello-banner name="Ada"></hello-banner>
No build step, no bundler: a plain <script type="module"> tag and a
plain HTML tag, exactly as written.
import "./hello-banner.js";
function App() {
return <hello-banner name="Ada" />;
}
React 19 renders a custom element's attributes and properties
natively. On React 18 and earlier, pass name as a lowercase
attribute (as above) rather than binding it to a JS property, since
older React always writes JSX props onto unknown tags as DOM
attributes.
"use client";
import "./hello-banner.js";
export function Greeting() {
return <hello-banner name="Ada" />;
}
Import it inside a client component: customElements.define needs a
real window, which doesn't exist while a Server Component renders
on the server.
<script setup>
import "./hello-banner.js";
</script>
<template>
<hello-banner name="Ada"></hello-banner>
</template>
Vue treats any tag containing a hyphen as a custom element automatically, with no extra configuration needed.
import { CUSTOM_ELEMENTS_SCHEMA, Component } from "@angular/core";
import "./hello-banner.js";
@Component({
selector: "app-root",
template: `<hello-banner name="Ada"></hello-banner>`,
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class AppComponent {}
Angular's template compiler rejects unrecognized tags by default,
so CUSTOM_ELEMENTS_SCHEMA tells it to allow any tag containing a
hyphen through unchecked.
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
HTML templates
The <template>
<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
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()replacesobservedAttributes+attributeChangedCallback: declaringlabelas a property automatically reflects thelabelattribute, keeps both in sync, and triggers a re-render when either changes.@state()marks internal reactive state that isn't exposed as an attribute (counthere), and changing it schedules a re-render the same way a property change does.render()+ thehtmltag replace manually rebuildinginnerHTML: 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+ thecsstag replace the inline<style>string, giving the same shadow-scoped result, but with syntax highlighting, and Lit shares oneCSSStyleSheetacross every instance of the component instead of duplicating a<style>tag per instance.
Every Eunomia component is a LitElement subclass exactly like
this. See
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
Get started : installing Eunomia and using its tags in your own frameworkPolyfills : the two platform APIs Eunomia relies on, and when a real polyfill is worth addingButton : a real `LitElement` component's full source