Basic Usage
define(tag, definition) registers a custom element and returns the tag name.
Your setup() function receives typed prop signals and a context bag, then returns an HTMLResult directly.
import { define, html, signal } from '@vielzeug/craft';
define('status-chip', {
setup() {
const online = signal(true);
return html`
<button @click=${() => (online.value = !online.value)}>${() => (online.value ? 'Online' : 'Offline')}</button>
`;
},
});The setup context bag provides el, bind, emit, slots, onMounted, onCleanup, onEvent, onElement, and effect:
define('my-widget', {
setup(_props, { el, bind, emit, slots }) {
// el — the host HTMLElement
// bind — host binding helper (attr, class, style, prop, on)
// emit — typed event emitter
// slots — reactive slot observation
return html`<slot></slot>`;
},
});signals and effects
Craft re-exports signal primitives from @vielzeug/ripple.
import { batch, computed, effect, signal, watch } from '@vielzeug/craft';
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 ctx.onMounted() for DOM-dependent initialization that must run after the template is mounted. Use ctx.onElement(ref, cb) for work tied to a specific DOM node. ctx.onEvent() attaches a listener that is automatically removed on disconnect.
import { define, html, ref, signal } from '@vielzeug/craft';
define('deferred-init', {
setup(_props, { slots, onMounted, onElement, onEvent }) {
const tabIndex = signal(0);
const inputRef = ref<HTMLInputElement>();
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/craft';
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, define, html, ref, signal } from '@vielzeug/craft';
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
Craft includes each, classMap, styleMap, when, live, and raw.
import { classMap, define, each, html, signal, styleMap, when } from '@vielzeug/craft';
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 key per item
- 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 { define, html, live, signal } from '@vielzeug/craft';
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
The setup context provides bind for wiring the host element.
import { define, html, signal } from '@vielzeug/craft';
define('x-toggle', {
setup(_props, { bind }) {
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, prop, and on sections. You can also call createBind(el) directly for advanced use cases outside setup context.
slots and emits
import { define, html, when } from '@vielzeug/craft';
define('card-with-footer', {
slots: ['header', 'footer'] as const,
setup(_props, { slots, emit }) {
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>
`;
},
});When slots is declared as a const array, TypeScript narrows slots.has() and slots.elements() to only accept declared names.
context provide/inject
import { createContext, define, html, injectStrict, signal } from '@vielzeug/craft';
const COUNT_CTX = createContext<ReturnType<typeof signal<number>>>('count');
define('count-provider', {
setup(_props, { provide }) {
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>`;
},
});form-associated elements
import { define, html, prop, signal, useField } from '@vielzeug/craft';
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>
`;
},
});async setup
When setup() returns a Promise<HTMLResult>, craft renders loading() immediately and swaps in the real template once the promise resolves. Use onError to handle failures gracefully.
import { define, html, prop } from '@vielzeug/craft';
define('user-profile', {
props: { userId: prop.string('') },
loading: () => html`<p>Loading…</p>`,
onError: (_err, el) => html`<p>Failed to load for ${el.getAttribute('user-id')}</p>`,
async setup(props) {
const user = await fetch(`/api/users/${props.userId.value}`).then((r) => r.json());
return html`<p>${user.name}</p>`;
},
});platform observers
Observer helpers from @vielzeug/craft/observers require real DOM nodes, so call them inside ctx.onMounted().
import { define, html, ref, watch } from '@vielzeug/craft';
import { intersectionObserver, mediaObserver, resizeObserver } from '@vielzeug/craft/observers';
define('x-observed', {
setup(_props, { onMounted }) {
const boxRef = ref<HTMLDivElement>();
onMounted(() => {
const element = boxRef.value;
if (!element) return;
const size = resizeObserver(element);
const visible = intersectionObserver(element, { threshold: 0.5 });
const dark = mediaObserver('(prefers-color-scheme: dark)');
watch([size, visible, dark], () => {
console.log(size.value.width, visible.value?.isIntersecting, dark.value);
});
});
return html`<div ref=${boxRef}>Observe me</div>`;
},
});testing utilities
Import from @vielzeug/craft/testing.
import { describe, expect, it } from 'vitest';
import { html, signal } from '@vielzeug/craft';
import { cleanup, fire, flush, mount, waitFor } from '@vielzeug/craft/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(() => fire.click(query('button')!));
expect(query('button')?.textContent).toBe('1');
});
});Framework Integration
Craft components are standard custom elements and work natively in any framework.
// React 19+ supports custom elements natively.
import '@vielzeug/sigil';
function App() {
return <x-toggle aria-label="Open menu" />;
}<script setup lang="ts">
import '@vielzeug/sigil';
import { ref } from 'vue';
const open = ref(false);
</script>
<template>
<x-toggle :aria-label="'Open menu'" @click="open = !open" />
</template><script>
import '@vielzeug/sigil';
function handleClick() {
console.log('toggled');
}
</script>
<x-toggle aria-label="Open menu" on:click={handleClick} />Working with Other Vielzeug Libraries
With Ripple
Craft re-exports core ripple primitives, but you can import ripple directly for standalone reactive state outside components.
import { signal, computed } from '@vielzeug/ripple';
import { define, html } from '@vielzeug/craft';
// 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 ? '<sg-icon name="sun" size="16"></sg-icon>' : '<sg-icon name="moon" size="16"></sg-icon>'}
</button>
`;
},
});With Forge
Use @vielzeug/forge for typed form state alongside Craft's useField() for form-associated elements.
import { createForm } from '@vielzeug/forge';
import { s } from '@vielzeug/spell';
import { createFormContext, define, html, FORM_CONTEXT_KEY } from '@vielzeug/craft';
define('signup-form', {
setup(_props, { provide }) {
const formCtx = createFormContext({
onSubmit: async (e) => {
e?.preventDefault();
// submit logic
},
});
provide(FORM_CONTEXT_KEY, formCtx);
return html`
<form @submit.prevent=${() => formCtx.submit()}>
<slot></slot>
</form>
`;
},
});Best Practices
- Setup returns
html\...`` directly — not a function wrapping the template. - Use
ctx.effect()for reactive subscriptions tied to component lifetime — it auto-registers cleanup on disconnect. - Use
ctx.onElement(ref, cb)instead ofctx.onMountedwhen the work is tied to a single DOM node. - Bind host attributes and classes via
ctx.bind()rather than mutating the element directly. - Provide context at the nearest ancestor — avoid global context singletons.
- Call
ctx.onCleanup()for every resource allocated insetup()(WebSockets, intervals, external subscriptions). - Use
live(signal)for form inputs to prevent clobbering user-in-progress edits. - Thread lifecycle hooks explicitly into composable helpers via function parameters — do not rely on implicit module-level context.
- Test with
@vielzeug/craft/testinghelpers (mount,flush,waitFor) rather than direct DOM manipulation.