Ellement is a small TypeScript-first helper for building native Web Components with a lightweight component class, tagged html / css templates, local state, shadow DOM support, and delegated events.
The project is currently a Vite + TypeScript workspace and is not published to npm yet, so this README focuses on how the library works and how to use the source directly while it is in development.
- A base
EllementComponentclass that extendsHTMLElement. - Shadow DOM rendering by default.
- A
state()helper that stores local component state and schedules re-renders. - Tagged
htmlandcsstemplate helpers. - Static component styles plus per-render reactive styles.
- Delegated event handling through
this.on(...). - A simple render contract: return an HTML template or an object containing
htmland optionalstyles.
src/
core/
EllementComponent.ts Base class for all Ellement components
template/
css.ts css tagged template helper
html.ts html tagged template helper and parser
index.ts Template helper exports
types.ts TemplateResult types
examples/
Counter.element.ts Example counter Web Component
main.ts Demo entry that registers the counter example
style.css Vite demo styles
index.html Demo page
import EllementComponent from "./src/core/EllementComponent";
import { html, css } from "./src/template";
class GreetingElement extends EllementComponent {
static styles = css`
:host {
display: block;
font-family: system-ui, sans-serif;
}
strong {
color: royalblue;
}
`;
render() {
return html`
<p>Hello from <strong>Ellement</strong>.</p>
`;
}
}
customElements.define("greeting-element", GreetingElement);Then use the custom element in HTML:
<greeting-element></greeting-element>Every Ellement component extends EllementComponent and implements render().
import EllementComponent from "./src/core/EllementComponent";
import { html } from "./src/template";
class MessageElement extends EllementComponent {
render() {
return html`
<article>
<h2>Message</h2>
<p>This content is rendered inside the component root.</p>
</article>
`;
}
}
customElements.define("message-element", MessageElement);By default, Ellement attaches an open shadow root and renders into it. This keeps component markup and styles scoped away from the main document.
Use the html tagged template helper to create a TemplateResult.
render() {
const name = "Ada";
return html`
<p>Hello, ${name}.</p>
`;
}Interpolated values are normalized before being added to the output string:
null,undefined, andfalserender as an empty string.- Arrays are recursively joined.
- All other values are converted with
String(value).
Example:
render() {
const items = ["One", "Two", "Three"];
return html`
<ul>
${items.map((item) => `<li>${item}</li>`)}
</ul>
`;
}Ellement supports two kinds of styles:
- Static styles through
static styles. - Reactive styles returned from
render().
Static styles are defined once on the component class.
class BadgeElement extends EllementComponent {
static styles = css`
:host {
display: inline-block;
padding: 4px 8px;
border-radius: 999px;
background: #222;
color: white;
}
`;
render() {
return html`<slot></slot>`;
}
}If styles depend on component state, return an object with both html and styles.
class ToggleBadge extends EllementComponent {
active = this.state(false);
render() {
return {
styles: css`
:host {
background: ${this.active.value ? "seagreen" : "gray"};
color: white;
}
`,
html: html`
<button>${this.active.value ? "Active" : "Inactive"}</button>
`,
};
}
}During rendering, Ellement combines static styles first and reactive styles second, then injects them into a single <style> tag before the component HTML.
Use this.state(initialValue) to create local component state.
class CounterElement extends EllementComponent {
count = this.state(0);
render() {
return html`
<button id="dec">-</button>
<span>${this.count.value}</span>
<button id="inc">+</button>
`;
}
events() {
this.on("click", "#inc", () => {
this.count.setState((count) => count + 1);
});
this.on("click", "#dec", () => {
this.count.setState((count) => count - 1);
});
}
}The object returned by state() has:
| Property | Description |
|---|---|
value |
Getter for the current state value. |
setState(next) |
Updates state and requests a render. Accepts a direct value or updater function. |
setState() uses Object.is() to compare the old and new values. If the value did not change, Ellement skips the render.
For object state, return a new object when updating:
this.profile.setState((profile) => ({
...profile,
name: "Grace",
}));Calling setState() does not render synchronously. It calls requestRender(), which batches rendering into a microtask with queueMicrotask().
That means multiple state updates in the same synchronous call stack are coalesced into a single render request.
this.count.setState((count) => count + 1);
this.count.setState((count) => count + 1);Both updates happen before the scheduled render runs.
Use the optional events() lifecycle method to register delegated event handlers.
events() {
this.on("click", "button.save", (event, button) => {
console.log("Save clicked", event, button);
});
}this.on(event, selector, handler) listens from the component root and walks the event's composed path until it finds an element matching the selector.
The handler receives:
| Argument | Description |
|---|---|
event |
The typed DOM event object. |
el |
The matching HTMLElement or EllementComponent. |
Events are initialized once after the first render. Because handlers are delegated from the root, they continue to work after later renders replace the component's HTML.
Ellement uses shadow DOM by default:
protected static useShadow = true;To render directly into the custom element instead, override useShadow:
class LightDomElement extends EllementComponent {
static useShadow = false;
render() {
return html`<p>This renders in light DOM.</p>`;
}
}Use light DOM when you want global styles, normal document selectors, or external layout systems to affect the component directly.
The repository includes src/examples/Counter.element.ts, which demonstrates local object state, static styles, reactive styles, and events.
import { css, html } from "../template";
import EllementComponent from "../core/EllementComponent";
export default class CounterElement extends EllementComponent {
counterState = this.state({
count: 0,
color: "black",
});
static styles = css`
:host {
display: inline-block;
padding: 16px;
border-radius: 8px;
font-family: sans-serif;
border: 1px solid #ccc;
transition:
background 0.2s,
color 0.2s;
}
button {
margin: 4px;
padding: 6px 10px;
border: 1px solid #888;
border-radius: 4px;
cursor: pointer;
background: #f5f5f5;
}
span {
margin: 0 8px;
font-weight: bold;
font-size: 18px;
}
`;
render() {
return {
styles: css`
:host {
background: ${this.counterState.value.color === "black"
? "black"
: "white"};
color: ${this.counterState.value.color === "black"
? "white"
: "black"};
}
`,
html: html`
<button id="color">
Change color to
${this.counterState.value.color === "black" ? "white" : "black"}
</button>
<button id="dec">-</button>
<span>${this.counterState.value.count}</span>
<button id="inc">+</button>
`,
};
}
events() {
this.on("click", "#color", () => {
this.counterState.setState((value) => ({
...value,
color: value.color === "black" ? "white" : "black",
}));
});
this.on("click", "#inc", () => {
this.counterState.setState((value) => ({
...value,
count: value.count + 1,
}));
});
this.on("click", "#dec", () => {
this.counterState.setState((value) => ({
...value,
count: value.count - 1,
}));
});
}
}
customElements.define("counter-element", CounterElement);Base class for all components.
abstract class EllementComponent extends HTMLElement {
protected root: HTMLElement | ShadowRoot;
static useShadow = true;
static styles?: TemplateResult;
static props?: Record<string, EllementProp>;
connectedCallback(): void;
requestRender(): void;
state<T>(initial: T): {
readonly value: T;
setState(next: T | ((prev: T) => T)): void;
};
protected on<K extends keyof HTMLElementEventMap>(
event: K,
selector: string,
handler: (
e: HTMLElementEventMap[K],
el: HTMLElement | EllementComponent,
) => void,
): void;
protected events(): void;
abstract render(): TemplateResult | { styles?: TemplateResult; html: TemplateResult };
}Creates an HTML TemplateResult.
const view = html`<p>${message}</p>`;Creates a CSS TemplateResult.
const styles = css`
:host {
display: block;
}
`;Converts Ellement template results into the final string inserted into the component root.
Most users do not need to call this directly. It is used internally by EllementComponent.
type TemplateType = "html" | "css";
interface TemplateResult {
strings: TemplateStringsArray;
values: unknown[];
type: TemplateType;
}Ellement is intentionally small and still early. Keep these behaviors in mind:
- Rendering replaces
root.innerHTMLon every render. DOM nodes inside the component are recreated. - There is no DOM diffing or fine-grained update system yet.
- Template values are stringified. They are not automatically escaped or sanitized.
- Nested
htmlorcsstemplate results are not parsed inside interpolations yet. - Event binding is delegated and initialized once after the first render.
static propsexists in the type surface, but attribute/property reflection is not implemented yet.- There is no public package entrypoint yet. Current examples import from local source files.
The workspace includes these scripts:
{
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
}Use dev to run the local Vite demo, build to type-check and build, and preview to preview the built output.
Ellement depends on standard browser APIs:
- Custom Elements
- Shadow DOM
- Template literal tags
queueMicrotask()- DOM events and
composedPath()
It is designed for modern browsers that support native Web Components.