@li3/web

Primitives for general-purpose web applications in the modern web. A reactive, template-driven component
library built on web standards (custom elements, templates, shadow DOM, CSS modules) with a concise
programming interface.

Public API

Reactive values and references:

    ref(value)                               Creates a reactive wrapper to a value (like a signal)
    computed(fn)                             Creates a computed property that updates when its dependencies change (e.g. other computed or refs)
    templateRef(refName: string)             Creates a ref and finds an element with a `ref={refName}` attribute in the view
    hook(value)                              Creates a reactive wrapper using React style hooks (e.g. const [value, setValue] = hook(initialValue))
    watch(ref, callback)                     Watches a reactive source and calls the callback when it changes
    effect(fn, effectFn)                     Runs a function and tracks its dependencies, re-running the effectFn when they change (wraps fn in computed)
    reactive(object, effect)                 Creates a reactive version of an object that triggers an effect when it changes
    nextTick()                               Resolves after the pending reactive update queue flushes

Lifecycle Hooks:

    onInit(fn)                               Called just before the component is initialized
    onDestroy(fn)                            Called just before the component is destroyed
    onUpdate(fn)                             Called just before the component inputs have changed (i.e. one or more props have changed)
    onCleanup(fn)                            Registers per-instance cleanup for resources created during setup

Component setup definitions:

    defineProp(name, options)                Defines a prop for a component with the given name and options (type, default value, etc.)
    defineEvent(name)                        Defines a custom event that the component can emit with the given name

Component definition:

    defineComponent(name, options)           Defines a custom element with the given name and options (template, shadowDom and setup function)
    mount(targetElement, options)            Mounts a component to a target element with the given options
    unwrap(object)                           Unwraps a reactive reference or computed property to get its underlying value
    load(href)                               Loads one or more components (as HTML) from a source and registers the  as a custom element

Internals:

    compare(a, b)                            Compares two values for equality, handling reactive references and computed properties
    canBeObserved(object)                    Checks if an object can be observed for reactivity (internal use only)
    isRef(x)                                 Check if a value was created with ref()

Extensions:

    use(rule)                                Add a new node and attribute matcher. `use({ match(node, attribute, value) {}, exec(node, attribute, value, context) {} })`

Set window.name to debug in any page to attach component context to components.


Complete Guide

The rest of this document is a complete, example-driven description of everything @li3/web can do.
It is written to be the only documentation needed to design and build applications with this framework.

Table of contents

1. Mental model
2. Getting started
3. Template syntax reference

4. Template expressions: rules and semantics
5. Declaring components in HTML

6. Full component examples
7. Reactivity in depth
8. Props
9. Events between components
10. Lifecycle hooks
11. Loading components at runtime
12. Programmatic mounting
13. The extension system
14. Feature flags and debugging
15. Complete syntax cheat sheet
16. Gotchas and rules of thumb


1. Mental model

An application is a tree of custom elements. Each component is:

Data flows down via props (defineProp + bind-*) and up via events (defineEvent + on-*).
State is held in signals (ref, computed); templates track signal reads and update the DOM
automatically when they change.

Two ways to declare components:

1. Declaratively in HTML — <template component="my-element"> in the page (auto-initialized) or in
files fetched with load(href). This is the primary path.
2. Programmatically in JS — mount(element, { template, setup }) or defineComponent(name, options).

2. Getting started

Import the module in a page. Auto-initialization runs shortly after module evaluation (on
DOMContentLoaded), which:

1. defines every in-document <template component="..."> as a custom element,
2. fetches and registers every <link rel="component" href="...">,
3. mounts every <template app> in place.

<script type="importmap">
  { "imports": { "@li3/": "https://cdn.li3.dev/@li3/" } }
</script>
<script type="module">
  import "@li3/web";
</script>

<template app>
  <h1>{{ title }}</h1>
  <button on-click="increment()">Count: {{ count }}</button>

  <script setup>
    import { ref } from "@li3/web";

    export default function () {
      const title = ref("Hello Lithium");
      const count = ref(0);
      const increment = () => count.value++;

      return { title, count, increment };
    }
  </script>
</template>

A standalone loader script that injects the import map and imports @li3/web is also published as
@li3/web/loader. If your page already has an import map, add "@li3/": "https://cdn.li3.dev/"
to it manually instead.

3. Template syntax reference

Templates are plain HTML. Special meaning is given to:

After a binding is processed, its attribute is removed from the DOM (kept when the debug feature
flag is on). Only one rule applies per attribute — the first matching rule wins.

3.1 Interpolation: {{ expression }}

Any text node containing {{ becomes reactive. Each {{ expression }} is spliced into a template
literal; the whole text node re-renders whenever any dependency of the expressions changes.

<p>Count: {{ count }}</p>
<p>{{ count }} doubled is {{ count * 2 }}</p>
<p>{{ user.name }} ({{ user.roles.join(', ') }})</p>

need granular structure: <p>Hello <b>{{ name }}</b>!</p>.

3.2 Event bindings: on-<event>[.<modifier>...]="expression"

Adds an event listener. The expression is compiled to a function receiving $event.

<button on-click="increment()">+1</button>
<input on-input="query = $event.target.value" />
<form on-submit.prevent="save()">...</form>
<div on-click.self="close()">...</div>
<a href="/x" on-click.stop.prevent="navigate($event)">link</a>

is in $event.detail.

Handlers call functions from the setup context. Direct assignment to context variables is not possible
(read-only context, see §4) — call methods instead.

3.3 Property bindings: bind-<prop>[.<modifier>...]="expression"

Sets a DOM property (not an attribute). Re-evaluated whenever the expression's dependencies change.

<input bind-value="name" />
<video bind-current-time="position"></video>
<div bind-scroll-top="scrollY"></div>
<pre bind-innerhtml="highlightedCode"></pre>

bind-class → el.className.

```html
<button bind-disabled.bool="isSaving">Save</button>
<section bind-hidden.bool="!isVisible">...</section>
```

toggled as class name(s) by the truthiness of its value. Separate multiple class names in a key
with spaces or dots:

```html
<div bind-class="{ active: isActive, disabled: isDisabled }"></div>
<div bind-class="{ 'foo bar xyz': isActive }"></div>
```

```html
<div bind-style="{ color: themeColor, 'font-size': size + 'px' }"></div>
```

This is also how you pass data into child components (see §8):

<count-trigger bind-trigger="currentValue"></count-trigger>

3.4 Attribute bindings: attr-<name>[.<modifier>...]="expression"

Sets an HTML attribute (stringified). Re-evaluated on change.

<button attr-aria-label="buttonLabel">?</button>
<div attr-data-state="state" attr-title="tooltip"></div>
<input attr-aria-invalid="hasError" />

```html
<button attr-disabled.bool="!isValid">Submit</button>
```

properties (value, checked, scrollTop, component props).

3.5 Class bindings: class-<name>[.<name2>...]="expression"

Toggles one or more classes based on the truthiness of the expression.

<div class-warning="count > 10">...</div>
<button class-font-bold.text-lg="counter > 2">...</button>
<li class-selected="item.id === selectedId">...</li>

Multiple classes in a single attribute are separated by dots: class-a.b="x" toggles a and b.
(A hyphenated name like class-font-bold is a single class font-bold.)

For object-style class maps use bind-class="{ ... }" (§3.3).

3.6 Style bindings: style-<property>="expression"

Sets an inline style property. Re-evaluated on change.

<div style-color="themeColor">...</div>
<div style-background-image="bgUrl">...</div>
<div style-transform="'translateX(' + x + 'px)'">...</div>

The property name is camelCased: style-background-image → el.style.backgroundImage.
For object style maps use bind-style="{ ... }".

3.7 Conditional rendering: <template if="expression">

Renders its content only while the expression is truthy.

<template if="count > 0">
  <p>You've clicked {{ count }} times</p>
</template>

<template if="user">
  <user-card bind-user="user"></user-card>
</template>

DOM when the expression turns truthy and removed when it turns falsy.

or computed expressions.

right after changing the condition.

3.8 List rendering: <template foreach="item of items">

Renders the content once per item of an iterable.

foreach is the recommended list directive and maps to the TemplateForeach built-in rule class.
For migration compatibility, <template for="..."> is also accepted for now. It may conflict with
the proposed platform meaning of for, so migrate applications to foreach before that alias is
removed in a future release.

<ul>
  <template foreach="todo of todos">
    <li class-done="todo.done" on-click="toggle(todo)">{{ todo.title }}</li>
  </template>
</ul>

With an index, use array destructuring syntax:

<template foreach="[user, i] of users">
  <tr>
    <td>{{ i + 1 }}</td>
    <td>{{ user.name }}</td>
  </tr>
</template>

Array.from(expression || []), so arrays and iterables work, and null/undefined render nothing.

are auto-unwrapped refs, so {{ todo.title }} just works.

updated in place (item.value = newItem). Rows beyond the new length are removed; new rows are
cloned and appended. There is no keying / track by — reordering an array rewrites the item of
each row at its index rather than moving DOM nodes.

```html
<template foreach="group of groups">
<h2>{{ group.name }}</h2>
<template foreach="[member, j] of group.members">
<p>{{ j }}: {{ member.name }} ({{ group.name }})</p>
</template>
</template>
```

3.9 Element refs: ref="name" + templateRef(name)

Mark any element with a plain ref attribute, then grab it in setup with templateRef:

<template component="search-box">
  <input ref="input" on-input="onInput($event)" />
  <button on-click="input.focus()">Focus</button>

  <script setup>
    import { templateRef, onInit } from "@li3/web";

    export default function () {
      const input = templateRef("input");

      onInit(() => input.value.focus());

      function onInput(e) {
        /* ... */
      }

      return { input, onInput };
    }
  </script>
</template>

HTMLInputElement).

directly (e.g. on-click="input.focus()").

3.10 Emitting events from templates: $$emit

Every component context contains an injected $$emit(name, value) function that dispatches a
CustomEvent on the host element:

<button on-click="$$emit('save', { id: item.id })">Save</button>

Equivalent to using defineEvent in setup (see §9), but callable
directly from template expressions.

3.11 Skipping subtrees: do-not-render

Any element carrying the do-not-render attribute is skipped by the binding walker — neither it nor
its children are processed. Useful for raw content that may contain {{ ... }} sequences:

<pre do-not-render>Literal mustache: {{ not-evaluated }}</pre>

4. Template expressions: rules and semantics

All binding expressions ({{ }}, on-*, bind-*, attr-*, class-*, style-*, if, for)
follow the same rules:

1. Plain JavaScript. By default, expressions are compiled with new Function. if is wrapped in
Boolean(...); for sources in Array.from(... || []); text interpolations become template
literals.
2. The context is the setup's return value merged with props (defineProp) and template refs
(templateRef / ref="..." attributes), plus the injected $$emit function. Only context keys
whose names literally appear in the expression are in scope for that expression.
3. Refs auto-unwrap on read. A context value created with ref/computed is transparently
replaced by its .value in templates: {{ count }}, count > 2, items.length all read the
unwrapped value. Refs also implement Symbol.toPrimitive, so arithmetic/coercion works even when a
ref slips through (count + 2).
4. The context is read-only. Assigning to a top-level context variable in a template
(on-click="count = count + 1") throws "View contexts are read-only". Call setup methods
instead: on-click="increment()". Two exceptions:

mutates the reactive object held by the user ref.

on-click="$event.target.trigger++".
5. Reactivity is automatic. Each binding re-evaluates its expression when any signal read during
evaluation changes. DOM updates are scheduled asynchronously (a ~5ms queue).
6. Functions are values. Methods returned from setup (increment, save, ...) are available by
name; call them in event handlers: on-click="increment()".

5. Declaring components in HTML

5.1 The component template

<template component="my-element">
  <!-- view markup with any template syntax -->
  <p>{{ message }}</p>

  <!-- optional behavior -->
  <script setup>
    import { ref } from "@li3/web";
    export default function () {
      const message = ref("hi");
      return { message };
    }
  </script>
</template>

font-face are rejected).

in the page (or added later) mounts the template.

JSON ShadowRootInit such as shadow-dom='{"mode":"open","delegatesFocus":true}'.

any number of <style> / <link rel="stylesheet">, <link rel="component">, and <ref> tags.
All of these special children are removed from the template before mounting and never appear in
the rendered output.

5.2 The app template

<template app> is a component without a name that mounts immediately at its own location (into a
<div style="display: contents"> inserted where the template was, which is then removed). Use it for
the application root — or several independent apps on one page.

<template app>
  <h1>{{ title }}</h1>
  <script setup>
    import { ref } from "@li3/web";
    export default function () {
      return { title: ref("My App") };
    }
  </script>
</template>

5.3 Setup scripts: <script setup>

The component's behavior module. Its default export is the setup function, executed once per
component instance. Everything the template needs must be returned as an object — forgetting
return yields an empty context.

<script setup>
  import { ref, computed, defineProp, defineEvent, onInit, templateRef } from "@li3/web";

  export default function () {
    const initial = defineProp("initial", { default: 0 });
    const count = ref(initial.value);
    const doubled = computed(() => count.value * 2);
    const onSave = defineEvent("save");
    const input = templateRef("input");

    const increment = () => count.value++;
    const save = () => onSave(count.value);

    onInit(() => console.log("mounted"));

    return { count, doubled, increment, save, input };
  }
</script>

relative URLs resolved against the page / the file the template was loaded from).

<script setup src="./logic.js"></script> (resolved relative to the component file).

5.4 Declarative JSON state: <script state>

Components with no behavior can skip the setup script and declare their initial context as JSON:

<template app>
  <script state type="application/json">
    {
      "title": "Facts",
      "facts": [
        { "title": "One", "content": "..." },
        { "title": "Two", "content": "..." }
      ]
    }
  </script>

  <h1>{{ title }}</h1>
  <template foreach="fact of facts">
    <article>
      <h3>{{ fact.title }}</h3>
      <p>{{ fact.content }}</p>
    </article>
  </template>
</template>

When both state and setup exist, each JSON entry is applied onto the setup result: writable
refs receive .value = entry, other keys are overwritten. This lets HTML authors inject content into
a scripted component:

<template app>
  <script state type="application/json">
    { "title": "Overridden" }
  </script>
  <script setup>
    import { ref } from "@li3/web";
    export default function () {
      return { title: ref("Default title") };
    }
  </script>
  <h1>{{ title }}</h1>
</template>

5.5 Declarative state refs: <ref>

Individual reactive values can be declared inside a template with <ref> tags (removed before mount):

<template app>
  <ref name="count" value="0" setter="setCount"></ref>
  <ref name="user" value='{"name":"Ada","admin":true}'></ref>

  <p>{{ count }} — {{ user.name }} ({{ user.admin }})</p>
  <button on-click="setCount(count + 1)">+1</button>
</template>

evaluation, anything else → string.

Without a setter, mutations must go through a setup function (or object mutation like user.name = x).

5.6 Styles: <style> and <link rel="stylesheet">

Style tags and stylesheet links inside a component template are converted to CSSStyleSheets and added
to adoptedStyleSheets of the shadow root (or the document, when no shadow DOM) at mount time:

<template component="ui-card" shadow-dom="open">
  <div class="card"><slot></slot></div>

  <style>
    .card {
      padding: 1rem;
      border: 1px solid #ccc;
      border-radius: 0.5rem;
    }
  </style>
  <link rel="stylesheet" href="./card.css" />
</template>

Additionally, loadCss(href, options?) can be called during setup to load and adopt a stylesheet
programmatically ({ adopt: false } skips adoption and just returns Promise<CSSStyleSheet>).

5.7 Component dependencies: <link rel="component">

A component file (or the page itself) can declare dependencies on other component files:

<!-- in the page head: loaded and registered at startup -->
<link rel="component" href="./components/ui-kit.html" />

<!-- inside a component template: loaded when the component is defined -->
<template component="user-dashboard">
  <link rel="component" href="./widgets.html" />
  <user-card></user-card>
</template>

URLs inside a loaded file resolve relative to that file. Loads are cached per resolved URL.

5.8 Shadow DOM and slots

With shadow-dom, the template renders into the element's shadow root and native <slot> works:

<template component="ui-card" shadow-dom="open">
  <div class="card">
    <span class="card-title">{{ title }}</span>
    <slot></slot>
    <slot name="footer"></slot>
  </div>
  <style>
    .card {
      padding: 1rem;
    }
  </style>
</template>

<ui-card title="Hello">
  <p>Default slot content</p>
  <footer slot="footer">Named slot content</footer>
</ui-card>

Without shadow-dom, content renders in light DOM and <slot> has no effect (there is no custom
slot implementation).

6. Full component examples

6.1 Counter (single file app)

<script type="importmap">
  { "imports": { "@li3/": "https://cdn.li3.dev/@li3/" } }
</script>
<script type="module">
  import "@li3/web";
</script>

<template app>
  <h1>{{ title }}</h1>
  <div class-warning="count > 10">
    <p>Count: <strong>{{ count }}</strong> (double: {{ doubled }})</p>
    <template if="count > 0">
      <p>You've clicked {{ count }} times</p>
    </template>
  </div>
  <button on-click="increment()" bind-disabled.bool="count > 5">Increment</button>
  <button on-click="reset()">Reset</button>

  <script setup>
    import { ref, computed } from "@li3/web";

    export default function () {
      const title = ref("Counter App");
      const count = ref(0);
      const doubled = computed(() => count.value * 2);
      const increment = () => count.value++;
      const reset = () => (count.value = 0);

      return { title, count, doubled, increment, reset };
    }
  </script>
</template>

6.2 Reusable component with props, events and shadow DOM

<template component="ui-card" shadow-dom="open">
  <div class="card">
    <span class="card-title">{{ title }}</span>
    <slot></slot>
    <button on-click="select()">Select</button>
  </div>

  <script setup>
    import { defineProp, defineEvent } from "@li3/web";

    export default function () {
      const title = defineProp("title", { default: "" });
      const onSelect = defineEvent("select");

      const select = () => onSelect(title.value);

      return { title, select };
    }
  </script>

  <style>
    .card {
      padding: 1rem;
      margin: 1rem auto;
      border-radius: 0.5rem;
      border: 1px solid #ccc;
    }
    .card-title {
      color: #999;
      text-transform: uppercase;
      font-size: 0.75rem;
    }
  </style>
</template>

<ui-card title="First card" on-select="onCardSelect($event)">
  <p>Card content</p>
</ui-card>

6.3 Lifecycle + props: count-trigger

<template component="count-trigger">
  <div>
    Ready? {{ ready }}<br />
    Update count: {{ count }}
  </div>

  <script setup>
    import { onInit, onUpdate, ref, defineProp } from "@li3/web";

    export default function () {
      const trigger = defineProp("trigger");
      const ready = ref(false);
      const count = ref(0);

      onInit(() => (ready.value = true));
      onUpdate(() => count.value++);

      return { trigger, ready, count };
    }
  </script>
</template>

<count-trigger bind-trigger="0" on-click="$event.target.trigger++"></count-trigger>

6.4 A todo list (loops, class bindings, two-way-ish input)

<template app>
  <form on-submit.prevent="add()">
    <input ref="draft" attr-placeholder="'What needs to be done?'" />
    <button attr-disabled.bool="!draft.value.trim()">Add</button>
  </form>

  <p>{{ remaining }} of {{ todos.length }} remaining</p>

  <ul>
    <template foreach="[todo, i] of todos">
      <li class-done="todo.done">
        <span on-click="toggle(todo)">{{ i + 1 }}. {{ todo.title }}</span>
        <button on-click="remove(todo)">x</button>
      </li>
    </template>
  </ul>

  <template if="!todos.length">
    <p>Nothing to do. 🎉</p>
  </template>

  <script setup>
    import { ref, computed, templateRef } from "@li3/web";

    export default function () {
      const draft = templateRef("draft");
      const todos = ref([
        { title: "Learn Lithium", done: true },
        { title: "Build an app", done: false },
      ]);
      const remaining = computed(() => todos.value.filter((t) => !t.done).length);

      function add() {
        todos.value.push({ title: draft.value.value.trim(), done: false });
        draft.value.value = "";
      }
      const toggle = (todo) => (todo.done = !todo.done);
      const remove = (todo) => (todos.value = todos.value.filter((t) => t !== todo));

      return { draft, todos, remaining, add, toggle, remove };
    }
  </script>
</template>

Note the patterns: array mutations (todos.value.push(...)) and nested object mutations
(todo.done = ...) are reactive; replacing the array (todos.value = filtered) also works.

6.5 Script-less app with <ref> and <script state>

<template app>
  <ref name="count" value="0" setter="setCount"></ref>
  <script state type="application/json">
    { "concepts": ["interpolation", "events", "loops"] }
  </script>

  <button on-click="setCount(count + 1)">Count: {{ count }}</button>
  <ul>
    <template foreach="c of concepts">
      <li>{{ c }}</li>
    </template>
  </ul>
</template>

7. Reactivity in depth

All reactive primitives are signals: objects with a .value property (type Signal<T> = { value: T }).

ref(value, isShallow?)

Creates a writable signal.

import { ref } from "@li3/web";

const count = ref(0);
count.value++; // triggers updates
console.log(count.value); // 1

mutations trigger updates: user.value.name = 'Ada', list.value.push(x).

(deep comparison for arrays/dates/regexps; see compare).

shallowRef(value)

A ref without deep wrapping — nested mutations do not trigger updates; only replacing .value does.
templateRef() returns a shallow ref (so input.value.value = 'x' on an element ref isn't tracked,
which is what you want).

computed(fn)

A read-only derived signal. Re-evaluates lazily when any signal read inside fn notifies.

import { ref, computed } from "@li3/web";

const a = ref(1);
const b = ref(2);
const sum = computed(() => a.value + b.value);

sum.value; // 3
a.value = 5;
sum.value; // 7
sum.value = 9; // throws: "Computed value cannot be set"

Errors thrown inside fn are swallowed and yield null (logged when debug is on).

watch(signal, callback, options?)

Calls callback(newValue, lastValue) whenever the signal changes. Returns an unsubscribe function.

import { ref, watch } from "@li3/web";

const count = ref(0);
const unwatch = watch(count, (value, lastValue) => {
  console.log(`${lastValue} -> ${value}`);
});

count.value = 1; // logs "0 -> 1"
unwatch(); // stop watching

passed, in which case it fires synchronously with the current value.

effect(fn, effectFn, options?)

effect(fn, effectFn) is watch(computed(fn), effectFn): fn computes a tracked value; whenever it
changes, effectFn(newValue, lastValue) runs. All DOM bindings are built on this.

import { ref, effect } from "@li3/web";

const user = ref({ name: "Ada" });
effect(
  () => user.value.name.toUpperCase(),
  (name) => console.log("name changed:", name),
);
user.value.name = "Grace"; // logs "name changed: GRACE"

hook(initialValue, isShallow?)

React-style tuple:

import { hook } from "@li3/web";

const [count, setCount] = hook(0);
setCount(42);
console.log(count.value); // 42

reactive(object, effectFn)

Creates a deep reactive proxy of object that calls effectFn on any change (set or delete, at any
depth). Non-objects and already-reactive objects pass through unchanged.

import { reactive } from "@li3/web";

const state = reactive({ user: { name: "Ada" }, items: [] }, () => console.log("changed"));
state.user.name = "Grace"; // logs "changed"
state.items.push(1); // logs "changed"

unwrap(object)

Returns the raw value: the inner object for reactive proxies, .value for refs/computed, otherwise
the input unchanged. null/undefined pass through.

isRef(x), isReadOnlyRef(x), isWritableRef(x)

Type guards. computed results are read-only; ref results are writable.

suspend(signal) / resume(signal)

Suspend a signal: writes to .value become no-ops and its dependencies are dropped. resume clears
the flag and forces a re-update. Used internally to freeze rows removed by <template for>.

canBeObserved(object)

True for plain objects that are not already reactive proxies. Internal use.

Timing

notifications run synchronously. All DOM updates from bindings flow through this, so DOM reflects
state changes asynchronously — don't assert on the DOM synchronously after a state change.

Use await nextTick() when code needs to wait for the scheduled reactive work:

import { nextTick } from "@li3/web";

count.value++;
await nextTick();
// DOM bindings have now processed the queued update.

Resources created during setup and template linking are disposed when the component is unmounted.
Application code can register its own cleanup:

import { onCleanup } from "@li3/web";

export default function () {
  const timer = setInterval(refresh, 1000);
  onCleanup(() => clearInterval(timer));
  return { refresh };
}

8. Props

defineProp(name, options?) declares an input. Call it in setup; it returns a writable ref and also
exposes the prop in the template context by name.

import { defineProp } from "@li3/web";

export default function () {
  const title = defineProp("title", { default: "Untitled" });
  const items = defineProp("items", { default: () => [] });
  const active = defineProp("active", { default: false, attribute: true });

  return { title, items, active };
}

Options (PropOptions):

is invoked (use it for object/array defaults).

(setAttribute(name, String(value))).

Initial value resolution, in order:

1. The element property if already set (el.title = 'Hi' before connection).
2. The element attribute, parsed smartly: 'true'/'false' → boolean, JSON/JS expressions and
numbers evaluated (value='[1,2,3]' → array), anything else → string.
3. The default.

Setting props from a parent — use bind-* (property assignment) so any value type works:

<user-list bind-users="allUsers" bind-page-size="25"></user-list>

(debounced 1ms), and reflects to the attribute when attribute: true.

mount are not observed.

el.title.

9. Events between components

defineEvent(name) declares an output and returns an emitter function. Call it with a payload:

import { defineEvent, ref } from "@li3/web";

export default function () {
  const onSave = defineEvent("save");
  const draft = ref("");

  const save = () => onSave(draft.value); // emits CustomEvent('save', { detail: draft.value })

  return { draft, save };
}

Parent listens with the standard event binding; the payload is in $event.detail:

<editor-pane on-save="handleSave($event)"></editor-pane>
function handleSave(e) {
  console.log("saved:", e.detail);
}

are not set), so listeners must be on the element itself — exactly what on-<name> does.

on-click="$$emit('close')".

10. Lifecycle hooks

All lifecycle functions must be called during setup (they need the current component context and
throw "Missing context for this component" otherwise).

import { onInit, onUpdate, onDestroy, getElement } from "@li3/web";

export default function () {
  onInit(() => {
    // DOM is in place; good for focus(), measurements, starting timers
    getElement().querySelector("input")?.focus();
  });

  onUpdate(() => {
    // one or more props changed (debounced ~1ms)
  });

  onDestroy(() => {
    // element disconnected / unmounted; clear timers, abort requests
  });

  return {};
}

11. Loading components at runtime: load

load(href, baseUrl?) fetches an HTML file, registers every <template component="..."> in it, and
resolves to the list of definitions. Relative URLs inside the file (setup src, stylesheets, nested
<link rel="component">) resolve against the file URL. Results are cached per resolved URL; failures
log to the console and resolve to [].

import { load } from "@li3/web";

await load("./components/ui-kit.html");
// <ui-card> etc. are now defined and upgrade automatically

Component file format — a complete example:

<!-- ui-kit.html -->
<template component="ui-badge">
  <ref name="kind" value="'info'"></ref>
  <span class-badge="true" class-info="kind === 'info'" class-warn="kind === 'warn'">
    <slot></slot>
  </span>
  <style>
    .badge {
      padding: 0.25rem 0.5rem;
      border-radius: 1rem;
    }
    .info {
      background: #def;
    }
    .warn {
      background: #fed;
    }
  </style>
</template>

<template component="ui-card" shadow-dom="open">
  <link rel="component" href="./ui-avatar.html" />
  <link rel="stylesheet" href="./ui-card.css" />

  <div class="card">
    <ui-avatar bind-user="author"></ui-avatar>
    <h3>{{ title }}</h3>
    <slot></slot>
  </div>

  <script setup>
    import { defineProp, defineEvent } from "@li3/web";
    export default function () {
      defineProp("title", { default: "" });
      defineProp("author", { default: null });
      const onOpen = defineEvent("open");
      return { onOpen };
    }
  </script>
</template>

12. Programmatic mounting: mount

mount(targetElement, options) mounts a component onto an existing element and returns an unmount
function. The function has a ready: Promise<void> property; it resolves once initial linking and mount
hooks complete. With codePlan, generated module loading makes this asynchronous. Calling the returned
function before ready settles cancels the pending mount and disposes its setup resources. Options
(MountOptions):

{
  template: HTMLTemplateElement | string; // a <template> or an HTML string
  setup?: () => object;                   // the setup function
  styles?: CSSStyleSheet[];               // adopted into shadowRoot or document
  shadowDom?: boolean | ShadowRootInit;   // true = { mode: 'open' }
  refs?: [name, value, setterName][];     // declarative refs
}
import { mount, ref } from "@li3/web";

const unmount = mount(document.getElementById("app"), {
  template: `<p>{{ message }}</p><button on-click="increment()">{{ count }}</button>`,
  setup() {
    const message = ref("Hello");
    const count = ref(0);
    const increment = () => count.value++;
    return { message, count, increment };
  },
});

// later: unmount();

defineComponent(name, options) registers a custom element class with the same options. Note it is
part of the component module but not re-exported from the package entry point — prefer
<template component> / load() for HTML-driven apps, and mount() for programmatic ones.

autoInitialize() returns a promise for the startup pass (define in-document template[component], load
link[rel=component], mount template[app]). It runs automatically unless the skipAutoInitialize
flag is set.

13. The extension system: use

All template syntax is implemented as rules, and you can add your own:

use({
  match(node: Element, attribute: string, value: string): boolean,
  exec(node: Element, attribute: string, value: string, context: any): void,
});

runs exec, and the attribute is removed from the DOM.

AddEventListener, SetProperty, SetAttribute, SetClassName, SetStyle.

createFunction(expression, context, args); wire reactivity with effect/watch.

Example — a custom tooltip directive:

import { use, effect } from "@li3/web";

use({
  match: (node, name) => name === "tooltip",
  exec: (node, name, value, context) => {
    // naive implementation: evaluate `value` against context and keep title in sync
    const fn = new Function(`with(this) { return ${value}; }`).bind(context);
    effect(fn, (v) => node.setAttribute("title", v ?? ""));
  },
});
<span tooltip="user.fullName">hover me</span>

Example — Vue-style syntax (this is how the sibling @li3/use package implements useVue()):
subclass the built-ins and remap attribute names:

import { use, TemplateForeach, TemplateIf, SetProperty, AddEventListener } from "@li3/web";

class VueSetProperty extends SetProperty {
  match(node, name) {
    return name.startsWith(":");
  }
  exec(node, name, value, context) {
    return super.exec(node, "bind-" + name.slice(1), value, context);
  }
}

class VueEventListener extends AddEventListener {
  match(node, name) {
    return name.startsWith("@");
  }
  exec(node, name, value, context) {
    return super.exec(node, "on-" + name.slice(1), value, context);
  }
}

class VueTemplateIf extends TemplateIf {
  match(node, name) {
    return name === "v-if";
  }
  exec(node, name, value, context) {
    const t = document.createElement("template");
    node.replaceWith(t);
    node.removeAttribute("v-if");
    t.content.append(node);
    super.exec(t, "if", value, context);
  }
}

class VueTemplateFor extends TemplateForeach {
  match(node, name) {
    return name === "v-for";
  }
  exec(node, name, value, context) {
    const t = document.createElement("template");
    node.replaceWith(t);
    node.removeAttribute("v-for");
    t.content.append(node);
    super.exec(t, "foreach", value, context);
  }
}

use(new VueSetProperty());
use(new VueEventListener());
use(new VueTemplateIf());
use(new VueTemplateFor());

This enables :value="x", @click="fn()", v-if="cond", v-for="item of items" on any element.

14. Feature flags and debugging

Set flags via setFeatureFlag(name, value?) (must run before components initialize) or by listing them
comma-separated in window.name (e.g. window.name = 'debug' — note this replaces the window name).

FlagEffect
debugKeeps processed binding attributes on elements; keeps <template app> elements in the DOM; attaches every signal to window.refList (a Set of WeakRefs); logs component redefinition and computed errors.
strictCompareUses === instead of the deep-ish compare for change detection.
skipAutoInitializeDisables the automatic startup pass; call autoInitialize() yourself.
codePlanExperimental generated binding plan. Disabled by default; opt in with window.name = 'codePlan' before importing Li3.

codePlan asynchronously imports generated binding modules from Blob URLs. It inlines expressions for
built-in rules and avoids new Function in that path, but requires CSP to allow blob: in script-src.
Custom rules may still use dynamic code generation. It compiles nested if/foreach content recursively
and resolves planned node paths before executing operations. Keep it opt-in until benchmarked against
representative applications.

Debugging aids:

attributes stay visible in devtools, and window.refList lets you inspect live signals.

the DOM, which helps trace conditional/list regions in devtools.

15. Complete syntax cheat sheet

FeatureSyntaxExample
Interpolation{{ expr }} in text nodes<p>{{ user.name }}</p>
Eventon-<event>="expr"<button on-click="save()">
Event modifiers.stop .prevent .self<form on-submit.prevent="save()">
Event payload$event ($event.detail for custom events)on-input="onInput($event)"
Property bindingbind-<prop>="expr" (camelCased)<input bind-value="name">
Boolean propertybind-<prop>.bool="expr"<button bind-disabled.bool="busy">
Class mapbind-class="{ cls: cond }"<div bind-class="{ active: isOn }">
Style mapbind-style="{ prop: value }"<div bind-style="{ color: c }">
Attribute bindingattr-<name>="expr"<div attr-data-id="id">
Boolean attributeattr-<name>.bool="expr"<input attr-readonly.bool="locked">
Class toggleclass-<name>[.<name2>]="expr"<li class-done="t.done">
Style propertystyle-<prop>="expr" (camelCased)<div style-color="c">
Conditional<template if="expr"><template if="user">...</template>
List<template foreach="item of items"><template foreach="u of users">...</template>
List + index<template foreach="[item, i] of items"><template foreach="[u, i] of users">...</template>
Element refref="name" + templateRef('name')<input ref="box">
Declarative state<ref name value setter> inside template<ref name="n" value="0" setter="setN">
JSON state<script state type="application/json">{ "title": "Hi" }
Setup module<script setup> (or src=)export default function () { ... }
Component<template component="my-el" shadow-dom="open">custom element definition
App root<template app>auto-mounted
Styles<style> / <link rel="stylesheet"> inside templateadopted stylesheets
Dependencies<link rel="component" href="...">page or template level
Emit from template$$emit('name', value)on-click="$$emit('close')"
Skip subtreedo-not-render attribute<pre do-not-render>
Custom ruleuse({ match, exec })see §13
Feature flagssetFeatureFlag('debug') / window.namesee §14

16. Gotchas and rules of thumb

1. Return your bindings. The setup function's returned object is the template context. No return
→ empty context → bindings silently do nothing.
2. Templates are read-only. on-click="count++" throws. Expose methods (increment()) and call
them. Object internals may be mutated (user.name = x).
3. for uses of, not in. There is no keying; rows are reused by index and their item values
updated in place.
4. No else. Use complementary <template if> conditions or computed flags.
5. DOM updates are async. Bindings flush on a ~5ms queue; if/for insertions happen on a
timeout. Read the DOM after a setTimeout/await new Promise(r => setTimeout(r)) if needed.
6. Custom events don't bubble. Listen with on-<name> directly on the child element tag.
7. Attributes initialize props; properties drive them. After mount, set props via properties
(bind-* / el.prop = v); attribute changes are not observed.
8. One rule per attribute, first match wins. Rule prefixes: on-, bind-, attr-, class-,
style-; if/for only on <template>.
9. Binding attributes are stripped after processing (visible with the debug flag).
10. Component names need a dash and must not be SVG-reserved names (font-face, etc.).
11. load() caches per URL and resolves to [] on failure (check the console).
12. Refs auto-unwrap in templates, but in setup code always use .value (except in coercions,
where Symbol.toPrimitive applies).
13. <slot> requires shadow DOM (shadow-dom="open"); there is no light-DOM slot polyfill.
14. Boolean attributes need .bool (attr-disabled.bool="x" or bind-hidden.bool="x"); plain
attr-disabled="false" sets the string "false", which is still truthy as an attribute presence.
15. Setup-side helpers require setup context: defineProp, defineEvent, templateRef,
onInit, onUpdate, onDestroy, getElement, loadCss (with adoption) throw outside a running
setup function.