Basic Usage
define(tag, definition) creates and registers a custom element. Call it from the module or browser bootstrap responsible for registration.
Your setup() function receives typed prop signals and returns an HTMLResult directly. Its state belongs to the current connection: disconnect disposes it, and reconnecting the same element runs setup again.
import { signal } from '@vielzeug/ripple';
import { define, html } from '@vielzeug/ore';
define('status-chip', {
setup() {
const online = signal(true);
return html`
<button @click=${() => (online.value = !online.value)}>${() => (online.value ? 'Online' : 'Offline')}</button>
`;
},
});Everything besides props: lifecycle hooks, host bindings, context, slots, emit: is a plain function imported from @vielzeug/ore, called directly from setup() (or a composable it calls):
import { define, getHost, html, bind, useEmit, useSlots } from '@vielzeug/ore';
define('my-widget', {
setup(_props) {
const el = getHost(); // the host HTMLElement
const emit = useEmit<{ close: undefined }>(); // typed event emitter
const slots = useSlots<'header'>(); // reactive slot observation
bind({ attr: { role: 'group' } }); // host binding helper (attr, class, style, on)
return html`<slot></slot>`;
},
});signals and effects
Ore does not re-export ripple primitives: import them directly from @vielzeug/ripple.
import { batch, computed, effect, signal, watch } from '@vielzeug/ripple';
const count = signal(0);
const doubled = computed(() => count.value * 2);
effect(() => {
console.log('doubled =', doubled.value);
});
watch(count, (next, prev) => {
console.log('count changed', prev, '->', next);
});
batch(() => {
count.value = 1;
count.value = 2;
});onMounted and lifecycle
Use onMounted() for DOM-dependent initialization that must run after the template is mounted. Use onElement(ref, cb) for work tied to a specific DOM node. onEvent() attaches a listener that is automatically removed on disconnect.
import { signal } from '@vielzeug/ripple';
import { define, html, onElement, onEvent, onMounted, ref, useSlots } from '@vielzeug/ore';
define('deferred-init', {
setup(_props) {
const tabIndex = signal(0);
const inputRef = ref<HTMLInputElement>();
const slots = useSlots<'items'>();
onMounted(() => {
const items = slots.elements('items').value;
console.log('Found', items.length, 'items');
});
onElement(inputRef, (input) => {
input.focus();
});
onEvent(window, 'keydown', (e: KeyboardEvent) => {
if (e.key === 'Escape') tabIndex.value = 0;
});
return html`<div><slot name="items"></slot><input ref=${inputRef} /></div>`;
},
});prop definitions
Use prop.* helpers for common cases, or raw PropDef objects for custom parsing or reflect: false.
import { define, html, prop } from '@vielzeug/ore';
define('x-button', {
props: {
label: prop.string('Button'),
disabled: prop.bool(false),
variant: prop.oneOf(['primary', 'secondary'] as const, 'primary'),
count: prop.number(0),
},
setup(props) {
return html`
<button ?disabled=${props.disabled} data-variant=${props.variant}>${props.label} (${props.count})</button>
`;
},
});template bindings
html supports text, attributes, booleans, properties, events, refs, and nested templates. Event bindings accept .stop, .prevent, .once, and .capture: the first two apply before the handler runs, while the latter two use native listener options. Unknown modifiers throw while Ore compiles the template.
import { computed, signal } from '@vielzeug/ripple';
import { define, html, model, ref } from '@vielzeug/ore';
define('profile-name', {
setup() {
const name = signal('Alice');
const inputRef = ref<HTMLInputElement>();
return html`
<label title=${computed(() => 'Current: ' + name.value)}>Name</label>
<input
ref=${inputRef}
value=${model(name)}
aria-label=${() => 'Current name ' + name.value} />
<p>Hello ${name}</p>
`;
},
});directives
Ore exports each, classMap, styleMap, when, live, model, and unsafeHtml from @vielzeug/ore.
import { signal } from '@vielzeug/ripple';
import { classMap, define, each, html, styleMap, when } from '@vielzeug/ore';
define('task-list', {
setup() {
const tasks = signal([{ id: 1, text: 'Write tests' }]);
const active = signal(true);
return html`
<ul
class="${classMap({ ready: () => tasks.value.length > 0 })}"
style=${styleMap({ opacity: () => (active.value ? 1 : 0.5) })}>
${when(
() => active.value,
() => html`<li>Active</li>`,
() => html`<li>Paused</li>`,
)}
${each(
tasks,
(task) => task.id,
(task) => html`<li>${() => task.value.text}</li>`,
)}
</ul>
`;
},
});each() API
each(source, key, render, fallback?) takes positional arguments:
- source: signal, getter, or plain array
- key: function returning a unique string or number per item; number and string keys remain distinct
- render: receives reactive
itemandindexsignals - fallback: optional, rendered when the list is empty
each(
items,
(item) => item.id,
(item, index) => html`<li>#${index}: ${() => item.value.label}</li>`,
() => html`<li>No items</li>`,
);Each reactive reconciliation is transactional. Ore creates missing entries before committing updates or removing stale entries; if a render callback throws, entries created during that pass are rolled back and the last successful list remains on screen. Ore reports the failure through ore:error with phase each-reconcile.
model: two-way form bindings
Use model(signal) for a controlled input: it writes the signal to the control's value/checked property and writes user input back on the control's change event ('change' for checkboxes, radios, and selects; 'input' otherwise). This replaces the live() + manual @input handler pair with one binding.
import { signal } from '@vielzeug/ripple';
import { define, html, model } from '@vielzeug/ore';
define('live-search', {
setup() {
const query = signal('');
const agree = signal(false);
return html`
<input value=${model(query)} />
<input type="checkbox" checked=${model(agree)} />
`;
},
});A native text input's value is always a string, so pass a get reader to keep a numeric signal numeric, and an event name for custom controls that emit their own change event:
const count = signal(0);
html`
<input type="number" value=${model(count, { get: (el) => Number(el.value) })} />
<my-picker value=${model(pick, { event: 'pick-change', get: (el) => (el as MyPicker).selection })} />
`;live: one-way bindings that preserve user edits
Use live(signal) when a binding must skip a stale app-state write while the user is mid-edit, without writing user input back. model() covers the common controlled-input case; live() is for one-way bindings that only need the stale-write guard.
import { signal } from '@vielzeug/ripple';
import { define, html, live } from '@vielzeug/ore';
define('live-search', {
setup() {
const query = signal('');
return html`
<input value=${live(query)} @input=${(e: Event) => (query.value = (e.target as HTMLInputElement).value)} />
`;
},
});host bindings
bind() wires reactive attrs, classes, styles, and events to the host element.
import { signal } from '@vielzeug/ripple';
import { bind, define, html } from '@vielzeug/ore';
define('x-toggle', {
setup(_props) {
const open = signal(false);
bind({
attr: { 'aria-expanded': () => String(open.value), role: 'button', tabindex: 0 },
class: { 'is-open': open },
on: { click: () => (open.value = !open.value) },
});
return html`<slot></slot>`;
},
});The bind config supports attr, class, style, and on sections.
ARIA bindings
Use explicit aria-* keys in bind({ attr: config }, { target }) to reactively sync ARIA attributes to any element.
import { signal } from '@vielzeug/ripple';
import { bind, define, html, onMounted } from '@vielzeug/ore';
define('x-disclosure', {
setup(_props) {
const open = signal(false);
const panelId = 'disclosure-panel';
bind({
attr: { role: 'button', tabindex: 0 },
on: { click: () => (open.value = !open.value) },
});
onMounted(() => {
const trigger = document.querySelector('#trigger') as HTMLElement;
if (trigger) {
// bind() registers cleanup automatically when called inside setup
bind(
{
attr: {
'aria-controls': panelId,
'aria-expanded': () => String(open.value),
'aria-haspopup': 'region',
},
},
{ target: trigger },
);
}
});
return html`<slot></slot>`;
},
});Static values are applied once. Getter functions create reactive effects. Setting a value to null, undefined, or false removes the attribute.
bind() always returns a cleanup function. Use it to stop syncing early when a trigger element can be swapped out:
onMounted(() => {
const trigger = document.querySelector('#trigger') as HTMLElement;
const stopAria = bind({ attr: { 'aria-expanded': () => String(open.value) } }, { target: trigger });
// Stop syncing when the trigger is replaced
onCleanup(stopAria);
});Binding a non-host element with bind()
Pass { target: el } as a second argument to bind attributes, classes, styles, or events to any element:
import { signal } from '@vielzeug/ripple';
import { bind, define, html, onMounted, ref } from '@vielzeug/ore';
define('button-wrapper', {
setup(_props) {
const visible = signal(false);
const btnRef = ref<HTMLButtonElement>();
onMounted(() => {
const btn = btnRef.value;
if (!btn) return;
bind(
{
attr: { 'aria-pressed': () => String(visible.value) },
on: { click: () => (visible.value = !visible.value) },
},
{ target: btn },
);
});
return html`<button ref=${btnRef}>Toggle</button>`;
},
});slots and emits
import { define, html, useEmit, useSlots, when } from '@vielzeug/ore';
define('card-with-footer', {
setup(_props) {
const slots = useSlots<'header' | 'footer'>();
const emit = useEmit<{ action: undefined }>();
return html`
<div class="card">
<slot name="header"></slot>
<slot></slot>
${when(slots.has('footer'), () => html`<footer><slot name="footer"></slot></footer>`)}
</div>
<button @click=${() => emit('action')}>Go</button>
`;
},
});Pass a SlotNames type parameter to useSlots<SlotNames>() to get typed slots.has() and slots.elements() calls.
context provide/inject
import { signal } from '@vielzeug/ripple';
import { createContext, define, html, injectStrict, provide } from '@vielzeug/ore';
const COUNT_CTX = createContext<ReturnType<typeof signal<number>>>('count');
define('count-provider', {
setup(_props) {
const count = signal(0);
provide(COUNT_CTX, count);
return html`<button @click=${() => count.value++}><slot></slot></button>`;
},
});
define('count-consumer', {
setup() {
const count = injectStrict(COUNT_CTX);
return html`<p>Count: ${count}</p>`;
},
});provide() registers cleanup automatically: context keys are removed from the registry when the providing component disconnects. On reconnect, setup() runs fresh and provide() re-registers without spurious "overwriting" warnings. Provide a Readable (signal/computed) rather than a raw value if descendants need to observe later changes: inject() resolves and caches the value once per consumer connection.
form-associated elements
import { signal } from '@vielzeug/ripple';
import { define, html, prop } from '@vielzeug/ore';
import { useField } from '@vielzeug/ore';
define('rating-input', {
formAssociated: true,
setup() {
const value = signal(0);
const field = useField({ value });
return html`
<button @click=${() => (value.value = 1)}>1</button>
<button @click=${() => (value.value = 2)}>2</button>
<button @click=${() => (value.value = 3)}>3</button>
<button @click=${() => field.reportValidity()}>Validate</button>
<p>Current: ${value}</p>
`;
},
});Sentinel Observers
Use @vielzeug/sentinel for subscribable browser and DOM observations. Create element-dependent Sentinels inside onMounted() and dispose them with the component.
import { define, html, onCleanup, onMounted, ref, watchEffect } from '@vielzeug/ore';
import { fromSubscribable } from '@vielzeug/ripple';
import { createElementSize, SentinelUnavailableError } from '@vielzeug/sentinel';
define('x-observed', {
setup(_props) {
const boxRef = ref<HTMLDivElement>();
onMounted(() => {
const element = boxRef.value;
if (!element) return;
try {
const size = createElementSize(element);
const sizeState = fromSubscribable(size, { signal: size.disposalSignal });
watchEffect(() => {
console.log(sizeState.value?.width);
});
onCleanup(() => size.dispose());
} catch (error) {
if (!(error instanceof SentinelUnavailableError)) throw error;
}
});
return html`<div ref=${boxRef}>Observe me</div>`;
},
});testing utilities
Import from @vielzeug/ore/testing.
import { afterEach, describe, expect, it } from 'vitest';
import { signal } from '@vielzeug/ripple';
import { fireClick } from '@vielzeug/assay';
import { html } from '@vielzeug/ore';
import { cleanup, mount } from '@vielzeug/ore/testing';
describe('my-counter', () => {
afterEach(cleanup);
it('increments on click', async () => {
let count!: ReturnType<typeof signal<number>>;
const { query, act } = await mount(() => {
count = signal(0);
return html`<button @click=${() => count.value++}>${count}</button>`;
});
expect(query('button')?.textContent).toBe('0');
await act(() => fireClick(query('button')!));
expect(query('button')?.textContent).toBe('1');
});
});Framework Integration
Ore components are standard custom elements and work natively in any framework.
// React 19+ supports custom elements natively.
import './x-toggle'; // wherever define('x-toggle', { ... }) is called
function App() {
return <x-toggle aria-label="Open menu" />;
}<script setup lang="ts">
import './x-toggle'; // wherever define('x-toggle', { ... }) is called
import { ref } from 'vue';
const open = ref(false);
</script>
<template>
<x-toggle :aria-label="'Open menu'" @click="open = !open" />
</template><script>
import './x-toggle'; // wherever define('x-toggle', { ... }) is called
function handleClick() {
console.log('toggled');
}
</script>
<x-toggle aria-label="Open menu" on:click={handleClick} />Working with Other Vielzeug Libraries
With Ripple
Import ripple primitives directly from @vielzeug/ripple for standalone reactive state outside components.
import { signal, computed } from '@vielzeug/ripple';
import { define, html } from '@vielzeug/ore';
// Shared state created outside any component
const theme = signal<'light' | 'dark'>('light');
const isDark = computed(() => theme.value === 'dark');
define('theme-toggle', {
setup() {
return html`
<button @click=${() => (theme.value = isDark.value ? 'light' : 'dark')}>
${() =>
isDark.value ? '<ore-icon name="sun" size="16"></ore-icon>' : '<ore-icon name="moon" size="16"></ore-icon>'}
</button>
`;
},
});With Forge
Use @vielzeug/forge for typed form state. useField() remains intentionally narrow: it connects a form-associated custom element to native ElementInternals without imposing submission, validation, or dirty-state policy.
import { createForm } from '@vielzeug/forge';
import { define, html } from '@vielzeug/ore';
define('signup-form', {
setup(_props) {
const form = createForm({ initialValues: { email: '' } });
return html`
<form
@submit=${(event: SubmitEvent) => {
event.preventDefault();
void form.submit(async (values) => {
console.log(values);
});
}}>
<slot></slot>
</form>
`;
},
});Best Practices
- Setup returns
html\...`` directly: not a function wrapping the template. - Use
watchEffect()for reactive subscriptions tied to component lifetime: it auto-registers cleanup on disconnect. - Use
onElement(ref, cb)instead ofonMountedwhen the work is tied to a single DOM node. - Bind host attributes and classes via
bind()rather than mutating the element directly. - Provide context at the nearest ancestor: avoid global context singletons.
- Call
onCleanup()for every resource allocated insetup()(WebSockets, intervals, external subscriptions). - Use
model(signal)for two-way form inputs; uselive(signal)for a one-way binding that must not clobber user-in-progress edits. - Extract composable helper functions freely:
onMounted/onCleanup/bind/... resolve the active component through implicit context, so they work from any function called (transitively) duringsetup(), with no need to pass them in as parameters. - Test component mounting and lifecycle with
@vielzeug/ore/testing; import generic DOM events, queries, and waits from@vielzeug/assay.