A tiny, signal-driven directive engine for HTML. Zero framework footprint. ~15KB (bundled).
- HTML-First & Purity — Interactivity is defined via
js-prefixed directives. After binding, alljs-attributes are stripped from the DOM for zero framework footprint and better accessibility. - Signal-Driven — Uses
@preact/signals-corefor fine-grained reactivity. - Just Enough — Only the essential directive engine. No virtual DOM, no compiler, no build step.
Load Signet as a classic <script> with defer and init. All elements with
js-scope are automatically discovered and mounted — no JavaScript required:
<script src="https://unpkg.com/signet.js" defer init></script>
<div js-scope="{ theme: 'light' }">
<p js-text="'Theme: ' + theme"></p>
<button js-on:click="theme = theme === 'light' ? 'dark' : 'light'">Toggle</button>
<!-- Nested scope inherits `theme` from the parent -->
<div js-scope="{ count: 0 }">
<p js-text="'Theme: ' + theme + ' | Count: ' + count"></p>
<button js-on:click="count++">+1</button>
</div>
</div>The defer attribute ensures the DOM is parsed before the script runs. The
init attribute triggers auto-discovery of all top-level [js-scope] elements.
Nested js-scope elements inherit parent scope properties via the prototype
chain — the inner scope above can read theme without redeclaring it.
<div id="app">
<h1 js-text="greeting"></h1>
<input js-model="name" />
<p js-show="name.length > 0" js-text="'Hello, ' + name + '!'"></p>
<button js-on:click="count++">
Clicked: <span js-text="count"></span>
</button>
</div>
<script type="module">
import Signet from './src/signet.js';
Signet(document.getElementById('app'), () => ({
greeting: 'Welcome to Signet.js',
name: '',
count: 0,
}));
</script>npm install signet.jsOr use directly via ESM import.
import Signet, { createApp } from 'signet.js';Uses a recursive descent parser — no eval() or new Function(). Safe under
strict Content-Security-Policy headers.
import Signet, { createApp } from 'signet.js/unsafe';Uses new Function() for expression evaluation. Smaller bundle via
tree-shaking (the parser is excluded), but requires unsafe-eval in your CSP.
Mount a reactive scope onto a root element.
- rootEl — The root DOM element to bind.
- dataFn — Optional function returning the initial data object. Plain values are wrapped in
signal(), getters becomecomputed(), functions are kept as-is.
Returns { store, directive, unmount, scope }.
Builder-style API (petite-vue compatible). Accepts a function or plain object.
import { createApp } from 'signet.js';
createApp({
count: 0,
get double() { return this.count * 2; },
})
.store({ theme: 'dark' })
.directive('log', ({ exp }) => console.log(exp))
.mount('#app');Returns { store, directive, mount } — chainable until .mount(el | selector).
Register global state accessible from any scope. Properties are merged into the prototype chain so local scope values take precedence.
const app = Signet(root, () => ({ name: 'local' }));
app.store({ theme: 'dark', apiUrl: '/api' });
// theme and apiUrl are now available in all expressionsRegister a custom directive (js-{name}).
app.directive('tooltip', ({ el, exp, arg, modifiers, scope, effect }) => {
const dispose = effect(() => {
el.title = /* evaluate expression */;
});
return dispose; // cleanup function (optional)
});Context object:
| Property | Type | Description |
|---|---|---|
el |
Element | The DOM element |
exp |
string | The expression string |
arg |
string | null | The argument (e.g., click in js-on:click) |
modifiers |
string[] | Dot-separated modifiers (e.g., ['prevent', 'stop']) |
scope |
object | The reactive scope |
effect |
function | The effect function from @preact/signals-core |
Tear down all effects and cleanup functions recursively.
Define an inline reactive scope. Nested scopes inherit parent properties via the prototype chain.
<div js-scope="{ count: 0 }">
<span js-text="count"></span>
<button js-on:click="count++">+1</button>
</div>Set el.textContent reactively.
<span js-text="count"></span>
<span js-text="'Total: ' + (count * price)"></span>Attach event listener. $event is available in the expression.
Modifiers:
.prevent—event.preventDefault().stop—event.stopPropagation().self— Only fire ifevent.target === el.once— Auto-remove after first trigger
<button js-on:click="count++">+1</button>
<form js-on:submit.prevent="handleSubmit($event)">...</form>
<div js-on:click.self="closeModal()">...</div>Toggle display: none based on expression truthiness.
<p js-show="isVisible">Now you see me</p>Conditional rendering. Uses comment anchors for DOM position tracking.
<div js-if="isLoggedIn">Welcome back!</div>List rendering. Each item gets its own scope with item and $index signals.
<ul>
<li js-for="todo in todos" js-text="todo"></li>
</ul>Two-way binding for form elements (input, checkbox, radio, select).
<input js-model="name" />
<input type="checkbox" js-model="agreed" />
<input type="radio" name="size" value="sm" js-model="size" />Reactively set any attribute. Removes attribute if value is null or false.
<a js-bind:href="url">Link</a>
<input js-bind:disabled="isLoading" />
<div js-bind:class="isActive ? 'active' : ''"></div>Set el.innerHTML reactively. Use only with trusted data.
<div js-html="richContent"></div>Assign element to a signal. Cleared to null on unmount.
<input js-ref="inputEl" />
<!-- inputEl is the <input> DOM element (auto-unwrapped) -->One-time evaluation — no reactive tracking.
<span js-once="Date.now()"></span>Removed after mounting. Pair with CSS: [js-cloak] { display: none; }
<div js-cloak>Content hidden until Signet.js mounts</div>Object getters are automatically wrapped in computed(). Inside a getter,
this auto-unwraps signals — write this.count, not this.count.value:
Signet(root, () => ({
count: 0,
get double() { return this.count * 2; },
get isEven() { return this.count % 2 === 0; },
}));The following are available in all expressions:
$signal(value)— Create a new signal$computed(fn)— Create a new computed signal$batch(fn)— Batch multiple signal updates
# Run tests (176 tests, 24 suites)
npm test
# Run property-based tests (22 tests, fast-check)
node --test test/pbt.test.js
# Run benchmarks
npm run bench- Runner: Node.js native test runner (
node:test) - DOM: linkedom
- PBT: fast-check — fuzzes the parser with random strings, verifies idempotency, whitespace invariance, nesting integrity, and the no-crash safety invariant
- Target: 100% coverage
MIT