Elemento

A lightweight, opinionated library for building web components with a functional, React-inspired approach, powered by the excellent lit-html templating library and the tiny, fast Preact Signals Core reactivity system.

Overview

Elemento is a tiny yet powerful library that bridges the gap between modern Web Components and functional programming. It provides a clean, declarative API for creating custom elements that leverage Shadow DOM while maintaining a reactive programming model.

Build-free usage: Elemento ships as modern ESM and works directly in browsers that support ES modules—no bundling or transpilation is required unless your app needs it.

Note on reactivity: Elemento uses Preact Signals Core (@preact/signals-core) for its reactive system. In our experience this has been the most stable and well-supported signals implementation for Elemento.

Core Philosophy

  • Embrace Web Standards: Built on native Custom Elements and Shadow DOM
  • Functional Composition: React-inspired component architecture without React
  • Declarative Rendering: Reactive UI updates based on signal/state changes
  • Type Safety: Full TypeScript support
  • Minimalist Approach: Small API surface with powerful capabilities

Features

  • Reactive Rendering: lit-html templating + reactivity powered by Preact Signals Core
  • Attribute Reactivity: Automatic synchronization between attributes and reactive signals
  • Property Reactivity: Reactive web component properties exposed as signals via getters/setters
  • Encapsulated Styling: First-class support for Shadow DOM and Constructable Stylesheets
  • Lightweight: Tiny footprint with no heavy dependencies
  • Composition-First: Functional component patterns for easy composition

Getting Started

Installation

npm install @solidx/elemento

Basic Usage

A minimal component that renders a greeting and reacts to a name attribute.

import { Elemento, html } from '@solidx/elemento';

// Observe one attribute (as a reactive signal)
const observed = ['name'];

// Functional component: read signal with .value
function Hello({ name }) {
  return html`<p>Hello ${name.value || 'World'}!</p>`;
}

// Register it
customElements.define('hello-name', Elemento(Hello, { observedAttributes: observed }));

Use it in HTML:

<hello-name name="Alice"></hello-name>
<!-- Changing the attribute updates the content reactively -->
<script type="module">
  const el = document.querySelector('hello-name');
  el.setAttribute('name', 'Bob');
</script>

Complex Example

Create your web component with a functional approach:

import buttonCss from './button.css?inline';
import { BoolAttr, Elemento, signal, computed, html, mount, unmount } from '@solidx/elemento';

const buttonStyles = new CSSStyleSheet();
buttonStyles.replaceSync(buttonCss);

// Define the list of reactive custom element attributes
const attributes = [
  'variant',
  'state',
  'loading',
  'shape',
  'inverted',
  'aria-expanded',
  'extended',
  'extended--mobile',
  'extended--tablet',
  'extended--laptop',
] as const;

type Attribute = (typeof attributes)[number];

// Define reactive component properties (settable via JS, exposed as signals)
const properties = ['count'] as const;

type Prop = (typeof properties)[number];

// Single-phase functional component: receives reactive props and the element
const Button = ({ variant, shape, inverted, extended, loading, count, ...rest }: Record<
  Attribute | Prop,
  { value: any }
>, el: HTMLElement) => {
  // Internal state using Elemento's signals (hook-like behavior)
  const internalClicks = signal(0);

  // Derived values
  const classes = computed(() =>
    [
      variant.value || 'btn',
      shape.value || 'default',
      BoolAttr(inverted.value) ? 'inverted' : '',
      BoolAttr(loading.value) ? 'loading' : '',
    ].join(' ')
  );

  const cssVars = computed(() =>
    [
      `--button-extended: ${BoolAttr(extended.value) ? '100%' : ''};`,
      `--button-extended--tablet: ${BoolAttr(rest['extended--tablet'].value) ? '100%' : ''};`,
      `--button-extended--mobile: ${BoolAttr(rest['extended--mobile'].value) ? '100%' : ''};`,
      `--button-extended--laptop: ${BoolAttr(rest['extended--laptop'].value) ? '100%' : ''};`,
    ].join('')
  );

  // Lifecycle: run once after the first render
  mount(() => {
    console.log('Button mounted');
  });

  // Cleanup when the element is disconnected
  unmount(() => {
    console.log('Button unmounted');
  });

  return html`<button
    class="${classes.value}"
    style="${cssVars.value}"
    aria-expanded="${rest['aria-expanded'].value}"
    @click=${() => {
      internalClicks.value++;
    }}
  >
    <slot></slot>
    <span>Prop count: ${count?.value ?? 0}</span>
    <span>Internal clicks: ${internalClicks.value}</span>
  </button>`;
};

// Register the custom element with Elemento
customElements.define(
  'my-button',
  // Signature: Elemento(template, options?)
  Elemento<Attribute, Prop>(Button, {
    observedAttributes: attributes,
    properties,
    cssStylesheets: [buttonStyles],
    formAssociated: true, // opt-in if you need form participation
  })
);

Then use it in your HTML:

<my-button id="btn" variant="primary">Click me</my-button>
<script type="module">
  // Set a reactive property via JavaScript
  const btn = document.getElementById('btn');
  btn.count = 5; // updates instantly because `count` is reactive
</script>

API

Elemento

Creates a Web Component class around your functional component.

Signature:

Elemento(
  template: (props: Record<K | P, Signal<any>>, el: HTMLElement) => Node | HTMLElement,
  options?: ElementoOptions<K, P>
): CustomElementConstructor

The options object matches the ElementoOptions<K, P> type and includes:

  • baseTag: CSS selector for your component’s base element inside the shadow root. When set, el.baseElement returns the first matching element.
  • observedAttributes: attributes mirrored to signals and kept in sync with the DOM
  • properties: JS properties mirrored to signals (no attributes involved)
  • cssStylesheets: constructable stylesheets adopted into the shadow root
  • internals: optional factory or partial internals to patch onto attachInternals() result
  • formAssociated: set to true to opt into form-associated custom elements (exposes ElementInternals via attachInternals)
  • onConnected: optional callback invoked after the element connects

TypeScript tip:

import type { ElementoOptions } from '@solidx/elemento';

const opts: ElementoOptions<'name', never> = {
  observedAttributes: ['name'],
};
customElements.define('hello-name', Elemento(Hello, opts));

Common option combinations:

  • Attributes only: Elemento(Component, { observedAttributes: ['foo', 'bar'] })
  • Properties only: Elemento(Component, { properties: ['value', 'data'] })
  • Styles + attributes: Elemento(Component, { observedAttributes: ['variant'], cssStylesheets: [sheet] })
  • Internals patching: Elemento(Component, { internals: el => ({ role: 'button' }) })
  • Form-associated: Elemento(Component, { formAssociated: true, internals: el => ({ role: 'button' }) })
  • Base element access: Elemento(Component, { baseTag: 'button' }) then inside your component use el.baseElement.

Component function

Your component function is called on every reactive change with a single object containing signals for both attributes and properties, and the custom element instance as second parameter. Read signal values via .value.

You may call Elemento’s hook-like utilities inside the function:

  • signal(initial): create internal state that persists across renders
  • computed(fn): derived read-only signal
  • mount(fn): runs once, right after the component’s first successful render
  • unmount(fn): runs when the custom element disconnects; use for cleanup
  • html: re-export from lit-html for templating
  • signals: signal/computed are powered by Preact Signals Core

Important notes:

  • Call signal/computed at the top level of your component and in the same order across renders (hook-like rule).
  • Boolean attributes are strings at the DOM level; use BoolAttr(value) to coerce presence/absence to boolean.
  • When baseTag is provided, you can access the underlying DOM node via the baseElement getter on the element instance (e.g., to call imperative methods or focus the element).

Spread directive

Elemento provides a spread helper that lets you apply multiple bindings at once inside a template, while automatically unwrapping Elemento signals. It is a thin wrapper around @open-wc/lit-helpersspread.

Usage:

import { Elemento, html, signal, spread } from '@solidx/elemento';

function FancyButton() {
  const id = signal('cta');              // attribute
  const disabled = signal(false);        // boolean attribute (use ? prefix)
  const count = signal(0);               // property (use . prefix)
  const onClick = signal(() => count.value++); // event (use @ prefix)

  return html`<button
    ${spread({
      id,              // -> id="cta"
      '?disabled': disabled, // -> disabled attribute toggled
      '.count': count,  // -> sets element.count property
      '@click': onClick // -> adds click listener
    })}
  >Clicked ${count.value} times</button>`;
}

customElements.define('fancy-button', Elemento(FancyButton));

Rules and tips:

  • Provide an object where values are Elemento signals; spread will pass their .value to lit.
  • Key prefixes follow lit’s conventions:
    • .prop for element properties
    • ?attr for boolean attributes
    • @event for event listeners
    • No prefix for regular attributes
  • For static values you don’t intend to change, set them directly in the template (e.g., class="btn"). If you want them to become reactive later, wrap them in a signal.
  • Under the hood this delegates to @open-wc/lit-helpers spread, so refer to their docs for advanced behavior.

Accessing the base element

If your component renders a primary control (like a native button) and you want to imperatively access it, set baseTag in options and then use el.baseElement:

function MyButton(props, el) {
  mount(() => {
    // Focus the internal button after first render
    el.baseElement?.focus();
  });
  return html`<button><slot></slot></button>`;
}

customElements.define(
  'my-focus-button',
  Elemento(MyButton, { baseTag: 'button' })
);

How it works

Elemento creates a class that extends HTMLElement and:

  • Observes specified attributes and maps them to signals
  • Creates reactive property signals with getters/setters
  • Reactivity powered by Preact Signals Core
  • Attaches a shadow root and adopts provided stylesheets
  • Re-renders efficiently via lit-html when any relevant signal changes
  • Cleans up on disconnect

Why Elemento?

  • No Virtual DOM: Direct DOM operations via lit-html
  • Standards-Based: Works with native browser technologies
  • Minimal Abstraction: Thin wrapper around web components
  • Functional Approach: Brings a React-like feel without React
  • TypeScript First: Designed with type safety in mind

Guides

License

MIT