Skip to content

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:

demo-counterrunning the real bundleEdit in playground
counter.js
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">&minus;</button>
<output>${count}</output>
<button @click=${() => count(count() + step())} aria-label="increment">+</button>`;
},
});

Now the same thing, built up a piece at a time.

  1. Define an element.

    define takes 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>
  2. 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, not count() — is what makes the binding live. That one text node now updates on its own forever.

  3. Handle an event.

    define('demo-counter', () => {
    const count = signal(0);
    return html`
    <button @click=${() => count(count() + 1)}>+</button>
    <output>${count}</output>`;
    });

    setup does not run again when count changes. Only the <output> text node is written.

  4. 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())}>&minus;</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.

  5. Add styles.

    styles is 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 }) { /* … */ },
    });
  • setup ran 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.