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>`);Options
Section titled “Options”| 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 |
setup runs once
Section titled “setup runs once”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.
Props work from every direction
Section titled “Props work from every direction”Each declared prop is simultaneously a signal, an observed attribute, and a real DOM property:
el.setAttribute('max', '20'); // React, plain HTML, server-rendered markupel.max = 20; // Vue, Angular, Svelte, imperative JSprops.max(); // inside setupprops.max.set(20); // inside setupThe 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.
Events out
Section titled “Events out”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();Lifecycle
Section titled “Lifecycle”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 runsother.append(el); // moved — nothing is torn downel.remove(); // effects disposeddocument.body.append(el); // reconnected — setup runs againAnything 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.
Light DOM
Section titled “Light DOM”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.
Composition
Section titled “Composition”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.