Skip to content

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.

ts
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):

ts
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.

ts
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.

ts
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.

ts
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.

ts
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.

ts
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 item and index signals
  • fallback — optional, rendered when the list is empty
ts
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.

ts
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.

ts
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.

ts
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:

ts
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:

ts
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

ts
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

ts
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

ts
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.

ts
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.

ts
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.

tsx
// React 19+ supports custom elements natively.
import './x-toggle'; // wherever define('x-toggle', { ... }) is called

function App() {
  return <x-toggle aria-label="Open menu" />;
}
ts
<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>
svelte
<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.

ts
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.

ts
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 of onMounted when 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 in setup() (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) during setup(), 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.