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.
import { computed, signal } from '@vielzeug/ripple';
import { define, html, 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=${name}
aria-label=${() => 'Current name ' + name.value}
@input=${(event: Event) => {
name.value = (event.target as HTMLInputElement).value;
}} />
<p>Hello ${name}</p>
`;
},
});directives
Ore exports each, classMap, styleMap, when, live, and unsafeHtml from @vielzeug/ore. Use ordinary attribute bindings plus native event handlers for two-way input state; no special model directive is required.
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>`,
);live form bindings
Use live(signal) for inputs that should preserve in-progress user edits instead of overwriting the DOM on stale writes.
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
live(signal)for form inputs to prevent clobbering 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.