Your first component
The whole model fits in one file. Here is the finished thing — it is running on this page, from the real bundle:
import { define, html, signal } from '@alacris/core';
define('demo-counter', { props: { start: 0, step: 1 }, styles: ` :host { display: inline-flex; align-items: center; gap: .75rem; font: inherit } button { font: inherit; padding: .3rem .7rem; border-radius: 6px; cursor: pointer; border: 1px solid currentColor; background: transparent; color: inherit } output { font-variant-numeric: tabular-nums; font-size: 1.4rem; min-width: 3ch; text-align: center; font-weight: 700 } `, setup({ start, step }) { const count = signal(start()); return html` <button @click=${() => count(count() - step())} aria-label="decrement">−</button> <output>${count}</output> <button @click=${() => count(count() + step())} aria-label="increment">+</button>`; },});Now the same thing, built up a piece at a time.
-
Define an element.
definetakes a tag name and a setup function. The name needs a hyphen — that is the platform’s rule for custom elements, not ours.import { define, html } from '@alacris/core';define('demo-counter', () => html`<p>hello</p>`);<demo-counter></demo-counter> -
Add state with a signal.
A signal is a function. Call it to read, call it with an argument to write.
import { define, html, signal } from '@alacris/core';define('demo-counter', () => {const count = signal(0);return html`<p>${count} clicks</p>`;});Interpolating
count— the function itself, notcount()— is what makes the binding live. That one text node now updates on its own forever. -
Handle an event.
define('demo-counter', () => {const count = signal(0);return html`<button @click=${() => count(count() + 1)}>+</button><output>${count}</output>`;});setupdoes not run again whencountchanges. Only the<output>text node is written. -
Accept props from the outside.
Declare them with defaults. The type of the default decides how the matching attribute is parsed.
define('demo-counter', {props: { start: 0, step: 1 },setup({ start, step }) {const count = signal(start());return html`<button @click=${() => count(count() - step())}>−</button><output>${count}</output><button @click=${() => count(count() + step())}>+</button>`;},});Each prop is now three things at once — a signal inside, an observed attribute outside, and a real DOM property:
<demo-counter start="3" step="5"></demo-counter>document.querySelector('demo-counter').step = 10;That is what makes the element behave natively in every framework: React sets attributes, Vue and Angular set properties, and both work.
-
Add styles.
stylesis parsed once for the whole page and shared by every instance.define('demo-counter', {props: { start: 0, step: 1 },styles: `:host { display: inline-flex; align-items: center; gap: .75rem }output { font-variant-numeric: tabular-nums; font-weight: 700 }`,setup({ start, step }) { /* … */ },});
What just happened
Section titled “What just happened”setupran once per element.- The template was parsed once for the whole page, then cloned per element.
- Clicking writes to one text node. Nothing else is consulted, compared or re-rendered.
- Alacris UI catalog — every component, live, nothing to clone
- Alacris UI — buttons, fields, and a theme, from npm
- Signals — computed values, effects and cleanup
- Templates — the full binding syntax
- Components — props, events, lifecycle and slots