auril.js

auril.js

A no-build frontend kernel for personal apps.

Escaped HTML templates, DOM morphing, custom elements, one store and a Navigation API router. No npm, no bundler, no transpilation, no JSX, no virtual DOM. The files in src/ are the files that run.

  • No build step
  • Small by rule
  • Measured, not claimed

Run the examples → Source on GitHub

Kernel size enforced in CI

504 of a 600-line budget

Eight files, and the hatched remainder is all the room left. To cross the budget, something has to be deleted first — the check runs on every push, so the number cannot drift.

01

A complete app

A whole app in two files, no build step

No config, no entry-point ceremony, no compiler. One HTML page and one ES module, served exactly as written.

app.jsplain ES module
import { html, AurilElement, Store } from './auril/index.js';const store = new Store({ count: 0 }, { persist: ['count'] });AurilElement.store = store;class CounterApp extends AurilElement {  onConnect() {    this.watch(); // re-render on any store change    this.delegate('click', '[data-step]', (_, el) => {      const step = Number(el.getAttribute('data-step'));      store.set((s) => ({ count: s.count + step }));    });  }  render() {    return html`      <button data-step="-1" aria-label="Decrement">−</button>      <output>${store.state.count}</output>      <button data-step="1" aria-label="Increment">+</button>`;  }}customElements.define('counter-app', CounterApp);
index.htmlthe whole page
<counter-app></counter-app><script type="module" src="./app.js"></script>

That is the entire program

The count persists to localStorage and syncs across browser tabs, because Store listens for storage events. Nothing else is required: no bundler entry, no config file, no compile step, no node_modules.

What you read above is what the browser executes. Set a breakpoint and the debugger shows this file — not a transpiled approximation of it.

Run this counter → See the full todo app →

03

Why

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 router, event delegation, and debug logging when you ask for it. auril.js is those pieces and then it stops.

Everything app-specific stays in the app. There is no registry and no auto-update: you copy the kernel into your project, pin it, and upgrade deliberately by diffing — like any dependency bump, except you can read all of it in an afternoon.

The six rules that keep it small

Every personal framework dies the same way: by growing into the bloat it was built to escape. These rules are as much the framework as the code is.

  • Two-app rule. Nothing enters the kernel until at least two apps need it today. Not hypothetically.
  • Hard budget: 600 lines across top-level src/*.js. To cross it, delete something first — CI fails the build otherwise.
  • Platform first. <dialog>, popover, :has(), form validation, View Transitions — reach for those before writing JavaScript.
  • Near-frozen. Bug fixes always welcome. For features, the default answer is no.
  • Source-only. No minified artifact ships. If an app needs minification, that app owns the build step.
  • The docs are the contract. Every kernel change updates FRAMEWORK.md in the same commit. Undocumented behaviour is not part of the framework.

04

The kernel

Eight files, readable in one sitting

  1. element.jsAurilElement base class151
  2. store.jsobservable store142
  3. router.jsNavigation API router100
  4. html.jsescaped template tag50
  5. morph.jsIdiomorph wrapper26
  6. delegate.jsevent delegation18
  7. dev.jsopt-in debug logging10
  8. index.jspublic exports7
  9. 8 files · one vendored dependency504 / 600
html``
A tagged template returning an HTML string. Values are escaped by default, arrays join, nested templates compose without double-escaping. raw() marks trusted markup — never user input.
morph()
Updates an element's children to match an HTML string while preserving focus, selection, scroll and node identity. Idiomorph underneath, using moveBefore() where available, so reorders do not restart animations or remount nested components.
AurilElement
A light-DOM custom element base. on() and delegate() auto-remove on disconnect, watch() subscribes to the store, and update() morphs — skipping the work entirely when the rendered string is unchanged.
Store
One observable state container. Updates batch per microtask, selected keys persist to localStorage, and persisted slices sync across browser tabs.
Router
Built on the Navigation API and URLPattern — no click hijacking, no popstate juggling. Route changes are wrapped in a View Transition.
delegate(), dev
Event delegation from a root element, and [auril]-prefixed debug logging you switch on with ?auril-dev in the URL.

05

What it costs

DOM morphing vs innerHTML, measured

5.6×morph vs innerHTML at 250 nodes
2–3 %of an update spent building the string
~250node ceiling per component
1.4 µstax per idle subscribed component

p50 milliseconds per operation, Chrome under a 4× CPU throttle (a mid-range phone, not a laptop), kernel time only — layout and paint excluded. Reproduce with bun run bench.

NodesrendermorphinnerHTMLmorph ÷ innerHTML
2500.1056.21.105.6×
10000.47531.94.806.6×

Yes — morphing costs 5–7× innerHTML. It does everything innerHTML does and then builds id maps and diffs. That multiplier buys node identity and focus preservation, which innerHTML cannot do at any speed. The benchmark asserts it: after one update, morph keeps both; innerHTML loses both.

Building the string is free. The cost is applying it, and it tracks tree size, not change size: changing one row costs 84–97 % of changing every row. So the rule that falls out of the numbers is a single convention — keep one component's rendered tree under ~250 nodes and split larger views into several components with scoped watch(selector, cb). Fan-out is cheap enough to make that trade.

Known limits: Chrome only, one machine, layout and paint excluded, run-to-run spread ~15 %. All of it — including the assumptions that turned out to be wrong — is written down in PERFORMANCE.md.

06

Get it

You do not install auril.js — you copy it

The kernel is vendored into each app deliberately: no registry, no transitive dependencies, no surprise upgrade.

vendor itonce per app
git clone https://github.com/nogo/aurilcd auril./vendor.sh ../my-app/web/auril
import itrelative path, no resolver
import { html, AurilElement, Store } from './auril/index.js';
work on the kernellive reload, SPA fallback
bun serve.js   # http://localhost:8000/bun test       # happy-dom unit testsbun run bench  # browser benchmarks

Types without a build

Type annotations live in JSDoc comments inside the .js files, so any TypeScript-aware editor gives autocomplete and shape-checking with nothing to compile. Store is generic: its state shape flows from the defaults you pass.

Debugging

Add ?auril-dev to any URL for [auril]-prefixed logging of connects, updates, store patches and route resolutions — plus live stores on globalThis.__auril for poking from the console.

Read the full contract →

07

Honest limits

When auril.js is the wrong tool

  • You need old browsers. Router requires the Navigation API and URLPattern — Baseline newly available 2026 (Chrome 102+, Firefox 147+, Safari 26.2+), with no fallback.
  • You need server rendering. auril renders in the browser. There is no SSR story and none is planned.
  • You have views that genuinely cannot be split under ~250 nodes. Morphing walks the whole tree every update; a 1000-node component pays ~30 ms per update under throttle.
  • You are a team that needs an ecosystem. No plugins, no devtools extension, no Stack Overflow answers. The compensation is that the whole thing is 504 lines you can read.
  • You want semver and a registry. Vendoring is the distribution model, on purpose.
  • You want a framework that grows with you. This one is near-frozen by design. If your app outgrows it, the exit is the same as the entry: plain ES modules over web standards, so you leave with your code intact.