A tiny, signal-driven directive engine for HTML. Zero framework footprint. ~15KB (bundled).
Philosophy
- 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.
Quick Start
Without JavaScript (CDN)
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.
With JavaScript (ESM)
<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>
Installation
Or use directly via ESM import.
CSP-Safe (Default)
import Signet, { createApp } from 'signet.js';
Uses a recursive descent parser — no eval() or new Function(). Safe under
strict Content-Security-Policy headers.
Unsafe-Eval (Smaller Bundle)
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.
API
Signet(rootEl, dataFn?)
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 }.
createApp(dataOrFn)
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).
.store(obj)
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 expressions
.directive(name, fn)
Register 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 |
.unmount()
Tear down all effects and cleanup functions recursively.
Directives
js-scope="{ ... }"
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>
js-text="expr"
Set el.textContent reactively.
<span js-text="count"></span> <span js-text="'Total: ' + (count * price)"></span>
js-on:[event].modifiers="expr"
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>
js-show="expr"
Toggle display: none based on expression truthiness.
<p js-show="isVisible">Now you see me</p>
js-if="expr"
Conditional rendering. Uses comment anchors for DOM position tracking.
<div js-if="isLoggedIn">Welcome back!</div>
js-for="item in list"
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>
js-model="prop"
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" />
js-bind:[attr]="expr"
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>
js-html="expr"
Set el.innerHTML reactively. Use only with trusted data.
<div js-html="richContent"></div>
js-ref="name"
Assign element to a signal. Cleared to null on unmount.
<input js-ref="inputEl" /> <!-- inputEl is the <input> DOM element (auto-unwrapped) -->
js-once="expr"
One-time evaluation — no reactive tracking.
<span js-once="Date.now()"></span>
js-cloak
Removed after mounting. Pair with CSS: [js-cloak] { display: none; }
<div js-cloak>Content hidden until Signet.js mounts</div>
Computed Properties
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; }, }));
Scope Helpers
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
Development
# 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
Testing Stack
- 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
License
MIT