- ✨ Key Features
- 🏗️ Basic Component Structure
- 🔒 Props and Type Safety
- 🚀 Event Emission
- 🔁 Lifecycle Hooks
- 🧾 Working with HTML entities and raw HTML
- 🧬 Reactive State Management
- 🎛️ Directives and Bindings
- 🔀 Conditional Rendering and Lists
- 🎨 Styling
- 🔗 Component Communication
- ⚙️ Advanced Configuration
- 🚀 Advanced APIs
- ⏳ Async Components
- 🧪 Testing Components
- 🎯 Best Practices
- 🎉 Summary
The Custom Elements Runtime provides a powerful, intuitive functional component API that emphasizes simplicity, type safety, and developer ergonomics. This API automatically handles reactive props, type inference, and event emission without requiring complex configuration objects.
- 🔧 Zero Configuration - No complex setup required
- ⚡ Automatic Reactivity - All props are automatically reactive
- 🎯 Type Safety - Full TypeScript inference from function signatures
- 📦 Props - Props are provided via
useProps()with defaults and type inference. Avoid destructuring into local variables if you need reactivity — read from thepropsobject or usecomputed/watchfor derived reactive values. - 🚀 Strongly Typed Hooks - React-style hooks with perfect TypeScript inference
- 📝 Runtime logging toggle - The runtime exports a
setDevMode(boolean)helper so consumers can enable or disable development logs at runtime. Note: setting a global runtime flag in server-side environments is process-wide (per-process) and not per-request. - 🔄 Automatic Prop Parsing - Runtime extracts prop defaults from your
useProps()calls (via a short discovery render in the browser) and uses them to infer prop types and observed attributes - 💡 Intuitive API - Familiar patterns similar to modern React/Vue components
The functional API follows a simple, intuitive pattern using context-based hooks:
import {
component,
html,
css,
useProps,
useEmit,
useOnConnected,
useOnDisconnected,
useOnAttributeChanged,
useOnError,
useStyle,
ref,
computed,
watch,
} from '@jasonshimmy/custom-elements-runtime';
import {
when,
each,
match,
} from '@jasonshimmy/custom-elements-runtime/directives';
import { eventBus } from '@jasonshimmy/custom-elements-runtime/event-bus';
component('component-name', () => {
// Access reactive props via useProps hook
const props = useProps({ prop1: 'default', prop2: 0 });
// Get hooks with perfect TypeScript inference
const emit = useEmit();
// Set up lifecycle hooks
useOnConnected(() => console.log('Component connected!'));
useOnDisconnected(() => console.log('Component disconnected!'));
useOnAttributeChanged((name, oldValue, newValue) => {
console.log(`Attribute ${name} changed from ${oldValue} to ${newValue}`);
});
useOnError((error) => console.error('Component error:', error));
// Component logic and rendering
return html`<div>Your template</div>`;
});component(
tag: string,
renderFn: () => VNode | VNode[] | Promise<VNode | VNode[]>,
)All hooks must be called during component render and provide perfect TypeScript inference:
useProps(defaults): Get reactive props with default values and type inferencedefineModel(propName?, defaultValue?): Declare a two-way model binding (see defineModel)useEmit(): Get the emit function for dispatching custom eventsuseOnConnected(callback): Set up lifecycle hook for when component connects to DOMuseOnDisconnected(callback): Set up lifecycle hook for when component disconnects from DOMuseOnAttributeChanged(callback): Set up lifecycle hook for when attributes changeuseOnError(callback): Set up lifecycle hook for error handlinguseStyle(fn: () => string): void: Provide a reactive CSS string applied as the component's scoped stylesheet. Thefncallback runs during render; any reactive reads inside are tracked so styles update when dependencies change. See jit-css.md for usage with thecsstemplate helper.useExpose(api): Publish methods and properties onto the host element as an imperative public API. See useExpose().useSlots(): Inspect which named slots have been filled by the consumer. See useSlots().
defineModel is a single hook that replaces the useProps + useEmit boilerplate needed for two-way binding. It wraps the common pattern of accepting a prop and emitting its counterpart update event, following the same contract as Vue's defineModel().
import {
component,
html,
defineModel,
} from '@jasonshimmy/custom-elements-runtime';
component('my-input', () => {
// parent uses :model="ref"
const model = defineModel('');
return html`
<input :model="${model}" />
<span>${model.value}</span>
`;
});The parent binds with :model as usual:
<my-input :model="${searchText}"></my-input>component('my-field', () => {
// parent uses :model:title="ref"
const title = defineModel('title', '');
const count = defineModel('count', 0);
return html`
<input :model="${title}" />
<input type="number" :model="${count}" />
`;
});The parent binds with :model:propName:
<my-field :model:title="${heading}" :model:count="${qty}"></my-field>| Direction | Mechanism |
|---|---|
| Parent → Child | Parent passes a ref via :model; the child reads it through model.value |
| Child → Parent | Setting model.value = x dispatches update:modelValue (or update:<propName>) from the host element; the parent's :model listener picks it up |
The returned ModelRef is also recognised by the vdom :model directive, so you can pass it directly to native inputs inside the child template without any extra wiring.
// Default model — prop name is 'modelValue'
defineModel<T>(): ModelRef<T | undefined>
defineModel<T>(defaultValue: T): ModelRef<T>
// Named model
defineModel<T>(propName: string, defaultValue: T): ModelRef<T>ModelRef<T> is a plain object with a writable value accessor:
interface ModelRef<T> {
value: T; // get → reads current prop; set → emits update event
}Define your component props using the useProps() hook with default values. The runtime automatically creates reactive props with type inference:
component('user-card', () => {
const props = useProps({
name: 'Anonymous',
age: 0,
email: '',
isActive: true,
tags: [] as string[],
});
return html`
<div class="user-card">
<h3>${props.name}</h3>
<p>Age: ${props.age}</p>
<p>Email: ${props.email}</p>
<p>Status: ${props.isActive ? 'Active' : 'Inactive'}</p>
<ul>
${props.tags.map((tag) => html`<li>${tag}</li>`)}
</ul>
</div>
`;
});Implementation note: The runtime performs a lightweight "discovery render" in browser environments to detect
useProps()default values and populate the component config (includingobservedAttributes). This discovery step is skipped during SSR (nowindow), so defaults may be discovered on the first real render server-side. Always calluseProps()during the component render (not at module top-level) so the runtime can pick up defaults correctly.
<!-- Primitive values (strings, numbers, booleans) as attributes -->
<user-card
name="John Doe"
age="30"
email="john@example.com"
is-active="true"
></user-card>
<!-- Complex types (arrays/objects) should be passed as JS properties or bound via :bind
Attributes containing JSON strings are NOT automatically parsed into arrays/objects. -->
<script>
// Recommended: set as a JavaScript property after creating the element
const el = document.createElement('user-card');
el.name = 'John Doe';
el.tags = ['developer', 'typescript']; // pass an actual array
document.body.appendChild(el);
</script>The runtime only performs automatic attribute-to-prop conversion for primitive types (String, Number, Boolean). If you declare a prop with a complex default (for example tags: [] as string[]), attribute values remain raw strings — they are not automatically parsed as JSON into arrays or objects.
Recommended patterns:
- Pass complex values as actual JavaScript properties on the element (preferred).
- Use the runtime's
:bind/property-binding mechanisms from a parent component so the child receives real JS values. - If you must accept a JSON string via an attribute, explicitly parse it inside your component and handle errors gracefully.
Example: safe parsing fallback inside a component
component('user-card', () => {
const props = useProps({ name: 'Anonymous', tags: [] as string[] });
// Ensure tags is an array whether it came from a property or an attribute string
function parseTags(raw: unknown): string[] {
if (Array.isArray(raw)) return raw as string[];
if (typeof raw === 'string' && raw.trim()) {
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
return [];
}
const tags = parseTags(props.tags as unknown);
return html`
<div>
<h3>${props.name}</h3>
<ul>
${tags.map((t) => html`<li>${t}</li>`)}
</ul>
</div>
`;
});The runtime automatically:
- ✅ Extracts default values from useProps defaults object
- ✅ Infers prop types from default values and TypeScript annotations
- ✅ Creates reactive Proxy for all props with automatic dependency tracking
- ✅ Converts attribute names (kebab-case) to prop names (camelCase)
- ✅ Handles type conversion (String, Number, Boolean)
Note about destructuring props
⚠️ If you destructure props (for exampleconst { foo } = useProps({ foo: 0 })) you receive a copy of the current value. That local variable will not stay reactive — updates to the prop will not update the previously-destructured variable. To keep reactivity, access props via the returnedpropsobject (for exampleprops.foo) or usecomputed/watchto derive reactive values.
Use the useEmit() hook to get a strongly typed emit function:
component('interactive-button', () => {
const props = useProps({ label: 'Click me', disabled: false });
const emit = useEmit();
const handleClick = () => {
if (!props.disabled) {
// Emit with type safety
emit('button-clicked', { timestamp: Date.now(), label: props.label });
emit('custom-event', { data: 'some data' });
}
};
return html`
<button :disabled="${props.disabled}" @click="${handleClick}">
${props.label}
</button>
`;
});// In another component or JavaScript
const button = document.querySelector('interactive-button');
button.addEventListener('button-clicked', (event) => {
console.log('Button clicked:', event.detail);
});The functional API provides lifecycle hooks through context-based hooks, allowing you to respond to component lifecycle events:
component('lifecycle-demo', () => {
const emit = useEmit();
const props = useProps({ data: [] });
// Set up lifecycle hooks
useOnConnected(() => {
console.log('Component mounted to DOM');
// Initialize external resources, start timers, etc.
emit('component-ready');
});
useOnDisconnected(() => {
console.log('Component removed from DOM');
// Clean up resources, stop timers, etc.
emit('component-destroyed');
});
useOnAttributeChanged((name, oldValue, newValue) => {
console.log(
`Attribute '${name}' changed from '${oldValue}' to '${newValue}'`,
);
if (name === 'data') {
emit('data-attribute-changed', { oldValue, newValue });
}
});
useOnError((error) => {
console.error('Component error:', error);
emit('component-error', { error: error.message });
});
return html`
<div class="lifecycle-demo">
<h3>Lifecycle Demo</h3>
<p>Data items: ${props.data.length}</p>
<ul>
${props.data.map((item) => html`<li>${item}</li>`)}
</ul>
</div>
`;
});useOnConnected(callback): Called when the component is inserted into the DOMuseOnDisconnected(callback): Called when the component is removed from the DOMuseOnAttributeChanged(callback): Called when any attribute on the element changesuseOnError(callback): Called when an error occurs during rendering or lifecycle events
// Example with external API integration
component('api-data', () => {
const props = useProps({ endpoint: '/api/data' });
const emit = useEmit();
const data = ref(null);
const loading = ref(false);
let abortController: AbortController | null = null;
const fetchData = async () => {
abortController = new AbortController();
loading.value = true;
try {
const response = await fetch(props.endpoint, {
signal: abortController.signal,
});
data.value = await response.json();
emit('data-loaded', data.value);
} catch (error) {
if (error.name !== 'AbortError') {
emit('data-error', error);
}
} finally {
loading.value = false;
}
};
useOnConnected(() => {
fetchData();
});
useOnDisconnected(() => {
if (abortController) {
abortController.abort();
}
});
useOnError((error) => {
console.error('API component error:', error);
loading.value = false;
});
return html`
<div class="api-data">
${when(loading.value, html`<p>Loading...</p>`)}
${when(
data.value,
html` <pre>${JSON.stringify(data.value, null, 2)}</pre> `,
)}
</div>
`;
});The runtime escapes interpolated values by default to keep the DOM safe from XSS. Two utilities are available when you need finer control:
decodeEntities(str)— a small helper to decode common HTML entities (e.g.<,>,&, numeric references) into their character equivalents.unsafeHTML(htmlString)— an opt-in marker for inserting raw HTML into the template. Use this only with trusted HTML.
- Use
decodeEntitieswhen you receive encoded HTML-like strings from a trusted source and want to display the decoded text in a text node (without interpreting it as markup). - Use
unsafeHTMLonly when you control or sanitize the HTML yourself and intentionally want the runtime to parse and insert DOM nodes from an HTML string.
import {
html,
unsafeHTML,
decodeEntities,
} from '@jasonshimmy/custom-elements-runtime';
// Literal entity decoding inside template text (the compiler decodes literal template text automatically)
const vnode = html`<p>This template literal contains <escaped> text</p>`;
// Interpolated values are preserved as-is. If you receive an encoded string, decode explicitly:
const encoded = '<3 & hi';
const decoded = decodeEntities(encoded); // '<3 & hi'
const vnode2 = html`<p>${decoded}</p>`; // renders text '<3 & hi'
// Insert raw HTML (opt-in):
const raw = '<b>Important</b> <i>note</i>';
const vnode3 = html`<div>${unsafeHTML(raw)}</div>`; // inserts <b> and <i> elements as nodesInserting or rendering raw HTML can open your application to XSS vulnerabilities. The runtime never inserts raw HTML unless you explicitly opt into it using unsafeHTML. Always sanitize or otherwise validate any HTML that originates from users or untrusted sources before passing it to unsafeHTML.
If you only want to display encoded HTML-like text (for example, to show <script> literally in UI), prefer decoding with decodeEntities and rendering as plain text nodes, not raw HTML.
Create reactive state outside components that can be shared:
// Shared reactive state
const userState = ref({
name: 'John',
email: 'john@example.com',
preferences: {
theme: 'dark',
notifications: true,
},
});
// Computed values
const displayName = computed(() => userState.value.name || 'Anonymous');
// Watchers
watch(
() => userState.value.email,
(newEmail, oldEmail) => {
console.log(`Email changed from ${oldEmail} to ${newEmail}`);
},
);
// Components can access and modify shared state
component('user-profile', () => {
const emit = useEmit();
const updateName = (newName: string) => {
userState.value.name = newName;
emit('name-updated', { name: newName });
};
return html`
<div>
<h2>${displayName.value}</h2>
<input
type="text"
:value="${userState.value.name}"
@input="${(e) => updateName(e.target.value)}"
placeholder="Enter your name"
/>
<p>Email: ${userState.value.email}</p>
</div>
`;
});For component-specific state, create reactive state within the component:
component('counter', () => {
const props = useProps({ initialValue: 0, step: 1 });
const emit = useEmit();
// Component-scoped reactive state
const count = ref(props.initialValue);
const increment = () => {
count.value += props.step;
emit('count-changed', { count: count.value });
};
const decrement = () => {
count.value -= props.step;
emit('count-changed', { count: count.value });
};
return html`
<div class="counter">
<button @click="${decrement}">-</button>
<span class="count">${count.value}</span>
<button @click="${increment}">+</button>
</div>
`;
});The streamlined API works seamlessly with all existing directives and bindings:
Bind reactive values to element properties and attributes:
component('dynamic-input', () => {
const emit = useEmit();
const props = useProps({
type: 'text',
value: '',
placeholder: 'Enter text...',
disabled: false,
});
return html`
<input
:type="${props.type}"
:value="${props.value}"
:placeholder="${props.placeholder}"
:disabled="${props.disabled}"
@input="${(e) => emit('input', e.target.value)}"
/>
`;
});For complex objects, functions, or when you need to ensure JavaScript property assignment:
component('complex-props', () => {
const emit = useEmit();
const props = useProps({
config: {},
items: [] as any[],
onItemClick: null as ((item: any) => void) | null,
});
return html`
<custom-element
:bind="${{
config: props.config,
items: props.items,
onItemClick:
props.onItemClick || ((item) => emit('item-clicked', item)),
}}"
></custom-element>
`;
});Note: :bind prefers to assign values as JavaScript properties (not attributes) so complex objects and functions are passed as real JS values to the child. A few caveats: data-*, aria-*, and class keys remain attributes for reliable HTML serialization; native controls (inputs/selects/textareas/buttons) treat disabled specially and may prefer attribute semantics unless the value is clearly boolean or a reactive/wrapper; if property assignment throws the runtime will fall back to setAttribute() with a serialized value. :bind also accepts a string expression (evaluated in the render context) which can produce an object to be bound the same way.
Dynamic class management with object and array syntax:
component('status-card', () => {
const props = useProps({
status: 'normal',
size: 'medium',
interactive: false,
});
const emit = useEmit();
return html`
<div
:class="${{
'status-card': true,
[`status-${props.status}`]: true,
[`size-${props.size}`]: true,
interactive: props.interactive,
clickable: props.interactive,
}}"
@click="${props.interactive ? () => emit('card-clicked') : null}"
>
<slot></slot>
</div>
`;
});Dynamic inline styles with object and string syntax:
component('progress-bar', () => {
const props = useProps({
progress: 0,
color: '#007bff',
height: '8px',
animated: false,
});
return html`
<div class="progress-container" :style="${{ height: props.height }}">
<div
class="progress-bar"
:class="${{ animated: props.animated }}"
:style="${{
width: `${Math.min(100, Math.max(0, props.progress))}%`,
backgroundColor: props.color,
transition: props.animated ? 'width 0.3s ease' : 'none',
}}"
></div>
</div>
`;
});The :model directive provides automatic two-way data binding for form elements. With the functional API, you can bind directly to reactive state objects:
component('form-field', () => {
const emit = useEmit();
const props = useProps({
label: 'Name',
type: 'text',
initialValue: '',
});
const value = ref(props.initialValue);
watch(value, (newVal) => emit('value-changed', newVal));
return html`
<label class="form-field">
${props.label}
<input type="${props.type}" :model="${value}" />
<p>Current value: ${value.value}</p>
</label>
`;
});For custom elements that need to work with parent components using props:
component('controlled-input', () => {
const emit = useEmit();
const props = useProps({
modelValue: '',
label: '',
});
return html`
<label class="controlled-input">
${props.label}
<input
value="${props.modelValue}"
@input="${(e: Event) =>
emit('update:modelValue', (e.target as HTMLInputElement).value)}"
/>
</label>
`;
});Custom property binding for complex components using reactive state:
component('multi-select', () => {
const emit = useEmit();
const props = useProps({
options: [] as { label: string; value: string }[],
selectedValues: [] as string[],
});
const isSelected = (value: string) => props.selectedValues.includes(value);
const toggleSelection = (value: string) => {
const newSelection = isSelected(value)
? props.selectedValues.filter((item: string) => item !== value)
: [...props.selectedValues, value];
// Emit update event so parent can update selectedValues via :model or binding
emit('update:selectedValues', newSelection);
emit('selection-changed', newSelection);
};
return html`
<div class="multi-select">
${each(
props.options,
(option) => html`
<label class="option">
<input
type="checkbox"
checked="${isSelected(option.value)}"
@change="${() => toggleSelection(option.value)}"
/>
${option.label}
</label>
`,
)}
<p>Selected: ${props.selectedValues.join(', ')}</p>
</div>
`;
});Comprehensive event handling with modifiers:
component('event-demo', () => {
const props = useProps({ disabled: false });
const emit = useEmit();
const handleKeydown = (e: KeyboardEvent) => {
if (e.key === 'Enter') {
emit('enter-pressed', { value: (e.target as HTMLInputElement).value });
} else if (e.key === 'Escape') {
emit('escape-pressed');
}
};
const handleSubmit = (e: Event) => {
e.preventDefault();
if (!props.disabled) {
const formData = new FormData(e.target as HTMLFormElement);
emit('form-submitted', Object.fromEntries(formData));
}
};
return html`
<form @submit="${handleSubmit}">
<input
type="text"
:disabled="${props.disabled}"
@keydown="${handleKeydown}"
@focus="${() => emit('input-focused')}"
@blur="${() => emit('input-blurred')}"
@input="${(e) => emit('input-changed', e.target.value)}"
/>
<button
type="submit"
:disabled="${props.disabled}"
@click="${() => emit('submit-clicked')}"
>
Submit
</button>
</form>
`;
});Access DOM elements directly:
component('focusable-input', () => {
const props = useProps({ autoFocus: false });
const emit = useEmit();
let inputRef: HTMLInputElement | null = null;
const focusInput = () => {
if (inputRef) {
inputRef.focus();
emit('input-focused');
}
};
const clearInput = () => {
if (inputRef) {
inputRef.value = '';
inputRef.focus();
emit('input-cleared');
}
};
return html`
<div class="input-container">
<input
:ref="${(el) => {
inputRef = el;
if (props.autoFocus) el.focus();
}}"
type="text"
@input="${(e) => emit('input-changed', e.target.value)}"
/>
<button @click="${focusInput}">Focus</button>
<button @click="${clearInput}">Clear</button>
</div>
`;
});-
Supported forms:
- Reactive ref objects (e.g.
const r = ref(null)) — the runtime will assign the element tor.value. - Callback refs (functions) — the runtime will call the function with the element when assigned.
- String refs (legacy) — the runtime stores the element in the component's internal
refsmap asrefs['name'] = element.
- Reactive ref objects (e.g.
-
Lifecycle & cleanup:
- Reactive refs are assigned during render and are available by the time connected hooks (for example
useOnConnected) run. They are not automatically nulled on unmount — clear them yourself in a disconnect hook if you neednullto indicate cleanup. - String refs are removed from the internal
refsmap when nodes are cleaned up by the runtime. - Callback refs are invoked on assignment but are not automatically called with
nullon cleanup.
- Reactive refs are assigned during render and are available by the time connected hooks (for example
-
Browser-only:
:refonly makes sense in a DOM environment — on the server there is no element to assign.
component('conditional-content', () => {
const props = useProps({
isLoggedIn: false,
userRole: 'guest',
showAdvanced: false,
});
const emit = useEmit();
return html`
<div>
${when(
props.isLoggedIn,
html`
<h2>Welcome back!</h2>
<p>Role: ${props.userRole}</p>
${when(
props.userRole === 'admin',
html`
<button @click="${() => emit('admin-action')}">
Admin Panel
</button>
`,
)}
${when(
props.showAdvanced,
html`
<div class="advanced-settings">
<h3>Advanced Settings</h3>
<!-- Advanced content -->
</div>
`,
)}
`,
)}
${when(
!props.isLoggedIn,
html`
<div class="login-prompt">
<h2>Please log in</h2>
<button @click="${() => emit('login-requested')}">Login</button>
</div>
`,
)}
</div>
`;
});Note: The
whendirective only accepts a condition and content. For if/else logic, use two separatewhencalls or thematchdirective below.
If your conditional content includes expressions that are expensive or that may throw when evaluated, prefer the lazy factory overload which defers building the child VNode(s) until the condition becomes truthy:
// Use a factory to avoid evaluating `expensive()` while `isVisible` is falsy
${when(isVisible, () => html`<div>${expensive()}</div>`) }Key points:
- This behavior is implemented entirely at runtime. There is no compile-time transform required or used.
- Existing code using
when(cond, html...)continues to work. Switch to the factory form when you need guarded evaluation. - The factory will only be executed when the condition is truthy. The runtime ensures stable anchor blocks so DOM updates remain predictable.
component('safe-render', () => {
const props = useProps({ jsonText: '' });
return html`
${when(props.jsonText, () => {
// parse may throw — run only when jsonText is present
const data = JSON.parse(props.jsonText);
return html`<pre>${JSON.stringify(data, null, 2)}</pre>`;
})}
`;
});The runtime preserves intentional falsy children inside conditional blocks. Values like 0, false, and '' are valid child nodes and will be rendered. Only null and undefined are treated as absent children and are filtered out when normalizing anchor block children.
component('status-indicator', () => {
const props = useProps({
status: 'pending' as 'pending' | 'success' | 'error' | 'warning',
message: '',
});
const emit = useEmit();
return html`
<div class="status-indicator">
${match()
.when(
props.status === 'pending',
html`
<div class="pending">
<span class="icon">⏳</span>
<span>Processing...</span>
</div>
`,
)
.when(
props.status === 'success',
html`
<div class="success">
<span class="icon">✅</span>
<span>Success: ${props.message}</span>
</div>
`,
)
.when(
props.status === 'error',
html`
<div class="error">
<span class="icon">❌</span>
<span>Error: ${props.message}</span>
<button @click="${() => emit('retry')}">Retry</button>
</div>
`,
)
.when(
props.status === 'warning',
html`
<div class="warning">
<span class="icon">⚠️</span>
<span>Warning: ${props.message}</span>
</div>
`,
)
.otherwise(html`
<div class="unknown">
<span>Unknown status: ${props.status}</span>
</div>
`)
.done()}
</div>
`;
});match() supports the same runtime factory pattern as when. Provide a factory to defer creating branch content until the branch is selected:
const node = match()
.when(condA, () => html`<div>${expensiveA()}</div>`) // not called until condA is truthy
.when(condB, html`<div>simple</div>`) // pre-built
.done();component('todo-list', () => {
const props = useProps({
todos: [] as Array<{ id: string; text: string; completed: boolean }>,
filter: 'all' as 'all' | 'active' | 'completed',
});
const emit = useEmit();
const filteredTodos = computed(() => {
switch (props.filter) {
case 'active':
return props.todos.filter((todo) => !todo.completed);
case 'completed':
return props.todos.filter((todo) => todo.completed);
default:
return props.todos;
}
});
const toggleTodo = (id: string) => {
emit('toggle-todo', { id });
};
const deleteTodo = (id: string) => {
emit('delete-todo', { id });
};
return html`
<div class="todo-list">
<div class="filters">
<button
:class="${{ active: props.filter === 'all' }}"
@click="${() => emit('filter-changed', 'all')}"
>
All
</button>
<button
:class="${{ active: props.filter === 'active' }}"
@click="${() => emit('filter-changed', 'active')}"
>
Active
</button>
<button
:class="${{ active: props.filter === 'completed' }}"
@click="${() => emit('filter-changed', 'completed')}"
>
Completed
</button>
</div>
<ul class="todo-items">
${each(
filteredTodos.value,
(todo) => html`
<li key="${todo.id}" :class="${{ completed: todo.completed }}">
<input
type="checkbox"
:checked="${todo.completed}"
@change="${() => toggleTodo(todo.id)}"
/>
<span class="todo-text">${todo.text}</span>
<button class="delete-btn" @click="${() => deleteTodo(todo.id)}">
Delete
</button>
</li>
`,
)}
</ul>
${when(
filteredTodos.value.length === 0,
html` <p class="empty-state">No todos found</p> `,
)}
</div>
`;
});import {
component,
html,
css,
useStyle,
} from '@jasonshimmy/custom-elements-runtime';
component('styled-button', () => {
const props = useProps({
variant: 'primary' as 'primary' | 'secondary' | 'danger',
size: 'medium' as 'small' | 'medium' | 'large',
});
const emit = useEmit();
useStyle(
() => css`
:host {
display: inline-block;
}
.btn {
border: none;
border-radius: 4px;
cursor: pointer;
font-family: inherit;
transition: all 0.2s ease;
}
.btn-small {
padding: 4px 8px;
font-size: 12px;
}
.btn-medium {
padding: 8px 16px;
font-size: 14px;
}
.btn-large {
padding: 12px 24px;
font-size: 16px;
}
.btn-primary {
background: #007bff;
color: white;
}
.btn-secondary {
background: #6c757d;
color: white;
}
.btn-danger {
background: #dc3545;
color: white;
}
.btn:hover {
opacity: 0.9;
transform: translateY(-1px);
}
`,
);
return html`
<button
class="btn btn-${props.variant} btn-${props.size}"
@click="${() => emit('click')}"
>
<slot></slot>
</button>
`;
});component('themed-card', () => {
const props = useProps({
theme: 'light' as 'light' | 'dark',
accentColor: '#007bff',
});
const emit = useEmit();
useStyle(
() => css`
:host {
display: block;
--accent-color: ${props.accentColor};
--bg-color: ${props.theme === 'dark' ? '#2d3748' : '#ffffff'};
--text-color: ${props.theme === 'dark' ? '#e2e8f0' : '#2d3748'};
--border-color: ${props.theme === 'dark' ? '#4a5568' : '#e2e8f0'};
}
.card {
background: var(--bg-color);
color: var(--text-color);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.card-header {
padding: 16px;
border-bottom: 1px solid var(--border-color);
background: linear-gradient(
135deg,
var(--accent-color),
color-mix(in srgb, var(--accent-color) 80%, white)
);
color: white;
}
.card-content {
padding: 16px;
}
`,
);
return html`
<div class="card">
<div class="card-header">
<slot name="header"></slot>
</div>
<div class="card-content">
<slot></slot>
</div>
</div>
`;
});The useStyle hook provides a powerful way to apply styles reactively based on component props and state:
- 🔄 Reactive Styling: Styles automatically update when props or state change
- 🎯 Type Safety: Full TypeScript inference for CSS values
- ⚡ Performance: Efficient updates only when dependencies change
- 🧹 Scoped: Styles are automatically scoped to the component
- 💡 Intuitive: Familiar CSS template literal syntax
Implementation notes
useStyleis a render-time hook: the callback you provide is executed during component render so any reactive reads inside will be tracked. The callback should return a CSS string (commonly produced via thecsstemplate helper). The runtime stores that computed CSS string on the component render context and applies it as part of the component's stylesheet update step.- The runtime prefers to apply styles using constructable
CSSStyleSheet(adoptedStyleSheets) when available and falls back to injecting a single<style data-cer-runtime>element in the shadow root for environments that don't support constructable stylesheets (or when creation fails). Tests and some environments may also see a stubbedadoptedStyleSheetsarray for compatibility. - The runtime no longer expects or supports a
useStyleflavor that receives the host element as an argument (i.e.useStyle((el) => ...)). The canonical API isuseStyle(() => css...)which returns a CSS string. - SSR / non-DOM environments:
useStylestill runs during server-side discovery or render when possible, but constructable stylesheet behavior is platform-dependent. The runtime uses safe fallbacks to avoid throwing in SSR.
component('styled-component', () => {
const props = useProps({ color: 'blue' });
useStyle(
() => css`
:host {
background-color: ${props.color};
border: 1px solid ${props.color};
}
`,
);
return html`<div>Styled content</div>`;
});component('interactive-button', () => {
const props = useProps({ initialColor: 'blue' });
const buttonState = ref({ color: props.initialColor, hovered: false });
useStyle(
() => css`
:host {
--button-color: ${buttonState.value.color};
--opacity: ${buttonState.value.hovered ? '0.8' : '1'};
}
.button {
background: var(--button-color);
opacity: var(--opacity);
transition: all 0.2s ease;
border: none;
padding: 12px 24px;
border-radius: 4px;
color: white;
cursor: pointer;
}
`,
);
return html`
<button
class="button"
@mouseenter="${() =>
(buttonState.value = { ...buttonState.value, hovered: true })}"
@mouseleave="${() =>
(buttonState.value = { ...buttonState.value, hovered: false })}"
@click="${() =>
(buttonState.value = {
...buttonState.value,
color: buttonState.value.color === 'blue' ? 'green' : 'blue',
})}"
>
Toggle Color
</button>
`;
});component('adaptive-card', () => {
const props = useProps({
theme: 'light' as 'light' | 'dark',
size: 'medium' as 'small' | 'medium' | 'large',
highlighted: false,
});
const cardState = ref({ expanded: false });
useStyle(() => {
const isDark = props.theme === 'dark';
const isLarge = props.size === 'large';
const isHighlighted = props.highlighted;
return css`
:host {
display: block;
transition: all 0.3s ease;
transform: ${cardState.value.expanded ? 'scale(1.02)' : 'scale(1)'};
}
.card {
background: ${isDark ? '#2d3748' : '#ffffff'};
color: ${isDark ? '#e2e8f0' : '#2d3748'};
border: 2px solid
${isHighlighted ? '#007bff' : isDark ? '#4a5568' : '#e2e8f0'};
border-radius: ${isLarge ? '12px' : '8px'};
padding: ${isLarge ? '24px' : '16px'};
box-shadow: ${isHighlighted
? '0 4px 12px rgba(0,123,255,0.3)'
: '0 2px 4px rgba(0,0,0,0.1)'};
font-size: ${isLarge ? '18px' : '14px'};
}
.card:hover {
box-shadow: ${isHighlighted
? '0 6px 20px rgba(0,123,255,0.4)'
: '0 4px 8px rgba(0,0,0,0.2)'};
}
`;
});
return html`
<div
class="card"
@click="${() =>
(cardState.value = {
...cardState.value,
expanded: !cardState.value.expanded,
})}"
>
<slot></slot>
</div>
`;
});- Use CSS Custom Properties: For complex themes and design tokens
- Leverage Template Literals: For dynamic CSS values
- Keep Logic Simple: Extract complex styling logic to separate functions
- Performance: useStyle is reactive - styles update only when dependencies change
// Good: Using CSS custom properties for consistency
component('themed-component', () => {
const props = useProps({ primaryColor: '#007bff' });
useStyle(
() => css`
:host {
--primary: ${props.primaryColor};
--primary-hover: color-mix(in srgb, var(--primary) 80%, black);
--primary-light: color-mix(in srgb, var(--primary) 20%, white);
}
.button {
background: var(--primary);
}
.button:hover {
background: var(--primary-hover);
}
.badge {
background: var(--primary-light);
}
`,
);
});// Child component
component('form-input', () => {
const props = useProps({
label: '',
value: '',
type: 'text',
required: false,
error: '',
});
const emit = useEmit();
const handleInput = (e: Event) => {
const input = e.target as HTMLInputElement;
emit('update:value', input.value);
// Emit validation event
if (props.required && !input.value.trim()) {
emit('validation-error', 'This field is required');
} else {
emit('validation-success');
}
};
return html`
<div class="form-group">
<label class="form-label">
${props.label}
${when(props.required, html`<span class="required">*</span>`)}
</label>
<input
:type="${props.type}"
:value="${props.value}"
:required="${props.required}"
:class="${{ error: !!props.error }}"
@input="${handleInput}"
@blur="${handleInput}"
/>
${when(
props.error,
html` <span class="error-message">${props.error}</span> `,
)}
</div>
`;
});
// Parent component
component('contact-form', () => {
const emit = useEmit();
const formData = ref({
name: '',
email: '',
message: '',
});
const errors = ref({
name: '',
email: '',
message: '',
});
const handleInputChange = (field: string, value: string) => {
formData.value[field] = value;
// Clear error when user starts typing
if (errors.value[field]) {
errors.value[field] = '';
}
};
const handleValidationError = (field: string, error: string) => {
errors.value[field] = error;
};
const submitForm = () => {
// Validate all fields
const hasErrors = Object.values(errors.value).some((error) => error);
if (!hasErrors) {
emit('form-submitted', formData.value);
}
};
return html`
<form
@submit="${(e) => {
e.preventDefault();
submitForm();
}}"
>
<form-input
label="Name"
:value="${formData.value.name}"
required="true"
:error="${errors.value.name}"
@update:value="${(value) => handleInputChange('name', value)}"
@validation-error="${(error) => handleValidationError('name', error)}"
@validation-success="${() => handleValidationError('name', '')}"
></form-input>
<form-input
label="Email"
type="email"
:value="${formData.value.email}"
required="true"
:error="${errors.value.email}"
@update:value="${(value) => handleInputChange('email', value)}"
@validation-error="${(error) => handleValidationError('email', error)}"
@validation-success="${() => handleValidationError('email', '')}"
></form-input>
<form-input
label="Message"
:value="${formData.value.message}"
required="true"
:error="${errors.value.message}"
@update:value="${(value) => handleInputChange('message', value)}"
@validation-error="${(error) =>
handleValidationError('message', error)}"
@validation-success="${() => handleValidationError('message', '')}"
></form-input>
<button type="submit">Send Message</button>
</form>
`;
});// Global state store
const appState = ref({
user: null,
notifications: [],
theme: 'light',
});
// Global actions
const userActions = {
login: (userData: any) => {
appState.value.user = userData;
eventBus.emit('user:login', userData);
},
logout: () => {
appState.value.user = null;
eventBus.emit('user:logout');
},
addNotification: (notification: any) => {
appState.value.notifications.push({
id: Date.now(),
...notification,
});
},
};
// Components can access global state
component('user-avatar', () => {
const emit = useEmit();
return html`
<div class="user-avatar">
${when(
appState.value.user,
html`
<img
src="${appState.value.user.avatar}"
alt="${appState.value.user.name}"
@click="${() => emit('profile-clicked')}"
/>
<span>${appState.value.user.name}</span>
`,
)}
${when(
!appState.value.user,
html`
<button @click="${() => emit('login-requested')}">Login</button>
`,
)}
</div>
`;
});
component('notification-center', () => {
const emit = useEmit();
const dismissNotification = (id: number) => {
appState.value.notifications = appState.value.notifications.filter(
(n) => n.id !== id,
);
};
return html`
<div class="notification-center">
${when(
appState.value.notifications.length > 0,
html`
<div class="notifications">
${each(
appState.value.notifications,
(notification) => html`
<div
key="${notification.id}"
class="notification notification-${notification.type}"
>
<span>${notification.message}</span>
<button
@click="${() => dismissNotification(notification.id)}"
>
×
</button>
</div>
`,
)}
</div>
`,
)}
</div>
`;
});The functional API uses hooks for all component features:
component('advanced-component', () => {
const props = useProps({ data: [] as any[] });
const emit = useEmit();
// Set up lifecycle hooks
useOnConnected(() => {
console.log('Component connected to DOM');
});
useOnDisconnected(() => {
console.log('Component disconnected from DOM');
});
useOnError((error: Error) => {
console.error('Component error:', error);
});
// Apply custom styling
useStyle(
() => css`
:host {
display: block;
padding: 16px;
}
`,
);
return html`<div>${props.data.length} items</div>`;
});defineAsyncComponent registers a custom element whose render function is loaded
asynchronously. Use it for heavy components that should not block the initial render.
import { defineAsyncComponent, html } from '@jasonshimmy/custom-elements-runtime';
defineAsyncComponent(
'heavy-chart',
() => import('./chart-impl').then(m => m.renderFn),
{
loading: () => html`<p>Loading chart…</p>`, // shown while pending
error: () => html`<p>Chart unavailable.</p>`, // shown on failure
timeout: 5000, // ms before showing error
},
)The element transitions through four states:
| State | Description |
|---|---|
idle / loading |
Loader promise is pending. The loading template is rendered if provided. |
resolved |
Loader fulfilled. The returned render function is called. |
error / timeout |
Loader rejected or timeout exceeded. The error template is rendered. |
Loader return value: The promise must resolve with a render function () => VNode | VNode[].
The render function runs inside the component's reactive context — useProps, ref, etc.
are all available.
Timeout: If timeout is set and the loader does not resolve within that many
milliseconds the element moves to the error state and renders the error template.
See async-components.md for the full API reference.
For simpler cases where you want to manage loading state yourself inside a
single component — without code-splitting — use a reactive ref pattern:
component('async-data', () => {
const props = useProps({ userId: '' });
const emit = useEmit();
const loading = ref(false);
const data = ref(null);
const error = ref(null);
const fetchData = async () => {
if (!props.userId) return;
loading.value = true;
error.value = null;
try {
const response = await fetch(`/api/users/${props.userId}`);
if (!response.ok) throw new Error('Failed to fetch user');
data.value = await response.json();
emit('data-loaded', data.value);
} catch (err) {
error.value = err.message;
emit('data-error', err);
} finally {
loading.value = false;
}
};
// Watch for userId changes
watch(() => props.userId, fetchData, { immediate: true });
return html`
<div class="async-data">
${when(
loading.value,
html` <div class="loading-spinner">Loading user data...</div> `,
)}
${when(
error.value,
html`
<div class="error-state">
<p>Error: ${error.value}</p>
<button @click="${fetchData}">Retry</button>
</div>
`,
)}
${when(
data.value && !loading.value,
html`
<div class="user-data">
<h2>${data.value.name}</h2>
<p>${data.value.email}</p>
<p>
Joined: ${new Date(data.value.createdAt).toLocaleDateString()}
</p>
</div>
`,
)}
</div>
`;
});The functional API is straightforward to test with any DOM-capable test runner (Vitest + happy-dom is the recommended pairing). See Testing Guide for a complete reference including lifecycle, async components, and event testing.
Key tips:
- Use
flushDOMUpdates()instead ofsetTimeoutfor deterministic, synchronous DOM flushing. - Register each component once — use a unique tag name per test or a shared
beforeAllsetup. - Query into
element.shadowRootto inspect rendered output.
import { describe, it, expect, beforeEach } from 'vitest';
import {
component,
html,
useProps,
useEmit,
flushDOMUpdates,
} from '@jasonshimmy/custom-elements-runtime';
describe('Streamlined Component', () => {
beforeEach(() => {
document.body.innerHTML = '';
});
it('should render with reactive props', () => {
component('test-component', () => {
const props = useProps({
message: 'default',
count: 0,
});
const emit = useEmit();
return html`
<div>
<span class="message">${props.message}</span>
<span class="count">${props.count}</span>
<button @click="${() => emit('increment')}">+</button>
</div>
`;
});
const element = document.createElement('test-component');
element.setAttribute('message', 'Hello World');
element.setAttribute('count', '5');
document.body.appendChild(element);
flushDOMUpdates();
const messageEl = element.shadowRoot?.querySelector('.message');
const countEl = element.shadowRoot?.querySelector('.count');
expect(messageEl?.textContent).toBe('Hello World');
expect(countEl?.textContent).toBe('5');
});
it('should emit events correctly', () => {
let emittedData = null;
component('emitter-test', () => {
const emit = useEmit();
return html`
<button @click="${() => emit('test-event', { data: 'test' })}">
Click
</button>
`;
});
const element = document.createElement('emitter-test');
element.addEventListener('test-event', (e) => {
emittedData = e.detail;
});
document.body.appendChild(element);
flushDOMUpdates();
const button = element.shadowRoot?.querySelector('button');
button?.click();
expect(emittedData).toEqual({ data: 'test' });
});
});// ✅ Good
component('user-badge', () => {
const props = useProps({
name: 'Anonymous',
role: 'user' as 'user' | 'admin' | 'moderator',
showAvatar: true,
size: 'medium' as 'small' | 'medium' | 'large',
});
const emit = useEmit();
// ...
});
// ❌ Avoid
component('user-badge', () => {
// Hard to understand
const props = useProps({ a: '', b: '', c: '' });
});// ✅ Good - Shared state
const userPreferences = ref({
theme: 'light',
language: 'en',
});
component('theme-switcher', () => {
const emit = useEmit();
// Use shared state
});
component('language-selector', () => {
const emit = useEmit();
// Use same shared state
});// ✅ Good
const items = ref([]);
const filteredItems = computed(() => items.value.filter((item) => item.active));
const itemCount = computed(() => filteredItems.value.length);
// ❌ Avoid - Manual synchronization
const items = ref([]);
const itemCount = ref(0);
// Manually updating itemCount everywhere items changes// ✅ Good - Semantic event names
emit('user-selected', { userId: 123 });
emit('form-submitted', { formData });
emit('validation-failed', { errors });
// ❌ Avoid - Generic event names
emit('click', data);
emit('change', data);
emit('event', data);component('data-loader', () => {
const props = useProps({ url: '' });
const emit = useEmit();
const loading = ref(false);
const error = ref(null);
const loadData = async () => {
try {
loading.value = true;
error.value = null;
// Load data...
} catch (err) {
error.value = err.message;
emit('load-error', err);
} finally {
loading.value = false;
}
};
return html`
${when(
error.value,
html`
<div class="error">
Error: ${error.value}
<button @click="${loadData}">Retry</button>
</div>
`,
)}
<!-- Rest of template -->
`;
});The streamlined functional component API provides:
- 🎯 Zero Configuration - No complex setup required
- ⚡ Automatic Reactivity - Props are reactive by default
- 🔒 Type Safety - Full TypeScript support with inference
- 🚀 Better Performance - Optimized prop parsing and reactivity
- 💡 Developer Experience - Intuitive, familiar patterns
- 🔄 Full Feature Support - All directives, bindings, and state management
This API eliminates the complexity of the previous system while maintaining all the power and flexibility you need to build modern, reactive custom elements.
The runtime ships several advanced utilities beyond the core functional API documented above.
computed() — Memoized derived state with automatic cache invalidation.
watchEffect(fn) — Run a side-effect automatically whenever any reactive dependency changes.
nextTick() — Defer work until after all pending DOM updates have been flushed.
See the full Reactive API guide for usage examples.
provide(key, value) and inject(key, defaultValue?) implement ancestor → descendant dependency injection without prop-drilling.
See the Provide / Inject guide for full documentation.
createComposable(fn) extracts reusable stateful logic — including lifecycle hooks — into shareable factory functions.
See the Composables guide for usage patterns.
useTeleport(target) renders virtual DOM content into an arbitrary DOM node outside the current shadow root — ideal for modals, tooltips, and popovers.
See the Teleport guide for full documentation.
registerKeepAlive() registers <cer-keep-alive>, a wrapper element that preserves component JavaScript state across DOM removals and re-insertions.
See the Keep-Alive guide for full documentation.
useExpose(api) publishes methods and properties directly onto the host element so parent components or plain JavaScript can call them by reference — similar to defineExpose() in Vue 3 or useImperativeHandle() in React.
See the useExpose() guide for full documentation.
useSlots() returns helpers (has(), getNodes(), names()) to inspect which named slots have content at render time. Use it to conditionally render wrapper elements only when a slot has been filled.
See the useSlots() guide for full documentation.
registerBuiltinComponents() registers <cer-suspense>, <cer-error-boundary>, and <cer-keep-alive> — opt-in utility components for loading states, graceful error recovery, and state preservation.
See the Built-in Components guide for full documentation.
scheduleWithPriority(update, priority?, componentId?) provides explicit control over when updates run: 'immediate' (synchronous), 'normal' (microtask-batched with deduplication), or 'idle' (deferred via requestIdleCallback — time-sliced and non-blocking).
See the Concurrent Rendering guide for full documentation.
The runtime exports two helpers for development-time diagnostics:
Programmatically enable or disable dev-mode console logging at runtime. This is useful when you want verbose logs during a session without changing build configuration.
import { setDevMode } from '@jasonshimmy/custom-elements-runtime';
setDevMode(true); // enable dev logs
setDevMode(false); // silence themAlternatively, set globalThis.__CE_RUNTIME_DEV__ = true before the library is imported to enable logging as early as possible.
Note: This flag is process-wide. In Node/SSR environments it affects all requests in the same process.
Log an informational message to the console only when dev mode is enabled. No-op in production builds or when dev mode is disabled.
import { devLog } from '@jasonshimmy/custom-elements-runtime';
component('my-widget', () => {
devLog('[my-widget] render()', { props }); // silent in production
// ...
});