@nativedom/core
v0.1.2
Published
Fine-grained signals, isomorphic Web Components, and html`` tagged templates
Downloads
46
Maintainers
Readme
@nativedom/core
Fine-grained signals, isomorphic Web Components, and a zero-dependency html tagged template.
Build reactive UIs with native Custom Elements — no virtual DOM, no compiler, no framework lock-in. Works on both client and server.
Install
npm install @nativedom/coreQuick start
import { component, signal, html } from '@nativedom/core';
component('app-counter', (_, { signal, html }) => {
const count = signal(0);
return html`
<button @click=${() => count.set(count.get() - 1)}>−</button>
<span>${() => count.get()}</span>
<button @click=${() => count.set(count.get() + 1)}>+</button>
`;
});<app-counter></app-counter>Core concepts
Signals
Reactive state primitives. Reading a signal inside an effect creates a subscription.
import { signal, computed, effect, batch, untrack } from '@nativedom/core';
const count = signal(0);
const doubled = computed(() => count.get() * 2);
effect(() => {
console.log(doubled.get()); // runs whenever count changes
});
batch(() => {
count.set(1); // grouped — subscribers notified once
count.set(2);
});
const snap = untrack(() => count.get()); // read without subscribingcomponent()
Defines an isomorphic Web Component. Same function runs on the server (returns HTML string) and client (returns a live DocumentFragment).
import { component } from '@nativedom/core';
interface Props {
'initial-count'?: string;
}
const Counter = component<Props>(
'app-counter',
({ 'initial-count': init = '0' }, { signal, computed, html }) => {
const count = signal(Number(init));
const isEven = computed(() => count.get() % 2 === 0);
return html`
<p>Count: ${() => count.get()} (${() => isEven.get() ? 'even' : 'odd'})</p>
<button @click=${() => count.set(count.get() + 1)}>+1</button>
`;
},
{ shadow: false }
);
// Type-safe factory — use inside other html`` templates:
html`${Counter({ 'initial-count': '5' })}`Options:
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| shadow | boolean | true | Use Shadow DOM |
| shadowMode | 'open' \| 'closed' | 'open' | Shadow DOM mode |
| island | boolean | false | SSR island — skips client re-render |
| observedAttrs | string[] | [] | Attribute changes that trigger re-render |
| styles | string | — | Scoped CSS (see below) |
| onError | (err) => Interpolation | — | Error boundary fallback |
html``
Tagged template literal for building DOM. Reactive bindings are attached directly to nodes — no diffing.
const name = signal('world');
const bold = signal(false);
const items = signal(['a', 'b', 'c']);
html`
<!-- content binding (reactive) -->
<p>Hello, ${() => name.get()}!</p>
<!-- attribute binding -->
<p class="${() => bold.get() ? 'bold' : ''}">text</p>
<!-- IDL property binding (bypasses setAttribute) -->
<input .value="${() => name.get()}" @input=${(e) => name.set(e.target.value)} />
<!-- event listener -->
<button @click=${() => name.set('NativeDOM')}>reset</button>
<!-- list rendering -->
<ul>${() => items.get().map(i => html`<li>${i}</li>`)}</ul>
`Scoped styles
component('my-card', (_, { html }) => html`<div class="card">...</div>`, {
shadow: false,
styles: `
:scope { display: block; } /* the my-card element itself */
.card { border-radius: 8px; padding: 1rem; }
.card:hover { box-shadow: 0 2px 8px rgba(0,0,0,.1); }
`,
});For shadow: false components, styles are injected once into <head> wrapped in @scope (tag-name) { }.
asyncSignal
Reactive data fetching. The fetcher re-runs whenever its signal dependencies change.
import { component, asyncSignal } from '@nativedom/core';
component('user-profile', ({ id }, { html }) => {
const { data, loading, error, refetch } = asyncSignal(() =>
fetch(`/api/users/${id}`).then(r => r.json())
);
return html`
${() => loading.get()
? html`<p>Loading…</p>`
: html`<h1>${() => data.get()?.name}</h1>`
}
`;
});ctx.watch()
Imperative watcher — like effect but skips the initial run.
component('search-box', (_, { signal, watch, html }) => {
const query = signal('');
watch(
() => query.get(),
(newVal, oldVal) => {
console.log(`changed from "${oldVal}" to "${newVal}"`);
}
);
return html`<input .value="${() => query.get()}" @input=${e => query.set(e.target.value)} />`;
});trustedHtml()
Insert a pre-rendered HTML string without escaping. Use for server-generated content only.
import { trustedHtml } from '@nativedom/core';
html`<div>${trustedHtml('<strong>bold</strong>')}</div>`SSR helpers
import { ssrRender, getComponentStyles } from '@nativedom/core';
// Render a component to an HTML string (server-side)
const html = ssrRender('my-card', { title: 'Hello' });
// Collect all @scope CSS for shadow:false components (inject into <head>)
const css = getComponentStyles();Context
import { createContext, component } from '@nativedom/core';
const ThemeCtx = createContext<'light' | 'dark'>('light');
component('theme-provider', (_, { provide, html }) => {
provide(ThemeCtx, 'dark');
return html`<slot></slot>`;
});
component('themed-card', (_, { inject, html }) => {
const theme = inject(ThemeCtx); // 'dark'
return html`<div class="card ${theme}">...</div>`;
});SSR island pattern
Mark components as island: true to freeze their SSR output on the client — zero JavaScript, perfect for SEO.
component('product-card', ({ id }) => {
const product = db.findById(id);
return `<article>...</article>`; // plain string — server only
}, { island: true, shadow: false });Related packages
@nativedom/server—renderPage()andstreamPage()for full SSR documents@nativedom/router— Signal-backed client-side router@nativedom/store— Global reactive store with SSR hydration
License
MIT
