---
title: "Web Components and Custom Elements"
description: "createWebComponent(C) derives a custom element from the contract: coerced attributes, element properties, bubbling CustomEvents, and bind as el.value."
source: https://lemonadejs.com/docs/web-components/
---

# Web components and custom elements

Web components, the platform's own custom elements, are its interop
standard: the one component
format every framework, CMS and plain HTML page can consume. In v6 you
do not write one. You derive it:

```javascript
import { createWebComponent } from 'lemonadejs';
import Switch from '@lemonadejs/switch';

createWebComponent(Switch);     // defines <lm-switch>, everything derived
```

```html
<lm-switch label="Dark mode" color="red" checked></lm-switch>
```

Zero options, because the [contract](/docs/contracts/) already says
everything an element needs to know: which attributes to observe, which
properties to expose, which events to dispatch, what the value is.

A contract becomes a custom element: attributes arrive typed, and each prop is a live element property.

<!--example-->

```js
import { html, component, createWebComponent } from 'lemonadejs';

const Greeting = component('greeting', { name: 'world', excited: false }, (props) =>
    html`<p>Hello, ${props.name}${() => (props.excited.value ? '!' : '.')}</p>`);

createWebComponent(Greeting);        // defines <lm-greeting>, everything derived from the contract

const App = () => {
    let el = null;
    return html`<div>
        <lm-greeting name="Ana" excited ref="${(e) => (el = e)}"></lm-greeting>
        <input placeholder="rename via el.name = …" oninput="${(e) => (el.name = e.target.value)}" />
        <button onclick="${() => (el.excited = !el.excited)}">toggle el.excited</button>
    </div>`;
};
```

## Background

Writing a custom element by hand means maintaining several parallel
pieces, and every piece drifts independently: the `observedAttributes`
list must be kept in sync with the properties, attributes are strings so
every typed property needs its own parsing, attribute→property and
property→render paths are wired one by one, events are dispatched
manually, and teardown in `disconnectedCallback` is on you. The element
is a second description of an interface the component already has,
written by hand, in a different dialect.

There is also a lifecycle issue the v5 engine ran into: a host may
remove an element from the DOM without calling any unmount API. The v5
policy was keep-alive: survive removal in case the host re-attaches.
When a host removes DOM freely, every removed element left a live,
subscribed component tree behind. Memory only grew.

## Derived element surface

`createWebComponent(C)` derives the entire element surface from the
contract, so there is no second description to drift:

- **Observed attributes = declared props, live.** Set an attribute
  before mount, after mount, from devtools, the component updates, and
  the string is coerced to the declared type (`pagination="4"` arrives
  as the number `4`; booleans use HTML presence semantics). Attributes
  set before the element is even defined are replayed at connect.
- **Element properties = declared props.** Every contract prop is a real
  accessor on the element: `el.label = 'x'` writes the live state,
  `el.label` reads it back. This is the core-of-HTML surface, the way
  rich values (arrays, objects, callbacks) travel to custom elements,
  since attributes can only carry strings.
- **Declared events dispatch real CustomEvents** on the host, bubbling
  and composed, with the callback's first argument as `event.detail`. A
  contract `onchange` becomes a `change` event: Vue's `@change`,
  Angular's `(change)` and plain `addEventListener` all work.
- **`bind` is the element's `value`.** The two-way state maps to an
  `el.value` property and an observed `value` attribute, and
  user-initiated changes dispatch a `change` CustomEvent even when the
  contract declares no `onchange`: form-control semantics, derived.
- **The tag is `lm-` + the contract name** (`{ prefix: 'app' }` for
  `<app-switch>`). Defining the same tag twice is a no-op that returns
  the tag name; registration is idempotent.

**Destroy by default.** A removed element unmounts its instance, so the
direct reversal of the v5 keep-alive policy and the reasoning is in the
source: hosts remove elements without calling `unmount()`, and a
kept-alive instance subscribed to a [store](/docs/store/) pins its whole
tree forever. One subtlety is handled for you: removal gets a
one-microtask grace period, so a same-tick *move* (reparenting, which
the DOM implements as remove + insert) survives with state intact, while
a real removal unmounts. Reconnecting later remounts fresh from the
element's preserved attribute states. The full memory story, heap
snapshots included, is the [Destroy](/docs/destroy/) chapter.

Components without a contract keep the legacy form:
`createWebComponent('name', fn)` passes connect-time attributes once as
string props, with rich values via `el.props = {...}` before connecting.
It works, but the live surface above is the reason to publish.

The element is one of three [deployments](/docs/deployments/) of the same
block, derived from the same contract that types it, documents it and
proves it.

## In practice

Every Studio block playground registers its element with the one line:
`createWebComponent(Switch)` sits at the top of the switch demo next to
by-value usage of the same import. The behavior is pinned in the library
suite (`tests/deployments.test.ts`), against the real datagrid:

```javascript
const tag = createWebComponent(Datagrid);    // 'lm-datagrid'
const el = document.createElement(tag);

el.data = rows;            // rich values as element PROPERTIES
el.columns = columns;
el.pagination = 4;
document.body.appendChild(el);
// → 4 rendered rows

el.pagination = 2;         // contract-derived accessor: LIVE
// → 2 rendered rows, no remount

el.remove();               // destroy-by-default
// one microtask later: instance unmounted, nothing leaked
```

And because object slot values become element properties inside
lemonade templates, the element composes back into its own ecosystem
without serialization:

```javascript
html`<lm-datagrid data="${rows}" columns="${columns}" pagination="${3}"></lm-datagrid>`
```

## Reference

```javascript
import { createWebComponent } from 'lemonadejs';

createWebComponent(C)                    // contract required → 'lm-<name>'
createWebComponent(C, { prefix: 'app' }) // → 'app-<name>'
createWebComponent('name', fn)           // no contract: connect-time string
                                         // props + el.props for rich values
// returns the tag name; idempotent if the tag is already defined
```

Derived from the contract: observed attributes (declared props,
lowercase, coerced, plus `value` when `bind` is declared); property
accessors per prop; `el.value` for `bind`; CustomEvents (bubbling,
composed, first callback argument as `detail`) for declared events, plus
`change` for `bind`. Lifecycle: removal unmounts after a one-microtask
grace (same-tick moves survive); reconnect remounts fresh; `el.unmount()`
forces disposal synchronously. For composition *inside* lemonade apps,
prefer `<${C} />` or a registered `<C />`; see
[Deployments](/docs/deployments/).