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.
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);
<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.
02
Examples
Six live demos, each a few dozen lines
Every demo runs straight from source with no build step — open devtools
on any of them and you are looking at the whole app.
Start with the todo app: type in its search box and
watch the list re-render on every keystroke while your cursor,
selection and scroll position stay exactly where they were. That is
morph() doing its one job.
- Start here Todos The canonical app: CRUD, inline editing, filters, search-as-you-type, persistence — every kernel piece in ~180 lines.
- Smallest Counter One store, one component, persisted across reloads and synced across browser tabs.
-
Async
Async search
Loading, error and retry states, request cancellation, and the
api.jsseam — all component-local state. - Navigation API Router Routing with params, deep links, back/forward, and view transitions between views.
- View Transitions Animation List shuffle, sort, add and remove — CSS pairs the cards, morph moves them.
- Stress test DBMonster The classic repaint benchmark, deliberately far over the 250-node ceiling. A measurement, not a pattern to copy.
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.mdin the same commit. Undocumented behaviour is not part of the framework.
04
The kernel
Eight files, readable in one sitting
- element.jsAurilElement base class151
- store.jsobservable store142
- router.jsNavigation API router100
- html.jsescaped template tag50
- morph.jsIdiomorph wrapper26
- delegate.jsevent delegation18
- dev.jsopt-in debug logging10
- index.jspublic exports7
- 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()anddelegate()auto-remove on disconnect,watch()subscribes to the store, andupdate()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, nopopstatejuggling. 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-devin the URL.
05
What it costs
DOM morphing vs innerHTML, measured
innerHTML at 250 nodes
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.
| Nodes | render | morph | innerHTML | morph ÷ innerHTML |
|---|---|---|---|---|
| 250 | 0.105 | 6.2 | 1.10 | 5.6× |
| 1000 | 0.475 | 31.9 | 4.80 | 6.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.
git clone https://github.com/nogo/aurilcd auril./vendor.sh ../my-app/web/auril
import { html, AurilElement, Store } from './auril/index.js';
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.
07
Honest limits
When auril.js is the wrong tool
- You need old browsers.
Routerrequires the Navigation API andURLPattern— 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.