---
title: "How to Build a LemonadeJS Component Block"
description: "The recipe every Studio block was built from: the file shape, the contract-first workflow, the conventions, verify() as test #1, and the registry gate."
source: https://lemonadejs.com/docs/building-blocks/
---

# Building blocks

Building a block follows one recipe, and every block in the
[Studio catalog](/docs/studio/) came from it. The recipe is the point: it is precise enough that most of
the catalog was built by AI agents running it in parallel. The recipe
*is* the prompt. This chapter is that recipe, written so you (human or
agent) can add the 41st block.

```javascript
// the whole shape of a block, in one declaration
export const Switch = component('switch', {
    bind: Boolean,               // two-way state
    checked: false,              // initial state when unbound
    label: '',                   // label displayed beside the switch
    disabled: false,             // blocks interaction (native)
    onchange: Function,          // fires on user-initiated changes
    api: { toggle: Function },   // imperative surface via ref
}, (props, { bind }) => { /* ... */ });
```

A block in one declaration: contract first, `bind()` for the value, `props.ref` for the api. This is the whole recipe.

<!--example-->

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

export const Stepper = component('stepper', {
    bind: Number,                     // two-way value
    min: 0,
    max: 10,
    step: 1,
    onchange: Function,               // fires on user-initiated changes
    api: { inc: Function, dec: Function },
}, (props, { bind }) => {
    const value = bind(props, props.min.value);
    const clamp = (v) => Math.min(props.max.value, Math.max(props.min.value, v));
    const inc = () => value.set(clamp(value.value + props.step.value));
    const dec = () => value.set(clamp(value.value - props.step.value));
    props.ref?.({ inc, dec });
    return html`<span>
        <button onclick="${dec}">−</button> <b>${value}</b> <button onclick="${inc}">+</button>
    </span>`;
});

const App = (props, { state }) => {
    const qty = state(2);
    let api = null;
    return html`<div>
        <${Stepper} bind="${qty}" max="${5}" ref="${(a) => (api = a)}" onchange="${(v) => console.log('qty', v)}" />
        <button onclick="${() => api.inc()}">api.inc()</button>
        <p>bound qty = <b>${qty}</b> · contract: <code>${JSON.stringify(contract(Stepper).props)}</code></p>
    </div>`;
};
```

## Components as products

A component that works in an app and a component that is a *product*
are different artifacts. The product needs an interface others can
discover without reading source, proof that the implementation honors
it, documentation that cannot drift, and a clean death. None of that
happens by intention alone, and without a fixed recipe, every author
(and every agent, every session) invents a slightly different shape:
different class naming, different event casing, different cleanup
discipline. Multiple authors on one catalog produce multiple dialects,
and the catalog stops being a catalog.

The hardest item to get right is cleanup. Gesture systems (drags,
resizes, outside-click closers) are where components accumulate
`document` listeners they never remove. LemonadeJS v5's plugins leaked
exactly this way; the v6 recipe exists so the 40 current blocks
provably do not.

## The recipe

The recipe has five parts: a file shape, a contract-first workflow, a
dialect, a gate, and the lessons the first 40 blocks paid for.

### 1. The file shape

```
components/<name>/
    package.json        @lemonadejs/<name>; exports ".", "./style.css",
                        "./contract.json"; peerDependency: lemonadejs
    src/index.ts        ONE file: header JSDoc + contract + component
    src/style.css       plain CSS, lm-<name>-* classes only
    <name>.test.ts      behavior tests; verify() is test #1
    demo.ts, demo.html  playground page (npm run dev auto-discovers it)
    --- generated, never hand-written ---
    contract.json       the interface     (npm run registry)
    verify.json         the proof         (npm run registry)
    dist/index.d.ts     typed projection  (npm run registry)
    README.md           the docs          (npm run docs)
```

Two parts of `src/index.ts` are load-bearing beyond the code itself:
the **header JSDoc** becomes the README's Overview, and the **inline
comment after each contract prop** becomes that prop's description in
the generated props table. You write documentation exactly once, in the
place it cannot drift from.

### 2. Contract first, verify() as test #1

Declare the interface before writing behavior. The contract literal is
small enough to design in full. Then make conformance the first test
in the file:

```javascript
import { render as t, verify } from 'lemonadejs/test';

describe('components/switch', () => {
    it('passes verify(), the registry gate', () => {
        expect(verify(Switch).pass).toBe(true);
    });
    // behavior tests follow: every claim in the header JSDoc gets one
});
```

`verify()` mounts the component with defaults, exercises every declared
prop as a plain value *and* as a live state, fires every event path,
checks `bind` semantics and that the declared `api` is actually
exposed, and fails on any engine warning during any check
([Tests](/docs/tests/)). While the contract is still a stub, `verify()`
fails; when it passes, the interface is real. It is a to-do list that
ends in a proof.

### 3. The conventions: one dialect

These are not style preferences; they are what makes 43 blocks by many
authors read as one library.

- **CSS classes are strictly `lm-<name>-*`** (`lm-switch`,
  `lm-switch-track`). No other prefix, no styling engine.
- **Visual variants are `data-*` attributes on the root**
  (`data-color="red"`, `data-position="right"`): selectable in CSS,
  inspectable in the DOM.
- **Events are lowercase**: `onchange`, `onopen`, `onitemclick`. The
  engine warns `LJS-305` on anything else; component props are
  case-sensitive, so `onChange` would be a different, ignored prop.
- **Never name a bound state `value`**, because `value.value` is unreadable.
  Name it for what it holds: `checked`, `picked`, `open`, `current`.
- **`bind()` and `checked` semantics**: `bind` is the live two-way
  state and wins when present; `checked` (or the equivalent) is the
  *initial* state when unbound; a `value` prop, where it exists, is the
  string the form submits. DOM semantics, three distinct jobs (see
  [Two-way binding](/docs/two-way-binding/)). `current.set(v)` commits a
  user-initiated change and fires `onchange`; parent writes stay
  silent.
- **A native element core where one exists.** The Switch is a real
  `<input type="checkbox">`: form participation, disabled semantics and
  keyboard accessibility come from the platform, not from
  re-implementation.
- **Destroy-clean gestures go through `listen()`, armed mid-event.**
  The Modal's drag/resize tracker is the canonical shape: a single
  release function, replaced per gesture, every armed listener
  disposal-bound so a mid-drag unmount still cleans up:

```javascript
let release = null;
const track = (move, done) => {
    release?.();                                  // never stack gestures
    const offs = [
        listen(document, 'mousemove', move),
        listen(document, 'mouseup', () => release?.()),
    ];
    release = () => {
        offs.forEach((off) => off());             // off(): fire-once, self-pruning
        release = null;
        done?.();
    };
};
```

- **Raw `addEventListener` never appears in block code.** External
  listeners go through `listen()`; intervals and other resources clean
  up through `onMount`'s return value. See
  [Lifecycle](/docs/lifecycle/) and [Destroy](/docs/destroy/). The suites
  assert listener *balance*, not good intentions.

### 4. The gate

```
npm run registry    contract.json + verify.json + dist/index.d.ts per
                    block, registry.json index. Any verify() failure
                    exits 1. No contract, no entry; no proof, no entry.
npm run docs        regenerates every README + the catalog index
npm run dev         the playground; every block's demo on :3000
```

The `.d.ts` is generated as a projection of the contract, never written
by hand: TypeScript users get editor types, agents get JSON, one source
of truth ([Contracts](/docs/contracts/)).

### 5. The lessons (paid for once, encoded here)

- **`peek()` for imperative reads, `.value` for reactive ones.**
  Inside api methods and event handlers, read states with `peek()`, as
  the dropdown's `getValue`/`getData` do, so an imperative read never
  creates a phantom dependency. `.value` belongs in templates and
  [computed()](/docs/computed/) derivations, where subscribing is the
  point.
- **Never derive from a prop at setup.** Setup runs once; `const top =
  props.top.value + 10` is frozen forever. Use `computed()`. The Modal
  shipped this exact bug and its fix repaired every composer
  ([computed()](/docs/computed/)).
- **Guard `focusout` with `isDisposing()`.** Removing a focused element
  fires `focusout` exactly like a user clicking away; three blocks
  independently hit it. Anything that closes or commits on blur checks
  the engine first ([Destroy](/docs/destroy/)).
- **Behavioral parity means reading the source, not the interface.**
  The first Modal port matched the v5 `.d.ts` and missed the 768-line
  implementation's actual behavior; it had to be rebuilt to behavioral
  fidelity. A type signature is not a specification.
- **jsdom cannot do layout.** Anchoring, overflow flipping and drag
  geometry need a real browser; the repo escalates to headless-Chrome
  probes (`npm run probe`) for exactly those assertions. Know which of
  your claims jsdom can actually check.

## The agent workflow

This recipe was executed mostly by parallel AI agents: waves of six to
twelve at a time, each assigned one block in its own folder. The
constraints that made that work are part of the recipe:

- **Disjoint folders, no shared-file writes.** Each agent owns
  `components/<name>/` and touches nothing else; generated shared files
  (`registry.json`, the catalog README) are produced by the scripts,
  never edited by hand.
- **The source is the spec.** Agents porting v5 plugins read the v5
  implementation, not its type declarations (lesson above).
- **The gate is the reviewer.** `verify()` plus the behavior tests are
  the done-signal; a block that fails the registry gate is not done,
  whatever its author believes. This is the loop the whole framework
  optimizes; see [Agents](/docs/agents/).

If you are an agent reading this: this chapter plus
[llms.txt](https://lemonadejs.com/llms.txt) is the complete prompt for
building a conforming block.

## Compared to scaffolds, workbenches and library contribution

- **Copy-source scaffolds** give you the file shape, and full ownership
  of edits afterwards. The recipe's shape additionally comes with a
  gate: edits can be re-proven with `verify()`.
- **Workbench-driven development** (Storybook and its peers) produces
  demos and human visual review, a different kind of evidence, and a
  valuable one. `verify()` covers the machine-checkable half;
  the playground demo keeps the human half.
- **Contributing to an established library** means adopting its styling
  system, review queue and release train: the coordination that keeps
  a large shared codebase coherent. The Studio recipe is designed so a
  conforming block can be produced, and proven, in one session,
  because the rules of the commons are enforced by scripts rather than
  by review.

The honest limit: a gate proves conformance to the contract you wrote,
not that the contract is the right one. Taste, meaning which props a block
*should* have, is still the author's job, which is why the recipe says
to mine the best existing libraries for their prop vocabularies before
declaring a contract.

## In practice

The smallest real block, end to end. This is `@lemonadejs/switch`,
condensed:

```javascript
/**
 * <Switch /> is built on a real <input type="checkbox">: native form
 * participation, native disabled semantics, native keyboard a11y.
 */
import { component, html } from 'lemonadejs';

export const Switch = component('switch', {
    bind: Boolean,               // two-way state
    checked: false,              // initial state when unbound
    label: '',                   // label displayed beside the switch
    disabled: false,             // blocks interaction (native)
    onchange: Function,          // fires on user-initiated changes
    api: { toggle: Function },   // imperative surface via ref
}, (props, { bind }) => {
    const current = bind(props, props.checked.value);
    const toggle = () => {
        if (!props.disabled.value) current.set(!current.value);
    };
    props.ref?.({ toggle });

    return html`<label
        class="lm-switch ${() => (current.value ? 'lm-switch-on' : 'lm-switch-off')}">
        <input type="checkbox" class="lm-switch-input"
            checked="${current}" disabled="${props.disabled}"
            onchange="${(e) => current.set(e.target.checked)}" />
        <span class="lm-switch-track"><span class="lm-switch-thumb"></span></span>
        ${() => props.label.value && html`<span class="lm-switch-label">${props.label}</span>`}
    </label>`;
});

export default Switch;
```

Then: `verify(Switch)` as test #1, behavior tests for every claim in
the header, a demo page, `npm run registry`, and the block exists with
its contract, proof, types and README, deployable three ways
([Deployments](/docs/deployments/)).

## Reference

The checklist. A block ships when every line is true:

```
[ ] components/<name>/ matches the file shape exactly
[ ] contract declared first; prop names lowercase; inline comments on
    every prop (they are the docs)
[ ] verify(Block).pass === true, as test #1
[ ] behavior tests for every claim in the header JSDoc
[ ] classes lm-<name>-* only; variants as data-* attributes
[ ] events lowercase; no bound state named "value"
[ ] bind = live two-way (wins) / checked = initial / value = form submit
[ ] gestures: one persistent cleanup; onMount returns its cleanup;
    focusout guarded by isDisposing()
[ ] demo.ts renders every prop family; layout claims probed in Chrome
[ ] npm run registry passes: contract.json, verify.json, d.ts generated
[ ] npm run docs run: README regenerated, catalog index updated
```

The engine chapters behind the recipe: [State](/docs/state/),
[computed()](/docs/computed/), [Two-way binding](/docs/two-way-binding/),
[Lifecycle](/docs/lifecycle/), [Destroy](/docs/destroy/),
[Contracts](/docs/contracts/), [Errors](/docs/errors/).