menut

HTML-first reactive micro-framework in a single file, no dependencies, no build. Inspired by Fixi, it rewrites the essence of Petite-Vue.

One state No build No Virtual DOM Granular reactivity No dependencies Web-native ~12.6 Kb
Three ideas. One library in a single file.

One state

window._ is the whole state. The assignment itself makes it reactive, and _.mount() is ready to go.

Granular reactivity

When _.user.name changes, only the nodes that read it update. The whole tree is never re-rendered.

Components (SFC)

Package reusable functionality with <define-component>: typed props, local state and SFC files.

Examples →
Menut is part of the weblin.org platform.

Installation

A single file, included with one tag:

<script src="https://cdn.jsdelivr.net/gh/alsanan/menut/menut.min.js"></script>

Then define the state and mount:

window._ = { name: "Ana", counter: 0, increment() { _.counter++; } }; _.mount();

Assigning window._ already makes it reactive and injects the helpers, so _.mount() is ready right after.


Directives

Every directive starts with : and its value is a JavaScript expression evaluated with _ as context.

DirectiveUse
:textInserts escaped text
::Syntactic sugar equivalent to :text
:htmlInserts unescaped HTML
:html-unsafeSame as :html — a marker for consciously inserting raw HTML
:modelTwo-way binding with forms
:asType coercion for :model (int, number, bool, array, object)
:transitionView Transition on re-render; modifiers .fade, .scale, .slideup, .shared="name"
:ifConditional rendering (on <template>)
:elseElse branch with :if (on <template>)
:eachRepeats content (on <template>)
:refReferences an element in _.ref
:on.*Listens to events. this is the element
:isDynamic root element on component usage (<x-card :is="article">)
:attributeAny HTML attribute (:class, :src, :value…)
:shorthand:name without = expands to :name="name"

If :if or :each appear on a non-<template> element, the console will warn with the problem so you can fix it.

Full documentation in docs/api.md.


Expression context

Available depending on context:

_ global state
In events: this is the element and event the event
In :each loops, item is the iterated element, item._index its position, and item._parent the parent loop if any.

There are no $el, $refs, $nextTick, $parent or $root.


JavaScript API

The whole JavaScript API boils down to two lines:

window._ = { ... }; _.mount();
  • Assigning window._ wraps it in a reactive Proxy and injects the helpers right away.
  • _.mount() scans the DOM; it is idempotent and re-scanable: after inserting dynamic HTML just call _.mount(container).
  • No MutationObserver: you decide when to mount again.

Helpers in _

mount() injects a few minimal one-liner helpers into _ (like _.ref): only if you have not defined them yourself, and as non-enumerable properties (they don't pollute Object.keys(_) or :each iterations). Because _ is the prototype of the expression context, they are usable as bare identifiers in any directive.

HelperWhat it does
$(sel)document.querySelector(sel)
$$(sel)[...document.querySelectorAll(sel)]
net.get(url)fetch(url).then(r => r.text())
net.json(url)fetch(url).then(r => r.json())
net.post(url, data)POST JSON → text
dispatch(name, detail)CustomEvent (bubbles, composed) on document
sleep(ms)new Promise(r => setTimeout(r, ms)) (for await)
raf(fn)requestAnimationFrame(fn)
mount(el?)Mounts (re-mounts) el; accepts a selector or element

dispatch is the complement to :on.myevent: it fires a custom event that any :on.* listener (or addEventListener) can catch. Inside a :on.* handler, this is the element, so this.dispatchEvent(new CustomEvent(...)) targets the element itself.

<button :on.click="dispatch('saved', { id: 5 })">Save</button> <button :on.click="await sleep(300); mount('#zone')">…</button>

Components (SFC)

Menut lets you package reusable functionality into single-file components (SFC), declared with <define-component> and used as custom elements. The reactive core does not change: a component instance is a mount() against its local state.

Definition & usage

The definition is explicit and separate from usage: <define-component> leaves the component registered and ready to be used later by its tag.

<!-- inline: name + a child <template> --> <define-component name="x-counter"> <template count:number="0"> <style>:host { display: block }</style> <button :on.click="inc()">Clicks: <span ::="count"></span></button> <script>this.inc = () => this.count++</script> </template> </define-component> <!-- external: the tag comes from the filename --> <define-component src="components/x-counter.html"></define-component> <!-- usage (requires a prior definition) --> <x-counter></x-counter> <x-counter count="10"></x-counter>

The external SFC file

A .html file with a single <template> (identical to the inline one):

<!-- components/x-counter.html --> <template count:number="0"> <style>:host { display: block }</style> <button :on.click="inc()">Clicks: <span ::="count"></span></button> <script>this.inc = () => this.count++</script> </template>

The tag is derived from the filename (components/x-counter.htmlx-counter), so the name must contain a hyphen. It is loaded with fetch() + DOMParser (which never runs scripts) and waiting instances are booted once it arrives.

State & instances

  • Light DOM by default (no shadow). Add shadowrootmode="open" to the <template> to encapsulate.
  • The template binds to the instance local state: _ is local and the global is accessed as window._ (no automatic fallback to the global).
  • Inside the component <script>, this is the local reactive state; this.el is the element.
  • Each instance is independent: a component inside :each creates one instance per row, and the element props bind with the parent context (<x-num :num="item.v">).

Props

They are declared on the <template> with name:type="default" attributes:

<template count:number="0" label:string="hello" active:boolean>
TypeCoercionDefault
:stringString(v)""
:numberNumber(v)0
:booleantrue unless "false"false
:datenew Date(v)null
:arrayJSON.parse(v)[]
:objectJSON.parse(v){}
(no suffix)auto-detectas-is
  • Primitives reflect to the attribute (state ↔ attribute); arrays/objects are property-only.
  • Prop names must be lowercase (HTML attributes are case-insensitive).
  • onpropchange fires when a prop changes, whether from the attribute or the state.

Lifecycle

In the <script> you can assign callbacks (all with this = state):

  • this.onpropchange = (name, val) => {} — when a prop changes.
  • this.onconnected = () => {} — when the element connects (DOM ready).
  • this.ondisconnected = () => {} — when it disconnects.

Programmatic API

_.define("x-counter", "<button :on.click=\"inc()\">…</button><script>this.inc = …</script>", { count: { type: "number", default: 0 } });

How it works under the hood

mount() registers <define-component> blocks before scanning, so definitions work even when they come after their usages. On registration the <style> and <script> are extracted from the template content (not cloned per instance); the style is injected once with scope (tag { … } + :host→tag). Each instance boots with a local reactive state (props + script methods), clones the template, scans it against that state and inserts it into the light DOM.

What to expect

  • Explicit definition: a usage without a definition stays empty (and silent).
  • The <script> goes inside the <template>: that way it is inert at parse time and Menut extracts it at definition.
  • Full reactivity: the component template uses the same directives as the rest.
  • Real reuse: the same component with different props and independent states.
  • A component can use other components (nested) and live inside :each.
  • v1 limitations: the <script> is synchronous (no top-level await); it can touch the DOM via this.el (runs after the template is rendered), but geometry measurements are unreliable before the first paint (use requestAnimationFrame or measure on interaction). Prop names are lowercase.

Full example in examples/components.html · Reference in docs/api.md.

Component library & contributing

Menut ships with a re-usable library of SFC components in components/. Browse them in the live gallery or the auto-generated reference, grouped by category: forms, data, text, buttons, layout, 3d, content.

To propose a new component: copy components/_template.html, fill its doc comment (@component, @category, @usage), add a demo to examples/components.html and open a pull request. A bot runs on the PR — the verify-component check, a static scan plus an AI review — and main is protected: the check must pass (and for community PRs, a review) before it can merge. New components are verified automatically, docs regenerate after merging, and the gallery is re-published from main.

Full guide with conventions, rules and checklist: CONTRIBUTING.md.


Live demo

These pages also use Menut. Menut is mounted in this very document.

Counter

<output style="font-size:2.5rem" ::="counter"></output> <div cluster> <button :on.click="decrement()">−</button> <button :on.click="increment()">+</button> </div>
_.increment = () => { _.counter++; } _.decrement = () => { _.counter--; }

Tasks

<ul style="list-style:none;padding:0"> <template :each="tasks"> <li style="display:flex;gap:.5rem;align-items:center"> <input type="checkbox" :model="item.done" :id="'todo'+item._index"> <label style="flex:1" ::="item.text" :for="'todo'+item._index"></label> <button fab :on.click="remove(item._index)">🗑️ </button> </li> </template> </ul> <form inline style="margin-top:1rem"> <input type="text" :model="newTask" placeholder="New task" style="flex:1"> <button :on.click="add(event)">Add</button> </form>
_.add = (event)=> { event.preventDefault(); const text = _.newTask.trim(); if (text) _.tasks.push({ text, done: false }); _.newTask = ""; } _.remove = (index)=> { _.tasks.splice(index, 1); }

Documentation

Everything you need to know about Menut lives in three documents, kept as the project's source of truth.

Specification

Philosophy, design decisions, internal architecture and code rules. The project's source of truth.

API & directives

Full reference for every directive (::, :model, :each…) and the expression context.

README

Quick overview: what Menut is, installation and a complete 30-line example.

Contributing

How to propose a component: SFC format, categories, conventions and the PR flow with its verification bot.


Motivation

Why Menut exists, how it compares to the alternatives, and the trade-offs it chose.

Inside Menut

Reactivity without a compiler. One global object, granular updates, and the unsafe-eval decision explained.

Comparison table

Menut vs. Alpine, Petite-Vue, htmx, Fixi — honest trade-offs, no framework wins everything.


Examples

Demos ready to open in the browser. They all use real Menut.

Components (SFC)

Single-file components with <define-component>: typed props, light DOM, and external loading via src.

Nested :each

Nested :each loops: albums with tracks, with item and item._index at each level.

Granular reactivity

Changing one branch does not re-render the other: each branch counts its DOM mutations.

Focus is kept

With a loop of inputs, adding a row does not steal focus from what you are typing.

Transitions

:transition with .scale, .fade, .slideup and .shared morphs.

Counter

::, :text, :on.click and :hidden.

Todo

:model, :each, item and item._index.

Table

:each over tables, sorting and reactive tasks.length.

Forms

:model on text, checkbox, radio and select, and :html.


Tests

A self-contained suite that exercises every directive and shows the result on screen. Open it in any browser.

During development the same suite runs against menut.js and menut.min.js to check they are equivalent.


Code anatomy

An interactive treemap of every function in menut.js, proportional to its size.


CSP & unsafe-eval

Menut uses new Function() to compile HTML expressions into JavaScript at runtime. This requires script-src 'unsafe-eval' in Content Security Policy.

This was a deliberate trade-off: it keeps the framework tiny (~13 kb), the API simple (no build step), and the codebase readable. A CSP-strict variant is possible — see CSP compatibility for the full analysis.