← Blog
/POST · ALFIE MILLS

Building a framework-free web component library with Lit and Astro

I keep reaching for the same handful of UI primitives (a button, a toggle, a counter) across projects that use wildly different stacks. Some are Nuxt, some are plain HTML, one is an Astro site, one is a Go app serving server-rendered pages. Shipping a React component library to all of those is a non-starter. So I built a small web component library instead: real custom elements that work anywhere a <script> tag does.

The project pairs Lit for the components with Astro for the docs site, and the thing I'm happiest with is that both are driven from a single registry file.

Why web components

The pitch is simple: a custom element is just an element. Once it's defined you use it like <am-toggle> in any HTML, regardless of framework. No adapter, no wrapper, no peer-dependency dance. Lit makes authoring them pleasant without dragging in a heavy runtime, a component is a class with reactive properties and a render() method:

import { LitElement, html, css } from 'lit';

export class Toggle extends LitElement {
  static styles = css`
    button { width: 48px; height: 28px; border-radius: 14px; background: #262626; }
    button[aria-checked="true"] { background: #22c55e; }
    .thumb { transition: transform 0.2s ease; }
    button[aria-checked="true"] .thumb { transform: translateX(20px); }
  `;

  static properties = {
    checked: { type: Boolean, reflect: true },
  };

  private toggle() {
    this.checked = !this.checked;
    this.dispatchEvent(new CustomEvent('change', {
      detail: { checked: this.checked },
      bubbles: true,
      composed: true,
    }));
  }

  render() {
    return html`
      <button role="switch" aria-checked="${this.checked}" @click="${this.toggle}" part="switch">
        <span class="thumb" part="thumb"></span>
      </button>
    `;
  }
}

customElements.define('am-toggle', Toggle);

A couple of details I made a point of getting right: the change event is dispatched with bubbles: true, composed: true so it actually crosses the shadow DOM boundary and reaches listeners outside the component, and I expose part="switch" / part="thumb" so consumers can restyle internals with ::part() without me leaking my whole stylesheet. Everything is prefixed am- to avoid collisions with anyone else's elements.

One registry, two outputs

The piece that ties it together is src/lib/registry.ts. It's the single source of truth, a typed list of components, each describing its name, description, and its attributes (type, options, defaults):

export const PREFIX = 'am';

export const components: Component[] = [
  {
    name: 'button',
    description: 'A button with variants and sizes.',
    attrs: [
      { name: 'variant', type: 'select', options: ['primary', 'secondary', 'ghost'], default: 'primary' },
      { name: 'size', type: 'select', options: ['sm', 'md', 'lg'], default: 'md' },
    ],
    content: 'Click me',
  },
  // toggle, counter, brick-background, template...
];

export const tag = (name: string) => `${PREFIX}-${name}`;
export const src = (name: string) => `/components/${name}.js`;
export const href = (name: string) => `/docs/${name}`;

That registry drives the navigation, the per-component docs pages, and the interactive preview's attribute controls. When I add a component, I add one entry and the docs site grows itself. The docs pages live in Astro's file-based routing, a single src/pages/docs/[component].astro dynamic route renders a page for every entry in the registry, with an attribute panel built from each component's attrs.

Building distributable bundles

Astro builds the docs site, but the components need to ship as standalone JS that anyone can drop into a page. That's a separate esbuild step (scripts/build-components.js) wired into the build:

import * as esbuild from 'esbuild';

await esbuild.build({
  entryPoints: [join(componentsDir, file)],
  outfile: join(outDir, `${name}.js`),
  bundle: true,
  format: 'esm',
  target: 'es2020',
  minify: true,
  external: [], // bundle Lit in too, consumers shouldn't have to install anything
});

It produces three flavours into public/components/:

  • one minified name.js per component, for when you only want one
  • a components.js bundle with everything
  • a non-minified components.dev.js for debugging

The key choice is external: [], Lit gets bundled into each output. That bloats the bytes slightly, but it means a consumer integrates with nothing more than:

<script type="module" src="https://.../components/components.js"></script>
<!-- then just use them -->
<am-toggle></am-toggle>

No npm install, no bundler, no framework. Exactly the property I wanted.

What I'd flag

  • Bundling Lit per-file duplicates it if a page loads several individual component files. For multi-component pages, the combined components.js is the right call; the single-file builds are for the "I just want one toggle" case.
  • Shadow DOM styling is a real boundary. It's a feature (my styles don't leak out, page styles don't leak in), but it means you must think about theming up front. CSS custom properties and ::part() are the escape hatches, and they only work if you expose them deliberately.
  • The template.ts component is intentionally a copy-paste starting point. Lowering the barrier to adding the next component keeps the registry-driven flow honest.

The result is a tiny library I can use literally anywhere, documented by a site that builds itself from a single file. For shared primitives across a mixed-stack collection of projects, web components turned out to be exactly the right level of abstraction.