moxi.js is an experimental, minimalist companion to fixi.js that lets you put small bits of behavior directly on HTML elements: event handlers, reactive expressions, and a compact query helper - all inline, all in one attribute.
Part of the fixi project.
Where fixi handles the network and swapping, moxi handles local interactivity. The two are designed to be used together, but moxi has no dependency on fixi and works perfectly well on its own.
The moxi api consists of three attributes, eight event modifiers, seven handler helpers, and three lifecycle events.
Here is an example:
<input id="name" placeholder="name">
<output live="this.innerText = 'hello ' + q('#name').value"></output>
<button on-click="q('#name').value = ''">clear</button>When a user types into the input, the output updates automatically because the live
attribute re-runs when the DOM or form state changes. The button clears the input when
clicked, and the output updates again in response.
Philosophically, moxi is to hyperscript what fixi is to htmx: a smaller, less ambitious version of the same idea, with fewer features, no DSL, and no parser beyond a couple of regexes. You write plain JavaScript - moxi just gives you a tiny, DOM-flavored scope to write it in.
As such, it does not include many of the features found in hyperscript, Alpine, or Vue:
- a bespoke scripting language
- reactive stores / shared JS state
- templating or iteration directives (
x-for,v-for) - two-way data binding (
v-model,x-model) - component model / custom elements
- fetch, swap, or any network behavior - that's fixi's job
moxi takes advantage of some modern JavaScript features:
asyncfunctions (via theAsyncFunctionconstructor)Proxyfor the query helperMutationObserverfor auto-wiring & reactivity- XPath for discovering moxi-powered elements
- The View Transition API
A hard constraint on the project is that the unminified, uncompressed size of moxi.js must be less than the minified + gzipped size of preact. Current sizes are listed on the fixi project site.
Another goal is that users should be able to debug moxi easily, since it is small enough to use unminified.
Like fixi, moxi has very few moving parts:
- No dependencies (including test and development)
- No public JS API (beyond the events and
__moxiproperty) - No minified
moxi.min.jsfile - No
package.json - No build step
The moxi project consists of four files:
moxi.js, the code for the librarytest.html, the test suite for the librarydemo.html, interactive examples you can open directly- This
README.md, which is the documentation
test.html is a stand-alone HTML file that implements its own visual testing
infrastructure and can be opened using the file: protocol for easy testing.
moxi is designed to be easily vendored - that is, copied into your project:
<script src="moxi.js"></script>That's the entire install. moxi auto-initializes on DOMContentLoaded and uses a
MutationObserver to pick up elements added later.
| attribute | description | example |
|---|---|---|
on-<event> |
Binds a handler for <event> on this element. Colons are allowed in the event name (e.g. on-fx:after). |
on-click="q('#out').innerText = 'hi'" |
on-init |
Special case - runs once at bind time rather than registering an event listener. Useful for setup code that lives on the element itself. | on-init="this.dataset.ready = true" |
live |
An expression that is evaluated at bind time and re-evaluated whenever the DOM or form state changes. Great for reactive output. | live="this.innerText = q('#name').value" |
mx-ignore |
Any element with this attribute on it or on an ancestor will be skipped during processing - no on-* or live attributes on it will be wired up. |
Modifiers are dot-separated and composable. They live between the event name and the =.
For example, on-click.prevent.stop="..." will both preventDefault() and stopPropagation()
before the body runs.
| modifier | description |
|---|---|
.prevent | Calls event.preventDefault() before the handler body runs. |
.stop | Calls event.stopPropagation() before the handler body runs. |
.halt | Equivalent to .prevent.stop - a shorthand for the common case. |
.once | Removes the listener after the first successful fire. Plays correctly with .self and .outside - skipped invocations don't consume the listener. |
.self | Skips the handler when event.target !== this. Ignores bubbled events from children. |
.capture | Passes {capture: true} to addEventListener. |
.passive | Passes {passive: true} to addEventListener. Required for smooth scroll/touch handlers. |
.outside | Attaches the listener to document instead of this, and only fires when the event happened outside the element. Useful for dismissing menus and modals. |
.cc | Camel-cases the event name. on-my-event.cc listens for myEvent. Useful when consuming custom events from libraries or web components that dispatch camelCase names, since HTML attribute names are lowercased by the parser and can't otherwise express mixed case. |
Inside every on-* and live expression, moxi injects the following variables:
| name | type | description |
|---|---|---|
this | Element | The element the attribute is on. |
event | Event | Available in on-* handlers; undefined for on-init and live. |
q(sel) | fn -> proxy | Query helper. See The q() Helper below. |
trigger(type, detail) | fn | Dispatches a bubbling, cancelable CustomEvent from this. |
wait(x) | fn -> Promise | x can be a number (ms delay) or a string (event name, resolves with the event object). |
debounce(ms) | fn -> Promise | Per-handler debouncer - superseded calls never resolve. Use with await. |
transition(fn) | fn | Wraps fn in document.startViewTransition(), with a fallback if unsupported. |
take(cls, from, to) | fn | Removes cls from every element matching selector from, then adds it to to. Perfect for active-tab / active-nav patterns. |
Handler bodies are compiled as async functions (via the AsyncFunction constructor), so
await works anywhere.
For on-* handlers, every key on event.detail is also exposed as a top-level
variable inside the handler body. So instead of writing
<button on-fx:config="event.detail.cfg.confirm = () => confirm('Delete?')">delete</button>you can drop the event.detail. prefix and write
<button on-fx:config="cfg.confirm = () => confirm('Delete?')">delete</button>Reads, mutations (cfg.foo = ...), and even reassignments (cfg = {...}) all hit the
underlying event.detail object. If a handler updates cfg.confirm inside an
fx:config listener, fixi sees the change. This is implemented with a with block
around the handler body, so:
- If
event.detailis missing or null (e.g., a plain non-CustomEvent), nothing is injected and the handler still runs. - Names that aren't on
event.detailresolve normally to the helpers above (q,trigger,wait, ...) or to globals. - Assignments to a name that isn't already a property of
event.detailfall through to the outer scope, so they don't accidentally pollutedetail.
q(selector) returns a proxy over matched elements. The selector grammar is:
[<direction> ]<css-selector>[ in (this | <scope-selector>)]
| direction | result |
|---|---|
| (none) | All elements matching the selector in the scope (default scope: document). |
next X | The first X after this in document order. |
prev X | The last X before this in document order. |
closest X | The same as this.closest(X). |
first X | The first X in the scope. |
last X | The last X in the scope. |
q('.row in this')- scopes the query tothisq('.row in #panel')- scopes the query to the element matching#panel- If the scope selector matches nothing,
qreturns an empty proxy (no throw).
The object returned by q() is a Proxy that fans reads, writes, and method calls across
every matched element:
| operation | behavior |
|---|---|
q(...).prop = v | Sets prop = v on every match. |
q(...).method(...) | Calls method on every match. Returns the result from the first match - so value-returning methods like checkValidity() or getAttribute() work naturally. |
q(...).prop (object) | Returns a new proxy over [e1.prop, e2.prop, ...], so nested access like q('.row').classList.add('sel') and q('.row').style.color = 'red' works. |
q(...).prop (primitive or function) | Returns the value from the first match. |
q(...).count | Returns the number of matched elements. |
q(...).arr() | Returns the matched elements as a plain Array, so you can chain .filter(), .map(), etc. without spreading. |
q(...).trigger(type, detail) | Dispatches the event from every matched element. |
q(...).insert(pos, html) | Parses html and inserts it at every matched element. pos is one of 'before' | 'start' | 'end' | 'after' - a friendlier spelling of the four insertAdjacentHTML positions. |
for (let e of q(...)) / [...q(...)] | Iterates over the raw matched elements. |
moxi fires three lifecycle events. All are dispatched on the element being processed; listen
on the document for global hooks.
| event | description |
|---|---|
mx:init |
Fired just before moxi initializes an element. Cancelable - calling preventDefault() will skip binding that element. |
mx:inited |
Fired after the element has been fully initialized. Does not bubble. |
mx:process |
moxi listens for this event on the document and will process the evt.target and its descendants. Dispatch this to force re-scanning after manual DOM changes. |
| property | description |
|---|---|
document.__moxi_mo |
The MutationObserver that moxi uses to auto-process newly added elements and to drive reactivity. You can disconnect() it temporarily for performance during large mutations. |
elt.__moxi |
An object mapping event names to the handlers moxi wired up on this element. Useful for debugging and for manually removing listeners. |
moxi's entry point is at the bottom of moxi.js. On DOMContentLoaded it:
- Starts a
MutationObserverwatching the document for added nodes, attribute changes, character data changes, and text child changes. - Adds capturing document-level listeners for
inputandchangeto drive reactivity. - Processes the existing body.
moxi finds elements using a single XPath query:
descendant-or-self::*[@live or @*[starts-with(name(),'on-')]]
That is - anything with a live attribute, or any attribute name starting with on-. XPath
means moxi only visits elements it actually needs to wire up, rather than iterating every
descendant.
For each on-<event>[.<mod>...] attribute, moxi compiles the attribute value into an async
function with the handler scope described above, then attaches it as an event listener. The
attribute name after the on- prefix is the event name (colons allowed), optionally followed
by dot-separated modifiers.
If the event name is the literal string init, moxi invokes the function immediately instead
of registering a listener.
For each live attribute, moxi compiles the value into an async function, runs it once, and
adds it to a global set of reactive expressions. Whenever the MutationObserver sees a
change, or the capturing input/change listener fires, every live expression is re-run.
To avoid runaway self-mutation cycles, moxi guards recompute behind a pending flag cleared
on the next macrotask - so a live expression writing to the DOM will, at worst, settle in two
ticks rather than cycle forever.
Live expressions whose element has been removed from the DOM are removed from the run set on
the next invocation (they detect !elt.isConnected and clean up).
moxi and fixi compose cleanly. Because moxi listens
for events via on-*, you can react to fixi's lifecycle events with an ordinary handler:
<div fx-action="/data" on-fx:after="q('closest section').classList.add('loaded')">
...
</div>or trigger a fixi request from a moxi handler via trigger:
<button on-click="q('#target').trigger('refresh')">Reload</button>
<div id="target" fx-action="/data" fx-trigger="refresh">...</div><input id="name" placeholder="type something">
<output live="this.innerText = 'hello ' + (q('#name').value || 'stranger')"></output><button on-init="this.count = 0"
on-click="this.count++; q('next output').value = this.count">click me</button>
<output>0</output><nav>
<button class="tab active" on-click="take('active', '.tab', this)">One</button>
<button class="tab" on-click="take('active', '.tab', this)">Two</button>
<button class="tab" on-click="take('active', '.tab', this)">Three</button>
</nav><input on-input="await debounce(250); q('next output').innerText = 'searching ' + this.value">
<output></output><button on-click="transition(() => q('#panel').classList.toggle('open'))">toggle</button>
<div id="panel">...</div><button on-click="q('#menu').hidden = false">open menu</button>
<div id="menu" hidden on-click.outside="this.hidden = true">
Menu contents...
</div><dialog on-confirm="alert('confirmed: ' + event.detail)">
<button on-click="trigger('confirm', 'yes')">yes</button>
<button on-click="trigger('confirm', 'no')">no</button>
</dialog>Because moxi ships no public JS API beyond its scope helpers and lifecycle events, extensions
are mostly a matter of hanging additional behavior off the mx:init and mx:inited events.
A suggested convention when adding moxi extension attributes is to use the ext-mx prefix:
// ext-mx-log: logs every time the element receives focus
document.addEventListener("mx:inited", (evt) => {
if (evt.target.hasAttribute("ext-mx-log")) {
evt.target.addEventListener("focus", () => console.log("focused:", evt.target))
}
})<input ext-mx-log placeholder="focus me">Zero-Clause BSD
=============
Permission to use, copy, modify, and/or distribute this software for
any purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL
WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES
OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE
FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY
DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN
AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT
OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.