Skip to content

Components

define registers a custom element. What comes out is a standards tag with no Alacris-shaped API bolted onto it.

import { define, html, css, signal, computed } from '@alacris/core';
define('user-card', {
props: { name: 'anon', age: 0, tags: [] },
styles: css`
:host { display: block; font: inherit }
h3 { margin: 0 }
`,
shadow: 'open',
setup({ name, age, tags }, host) {
const grown = computed(() => age() >= 18);
return html`
<h3>${name}</h3>
<p>${() => (grown() ? 'adult' : 'minor')} · ${() => tags().join(', ')}</p>
<button @click=${() => host.emit('greet', { name: name() })}>say hi</button>`;
},
});

Shorthand, when there are no props or styles:

define('hello-world', () => html`<p>hello</p>`);
option default meaning
props {} prop names and defaults; the default’s type drives attribute coercion
setup runs once per element; return a template to render it
styles a sheet, a CSS string, or an array; parsed once per unique stylesheet
shadow 'open' 'open', 'closed', or false for light DOM
formAssociated false register as a form-associated custom element

This is the part that differs most from React and friends. setup is not a render function — there is nothing to re-run, because each binding updates itself.

setup({ count }) {
console.log('this logs once per element, ever');
return html`<p>${count}</p>`;
}

Which means ordinary JavaScript works: local variables persist, closures are stable, and there are no dependency arrays anywhere.

Each declared prop is simultaneously a signal, an observed attribute, and a real DOM property:

el.setAttribute('max', '20'); // React, plain HTML, server-rendered markup
el.max = 20; // Vue, Angular, Svelte, imperative JS
props.max(); // inside setup
props.max.set(20); // inside setup

The default’s type decides how the attribute is parsed:

default attribute "12" becomes notes
0 12 number
'' '12' string
false true presence is truth; "false" is false
[] or {} parsed as JSON invalid JSON falls back to the default

camelCase props observe kebab-case attributes — maxItems watches max-items.

host.emit dispatches a CustomEvent that bubbles and is composed, so it crosses the shadow boundary and reaches listeners on the outside:

setup(props, host) {
return html`<button @click=${() => host.emit('picked', { id: 3 })}>pick</button>`;
}
el.addEventListener('picked', (e) => console.log(e.detail.id));

It returns false when a listener called preventDefault(), so it doubles as a cancellable action:

if (host.emit('closing')) close();

setup runs on first connect. Removing the element disposes every effect it created. Teardown is deferred by a microtask, so moving an element between parents does not destroy it:

parent.append(el); // setup runs
other.append(el); // moved — nothing is torn down
el.remove(); // effects disposed
document.body.append(el); // reconnected — setup runs again

Anything you create in setup can clean itself up through an effect:

setup() {
effect(() => {
const id = setInterval(tick, 1000);
return () => clearInterval(id); // cleared when the element goes away
});
}

Shadow DOM already solves children — use <slot> and the browser composes for you. No library feature is involved:

define('info-card', {
setup: () => html`
<section>
<h3><slot name="title">Untitled</slot></h3>
<slot></slot>
</section>`,
});
<info-card>
<span slot="title">Deploys</span>
<p>Everything green.</p>
</info-card>

Slotted content stays in the light DOM, so the page’s CSS styles it, not the component’s. Reach inside with ::slotted():

:host ::slotted(p) { margin: 0 }

A control inside a shadow root is invisible to an enclosing <form> — its value never submits. formAssociated: true registers the element as a form-associated custom element: the browser treats it as a real field, and host.internals (the platform’s ElementInternals) is how it reports a value, validity, and state.

define('x-field', {
formAssociated: true,
props: { value: '', name: '' },
setup({ value }, host) {
// A live binding: every write to `value` reaches the form.
effect(() => host.internals.setFormValue(value()));
host.onFormReset = () => value.set('');
host.onFormDisabled = (disabled) => { /* fieldset disabled you */ };
return html`<input .value=${value} @input=${(e) => value.set(e.target.value)}>`;
},
});
<form>
<x-field name="nick"></x-field> <!-- submits like a native input -->
</form>

The platform captures form lifecycle reactions when the element is registered, so they cannot be added per instance — assign the forwarded handlers in setup instead: onFormAssociated(form), onFormDisabled(disabled), onFormReset(), and onFormStateRestore(state, mode). Validity works the same way: host.internals.setValidity({ valueMissing: true }, 'Required', anchor) makes the form refuse to submit exactly like an empty <input required>.

One ordering fact to know: the initial association and disabled state are established during upgrade, before setup runs — so the handlers only hear changes from then on. For the starting state, read it: host.internals.form for the owning form, host.matches(':disabled') for a surrounding <fieldset disabled>.

host.internals is undefined in environments without attachInternals (some simulated DOMs in tests) — guard with host.internals?. where that matters.

shadow: false renders into the element itself. Your page’s CSS applies normally, there is no encapsulation, and styles go to the containing document once rather than per element.

define('x-plain', { shadow: false, setup: () => html`<p>styled by the page</p>` });

Useful when a component must participate in an existing design system, or where form association and global CSS matter more than isolation.

Components nest like any other tag. Pass data down as props — on a custom element, data=${row} sets the property (objects, arrays and camelCase names included). Pass the signal, not row(); calling is a snapshot and the child will not see later writes.

define('x-list', {
props: { rows: [] },
setup: ({ rows }) => html`
${each(rows, (row) => html`<x-row data=${row}></x-row>`, (r) => r.id)}`,
});

.data=${row} is the same binding, written explicitly. For a value that needs to reach far down a tree without every layer forwarding it, use context.

  • Styling — stylesheets, and why they are parsed once
  • Context — passing values through a deep tree