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.baseElementreturns 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 useel.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/computedare 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
baseTagis provided, you can access the underlying DOM node via thebaseElementgetter 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-helpers’ spread.
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;
spreadwill pass their.valueto lit. - Key prefixes follow lit’s conventions:
.propfor element properties?attrfor boolean attributes@eventfor 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 asignal. - Under the hood this delegates to
@open-wc/lit-helpersspread, 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