A no-build frontend kernel for personal apps.
auril.js is a tiny set of browser-native primitives for building small stateful web apps without npm, a bundler, transpilation, JSX, a virtual DOM, or a component compiler. Files ship as plain ES modules. The debugger shows the same source you wrote.
The kernel is intentionally small: top-level src/*.js currently fits in 495
lines, plus one vendored DOM morphing file.
Personal apps rarely need a framework stack. They need a few boring pieces:
- safe HTML string composition
- non-destructive DOM updates
- custom elements with lifecycle cleanup
- one shared store
- a small History API router
- event delegation
- debug logging when requested
auril.js provides those pieces and stops there. Everything app-specific stays in the app.
- No build step. The files in
src/are the files that run. - Platform first. Prefer HTML, CSS, custom elements, forms,
<dialog>,popover,:has(), View Transitions, and browser validation before adding JavaScript. - One store, light DOM, morphed strings. Components read shared state, render HTML strings, and morph their own light DOM.
- Vendored, pinned, never published. Apps copy the kernel deliberately. No registry, no auto-update.
- Source-only. auril.js does not ship a minified artifact. If an app needs minification, that app owns the build step; the kernel stays readable source.
- Small by rule. Kernel code has a hard 600-line budget for top-level
src/*.js.
See FRAMEWORK.md for the full contract, PERFORMANCE.md for what the model measurably costs (and which changes the numbers rule in or out), and ALTERNATIVES.md for the researched landscape of alternative tiny-kernel approaches (why string+morph, and when to revisit).
Clone the repo and serve it with the bundled dev server (live reload + SPA deep-link fallback):
bun serve.jsThen open:
http://localhost:8000/examples/ demo gallery — todos, counter, async, router, animation, dbmonster
Enable debug logging with:
http://localhost:8000/examples/todos/?auril-dev
No Bun? Any static server works, e.g. python3 -m http.server — but it has no
live reload and 404s on routed deep links (refreshing /v/personal/), so it
cannot develop a Router app.
import { html, AurilElement, Store } from './auril/index.js';
const store = new Store(
{ count: 0 },
{ persist: ['count'], key: 'counter-store' },
);
AurilElement.store = store;
class CounterApp extends AurilElement {
onConnect() {
this.watch();
this.on(this, 'click', () => {
store.set((s) => ({ count: s.count + 1 }));
});
}
render() {
return html`<button>Count: ${store.state.count}</button>`;
}
}
customElements.define('counter-app', CounterApp);<counter-app></counter-app>
<script type="module" src="./app.js"></script>html is a tagged template for safe HTML strings. Interpolated values are
escaped by default. Arrays join. null, undefined, and false render as
nothing. Nested html template results compose without double escaping.
const item = (todo) => html`<li id="todo-${todo.id}">${todo.text}</li>`;
const list = html`<ul>${todos.map(item)}</ul>`;Use raw(str) only for trusted markup, such as sanitized markdown renderer
output. Never wrap user input in raw(). Trusted results are branded with
Symbol.for('auril.raw') rather than instanceof, so two vendored copies of the
kernel on one page compose instead of double-escaping across the seam.
Always quote interpolated attribute values — class="${x}", never
class=${x}. Escaping covers &<>"' but not spaces or =, so an unquoted value
is an injection vector escaping cannot close. The one exception is a
boolean-attribute flag built from literals
(<input type="checkbox" ${done ? 'checked' : ''}>), where the interpolated text
never comes from data.
0 renders, so guard optional markup with a real boolean —
items.length > 0 && html\…`. items.length && …prints a bare0` when the
list is empty.
morph(target, content, options?) updates a target element's children to match
an HTML string while preserving focus, selection, scroll, and node identity.
The focused element's value is never overwritten (ignoreActiveValue), so an
input that triggers a re-render on every keystroke keeps its text, cursor, and
IME composition; the value resyncs from markup on blur. Bind such inputs in
markup (value="${s.query}") so they also stay correct while not focused.
Give repeated items stable id attributes so reorders pair the right nodes:
html`<li id="todo-${todo.id}">${todo.text}</li>`;Idiomorph uses Element.moveBefore() where available (Chrome 133+), so a reorder
moves nodes atomically: iframes keep their content, running animations don't
restart, and nested custom elements don't see a disconnect/reconnect cycle.
AurilElement is a custom-element base class for light-DOM components.
class TodoList extends AurilElement {
onConnect() {
this.watch((s) => s.todos, () => this.update());
}
render() {
return html`...`;
}
}Useful methods:
this.on(target, type, handler)adds an event listener and removes it automatically on disconnect.this.delegate(type, selector, handler)delegates events to matching descendants and removes the listener automatically on disconnect — prefer it over baredelegate(this, …)inside a component.this.watch()re-renders on any store change.this.watch(cb)callscb(state)on any store change.this.watch(selector, cb)callscb(slice, state)only when the selected slice changes by===.this.update()morphs the element to matchrender()— skipped when the rendered string is unchanged, so broadwatch()subscriptions stay cheap.
on(), delegate(), and watch() throw when called before connect or after
disconnect — call them from onConnect(). A failing render() is passed to
reportError() (tag name in the message, original error on .cause) and never
rethrown, so the connect path and the store-driven path fail identically.
Component-local state should live in private fields:
class EditorPanel extends AurilElement {
#editingId = null;
}Shared state belongs in Store.
Store is a small observable state container. Updates are batched per
microtask, so multiple synchronous set() calls produce one notification.
const store = new Store(
{ filter: 'all', todos: [] },
{ persist: ['filter'], key: 'todos-store', version: 1 },
);
store.set({ filter: 'done' });
store.set((s) => ({ todos: [...s.todos, todo] }));
const unsubscribe = store.subscribe((state) => {
console.log(state);
});Persistence uses localStorage for selected keys. Storage failures degrade
silently to in-memory state. An optional version discards incompatible saved
data (defaults win) when you bump it after a persisted shape changes, instead
of hydrating stale data. A subscriber that throws is caught and logged, so one
bad subscriber never blocks the others in a batch.
Persisted slices also sync across browser tabs: a storage event from another
tab re-applies the saved keys (last write wins; non-persisted keys untouched).
Only keys whose value actually differs are patched — otherwise the adopting tab
would write back and bounce the change to the sender, and every subscriber would
fire on each event because JSON.parse hands back fresh references.
store.destroy() detaches that listener; apps rarely need it, tests do.
Router is a small client-side router built on the Navigation API and
URLPattern (Baseline newly available 2026).
const router = new Router()
.route('/v/:vault/', ({ vault }) => showHome(vault))
.route('/v/:vault/review/:year', ({ vault, year }) => showReview(vault, year))
.route('/search', (_, url) => showSearch(url.searchParams.get('q')))
.notFound((url) => showMissing(url.pathname))
.start();
router.go('/v/personal/');Handlers receive (params, url). Routes match on url.pathname only; query
state comes off url.searchParams.
One navigate listener intercepts same-origin navigations — link clicks,
back/forward, and go(). Hash-only changes, downloads, POST form
submissions, and cross-origin navigations are left to the browser, as are
unmatched paths when no notFound handler is registered. GET form submissions
are ordinary navigations and stay routed. start() throws if called twice.
Intercepted route changes are wrapped in a View Transition and the intercept
handler awaits it, so the browser does not restore scroll or reset focus before
the new view is in place. The initial resolve in start() runs without a
transition — it would otherwise cross-fade from a blank page.
delegate(root, type, selector, handler) handles events from matching
descendants.
delegate(this, 'click', '.destroy', (event, button) => {
removeTodo(button.closest('li').id);
});delegate(root, …) is the bare helper. Inside an AurilElement, prefer
this.delegate(type, selector, handler), which scopes to the element and removes
the listener on disconnect; bare delegate(this, …) stacks listeners across
reconnects.
Non-bubbling events (focus, blur, mouseenter, mouseleave) never reach
root in the bubble phase — pass { capture: true } for those.
Debug logging is opt-in:
dev.enabled = true;
dev.log('store.set', patch);Or add ?auril-dev to the URL. When enabled, AurilElement logs each
connect / disconnect / update, and every Store registers itself at
globalThis.__auril[key] for console inspection (__auril['todos-store'].state).
auril.js is meant to be copied into apps, not installed from a registry.
./vendor.sh ../budget/web/aurilThe script copies src/, FRAMEWORK.md, and LICENSE into the destination.
Your app then imports:
import { html, AurilElement, Store } from './auril/index.js';Upgrade deliberately by re-running vendor.sh and reviewing the diff like any
dependency bump.
Run the dev server (static files, live reload, SPA deep-link fallback):
bun serve.js [port] # default port 8000Run tests:
bun testRun type checking:
bunx tsc -p jsconfig.jsonRun the benchmarks — wide update, narrow update, fan-out — in the system Chrome
via playwright-core at a 4× CPU throttle:
bun run bench # writes bench/results.json
bun bench/run.js --throttle=1 # unthrottledbench/index.html also runs standalone: serve the repo and open /bench/.
See the Benchmarks section of FRAMEWORK.md for the committed
baseline and what it implies for component size.
Check the kernel line budget:
wc -l src/*.jssrc/
html.js escaped HTML template helper
morph.js Idiomorph wrapper
element.js AurilElement
store.js observable store
router.js History API router
delegate.js event delegation helper
dev.js opt-in debug logging
index.js public exports
vendor/ pinned third-party code
examples/ demo gallery (todos, counter, async, router, animation, dbmonster); examples/todos/ is the canonical usage reference
test/ bun tests (happy-dom)
bench/ browser benchmarks (Playwright + system Chrome); results.json is committed
FRAMEWORK.md compact canonical framework contract
PERFORMANCE.md measured costs, closed decisions, revisit triggers
ALTERNATIVES.md researched landscape of alternative kernels
vendor.sh copy kernel into an app
serve.js static dev server (live reload, SPA fallback)
Do not add to the kernel because something might be useful later. A feature
belongs in src/ only when it is needed by at least two apps today, awkward to
do with the platform alone, understandable in one reading, and small enough to
fit the line budget.
Every kernel change must update FRAMEWORK.md. Undocumented behavior is not part of the framework.
MIT — see LICENSE. vendor.sh copies the license file into the
destination, so vendored copies carry the notice MIT requires.
src/vendor/idiomorph.js is Idiomorph v0.7.4 under Zero-Clause BSD, which
imposes no notice requirement of its own.