Reactive UI for small apps, without a build step.
Drop a single <script> tag into any server-rendered page and get reactive state, declarative DOM bindings, and a cross-component event bus — no build step, no compiler, no virtual DOM.
- ~5 kB gzip
- 0 dependencies
- 0 build step
- npm v2.3.1
- MIT licensed
- GitHub →
<div data-component="counter">
<button @click="dec">−</button>
<strong data-text="count"></strong>
<button @click="inc">+</button>
</div>
Micra.define('counter', {
state: { count: 0 },
inc() { this.state.count++ },
dec() { this.state.count-- },
})
Micra.start()
Built for speed
The same hot paths that React and Vue spend a build step
optimising — Micra runs at <script>-tag
startup. Numbers measured against Alpine.js 3.14.1 and
petite-vue 0.4.1 on Firefox / Mac (median of 7 runs); run
the benchmark page
on your own machine for the version you ship with.
Open cross-library benchmarks Mount 1000 components: 19 ms vs 47 ms Alpine, 25 ms petite-vue. First render 1000 keyed rows: 11 ms vs 948 ms Alpine. Unmount 1000: 2 ms.
Drop into an AI sandbox in 30 seconds
Claude artifacts, ChatGPT canvas, v0, Bolt, Replit AI — every one of them defaults the words "build me a UI" to React. To make Micra the answer instead, paste this prompt at the top of the chat. It pins the directives, the anti-patterns LLMs gravitate to, and a working CDN line that survives the sandboxes' Content Security Policies (jsDelivr, not unpkg).
- HTML artifact, not React. The prompt explicitly tells the LLM to create the HTML kind of artifact / canvas, overriding the default React route.
- jsDelivr, not unpkg. Claude's CSP allows cdn.jsdelivr.net and blocks unpkg.com. The prompt pins the working URL.
- Anti-patterns blocked. No getElementById+innerHTML, no addEventListener inside methods, no @keydown.enter, no nested-path writes — eight guardrails, all from real LLM failure modes.
- Seven canonical recipes inline. Counter, search-with-filter, fetch with loading/error, form + validation, modal via bus, tabs, todo — the LLM has reference answers for the common asks.
Fetches the latest PROMPT.md from master and writes it to your clipboard. ~14 KB; no install, no account.
- Claude artifacts
- ChatGPT canvas
- v0
- Bolt
- Replit AI
- Cursor
Getting Started
Micra works with any server-rendered page — no build step, no compiler, no framework setup. Add the script tag or install via npm, then mark elements with data-component.
<script src="https://cdn.jsdelivr.net/npm/micra.js/dist/micra.min.js"></script>
npm install micra.js
- Add data-component="name" to any HTML element.
- Call Micra.define() with that name and a plain object — state, methods, lifecycle hooks.
- Call Micra.start() to mount every registered component on the page.
State changes trigger batched microtask re-renders — multiple writes in the same tick collapse into one render. Already-mounted elements are skipped on repeated start() calls, making it safe to use with server-rendered pages.
Typed end-to-end
The npm package ships its own .d.ts — no @types/micra.js needed. Both state shape and method set are inferred from the literal you pass to Micra.define, so inside every method body this.state.X and this.someMethod() are fully checked at the call site.
import * as Micra from 'micra.js'
Micra.define('counter', {
state: { count: 0 },
inc() {
this.state.count++ // ✓ number
this.dec() // ✓ inferred sibling method
// this.foo() // ✗ Property 'foo' does not exist
},
dec() { this.state.count-- },
})
// Type-safe event bus via declaration merging
declare module 'micra.js' {
interface MicraEvents {
'cart:updated': { count: number }
'modal:close': void
}
}
Micra.emit('cart:updated', { count: 3 }) // ✓
Micra.emit('cart:updated', { count: '3' }) // ✗ type error
Micra.emit('modal:close') // ✓ void → no args
What's checked: imports, state shape, method names, event payloads, lifecycle hooks, refs. What's not: the expression strings inside data-text="…" / @click="…" attributes — those are plain HTML to the IDE and only validated when the page mounts. (Same trade-off as Alpine.js x-* and petite-vue v-*; the alternatives are JSX or a single-file-component compiler, neither of which Micra ships.)
- Server-rendered HTML with interactive islands
- Admin dashboards, forms, small SaaS UIs
- Adding interactivity without a build step
- Generating components with an LLM
- Replacing jQuery or vanilla JS event spaghetti
- You need a full SPA with client-side routing
- Deep nested state reactivity is required
- Your team is already on React or Vue
- You need a rich component ecosystem
| Micra.js | Alpine.js | petite-vue | Stimulus | htmx | hyperscript | vanilla JS | |
|---|---|---|---|---|---|---|---|
| Bundle (gzip) | ~5 kB | ~14 kB | ~6 kB | ~10 kB | ~14 kB | ~15 kB | 0 |
| Build step | no | no | no | bundler | no | no | no |
| Reactive client state | yes | yes | yes | manual | server-driven | no | no |
| Cross-component bus | built-in | stores | no | DOM events | DOM events | trigger | manual |
| Attribute syntax | data-* | x-* | v-* | data-controller | hx-* | _="…" | — |
| Keyed list diffing | yes | yes | yes | manual | OOB swap | no | manual |
| Expression safety | AST-validated | eval | Vue parser | no expr | no expr | DSL parser | — |
| Built-in fetch helper | this.fetch | $fetch plugin | manual | manual | core feature | trigger | manual |
| SSR-friendly props | this.prop() | data-* manual | manual | data-* manual | n/a | manual | manual |
| Lifecycle hooks | onCreate / onDestroy | x-init / x-destroy | mounted / unmounted | connect / disconnect | events | events | manual |
| Sweet spot | SSR + reactive islands | small reactive | small reactive | Rails / Hotwire | server-driven hypermedia | inline DSL fans | tiny one-offs |
<script src="dist/micra.js"></script>
<div data-component="counter-demo">
<button @click="dec">−</button>
<strong data-text="count"></strong>
<button @click="inc">+</button>
</div>
Micra.define('counter-demo', {
state: { count: 0 },
inc() { this.state.count++ },
dec() { this.state.count-- },
})
Micra.start()
Reactive State
this.state is a shallow reactive Proxy. Assign to any top-level key and Micra schedules a re-render — multiple writes in the same tick collapse into one pass. Shallow by design: exactly one kind of write triggers a re-render, and it's always explicit.
Only top-level writes trigger reactivity. Mutations inside arrays or nested objects are invisible to the proxy — replace the parent key instead. Use data-model for two-way binding on inputs, selects, and textareas.
Array replacement patterns
The three patterns you reach for again and again — always replace the top-level array, never push / splice / sort in place:
// add
this.state.items = [...this.state.items, next]
// remove
this.state.items = this.state.items.filter(i => i.id !== id)
// update
this.state.items = this.state.items.map(i =>
i.id === id ? { ...i, name: 'Updated' } : i,
)
Nested objects work the same way — replace the top-level key with a fresh spread: this.state.filters = { ...this.state.filters, query: 'billing' }.
Hello, !
<div data-component="reactive-demo">
<input data-model="name" placeholder="Type your name…">
<p>Hello, <strong data-text="name || 'stranger'"></strong>!</p>
<p data-text="'chars: ' + name.length"></p>
</div>
Micra.define('reactive-demo', {
state: { name: '' },
})
Directives
Directives are data-* attributes that bind reactive state to the DOM. Each one is evaluated against a proxy that resolves state properties and component methods. data-text and data-html set content; data-if mounts/unmounts from the DOM; data-show toggles display:none; data-bind sets attributes; data-model wires two-way input binding; data-class toggles classes additively.
On first render, Micra builds and caches a directive map. Subsequent re-renders reuse it — no repeated querySelectorAll, making re-renders O(1) per directive.
data-text / data-html
data-text
data-html
data-if
data-if = mount / unmount. Inspect the DOM while toggling to see the element appear and disappear.
data-show
data-show only flips style.display. The element stays in the DOM — use this for cheap visibility toggling.
data-bind
This text is styled by reactive state.
Try a hex code like #dc2626.
data-class
data-text / data-html
<div data-component="text-html-demo">
<button @click="toggle">Toggle content</button>
<div data-text="message"></div>
<div data-html="markup"></div>
</div>
Micra.define('text-html-demo', {
state: {
message: 'Plain text <strong>stays literal</strong>',
markup: '<strong>Rendered HTML</strong>',
},
toggle() { /* swap both values */ },
})
data-if
<div data-component="if-demo">
<button @click="toggle">Toggle</button>
<div data-if="open">Panel content</div>
</div>
Micra.define('if-demo', {
state: { open: true },
toggle() { this.state.open = !this.state.open },
})
data-show
<div data-component="show-demo">
<button @click="toggle">Toggle</button>
<div data-show="visible">Content</div>
</div>
Micra.define('show-demo', {
state: { visible: true },
toggle() { this.state.visible = !this.state.visible },
})
data-bind
<input data-model="color">
<p data-bind="style:{ color: color }">
This text is styled by reactive state.
</p>
Micra.define('bind-demo', {
state: { color: '#2563eb' },
})
data-class
<button @click="select"
data-bind="data-tab:'overview'"
data-class="active:tab === 'overview'">
Overview
</button>
Micra.define('class-demo', {
state: { tab: 'overview' },
select(e) { this.state.tab = e.currentTarget.dataset.tab },
})
Event Bus
The event bus is a global singleton — any component can publish or subscribe without a shared parent. Use this.emit(event, payload) to publish and this.on(event, handler) to listen.
Subscriptions made with this.on() are automatically cleaned up when the component is destroyed. To stop listening earlier, call the unsubscribe function it returns. Multiple handlers can subscribe to the same event — each runs independently.
Each click publishes a global event.
Unsubscribed — no more messages.
Micra.define('bus-sender', {
ping() {
Micra.emit('docs:ping', { at: Date.now() })
},
})
Micra.define('bus-receiver', {
state: { count: 0, last: 'Waiting…', unsubscribed: false },
onCreate() {
this.__sub = this.on('docs:ping', payload => {
if (!this.state.unsubscribed) {
this.state.count++
this.state.last = 'Ping at ' + payload.at
}
})
},
unsubscribe() {
this.state.unsubscribed = true
this.__sub && this.__sub() // calls Micra.off()
Micra.emit('docs:ping', { count: this.state.count })
},
})
Fetch
this.fetch(url, options?) wraps the native Fetch API with SaaS-friendly defaults: CSRF token auto-attached from <meta name="csrf-token">, JSON body serialization for POST/PUT/PATCH, query-param conversion for GET/HEAD, and automatic JSON/text response parsing.
Throws a typed FetchError on non-2xx responses so you can catch 404s and 403s by e.status. The demo on this page uses a mock — no backend required.
No users returned.
<div data-component="fetch-demo">
<button @click="load">Load users</button>
<template data-each="users" data-key="id">
<div>
<strong data-text="item.name"></strong>
<span data-text="item.role"></span>
</div>
</template>
</div>
Micra.define('fetch-demo', {
state: { loading: false, users: [] },
async load() { this.state.users = await this.fetch('/api/users') },
})
Fetch Errors
this.fetch() throws a typed FetchError on non-2xx responses. The error carries e.status (the HTTP status code) and e.message, so you can branch on 404, 403, 422, or any other code without parsing the body yourself.
Micra.define('fetch-error-demo', {
state: { status: null, message: '' },
async try404() {
try {
await this.fetch('/api/missing')
} catch (e) {
this.state.status = e.status // 404
this.state.message = e.message
}
},
async tryUsers() {
const data = await this.fetch('/api/users')
this.state.status = 200
this.state.message = 'Got ' + data.length + ' users'
},
})
Lists (data-each)
Render collections with data-each on a <template> element. Add data-key="id" for keyed diffing — Micra reuses existing DOM nodes when keys match, creates new ones for added items, and removes stale ones. This keeps input focus, scroll position, and CSS transitions intact.
Without data-key, items are matched by position. Inside each row, item and $index are available in expressions.
No items yet
Non-keyed (no data-key)
No items
<div data-component="each-demo">
<input data-model="newItem" @keydown.enter="add">
<template data-each="items" data-key="id">
<div data-bind="data-id:item.id">
<input type="checkbox"
data-bind="checked:item.done"
@change="toggle">
<span data-text="item.text" data-class="done:item.done"></span>
<button @click="remove" data-bind="data-id:item.id">✕</button>
</div>
</template>
</div>
<!-- Non-keyed: no data-key attribute -->
<template data-each="items">
<div class="todo-row">
<span data-text="item.text"></span>
<button @click="remove" data-bind="data-id:item.id">✕</button>
</div>
</template>
Methods in Expressions
Directive expressions can call component methods. Micra evaluates them against a proxy that resolves state properties first, then component methods — so data-text="formatPrice(price)" calls the component method directly instead of requiring a pre-computed state value.
Simple property lookups like count or user.name use a fast-path that avoids Function(). Complex expressions are compiled once and cached globally.
— computed by a method call in data-text
<div data-component="expr-demo">
<button @click="dec">−</button>
<strong data-text="'Qty: ' + qty"></strong>
<button @click="inc">+</button>
<!-- Calls formatPrice(qty * 9.99) -->
<strong data-text="formatPrice(qty * 9.99)"></strong>
<!-- Calls stockMessage(qty) -->
<p data-text="stockMessage(qty)"></p>
</div>
Micra.define('expr-demo', {
state: { qty: 1 },
inc() { this.state.qty++ },
dec() { if (this.state.qty > 1) this.state.qty-- },
formatPrice(n) {
return '$' + n.toFixed(2)
},
stockMessage(n) {
return n > 10 ? '⚠ Low stock!' : '✓ In stock'
},
})
Refs (data-ref)
Use data-ref="name" to grab direct DOM references for imperative APIs — canvas drawing, chart libraries, focus management, or third-party widgets. Refs are collected into this.refs after every render, so they stay current even after list updates.
Refs are available in onCreate(), which runs after the first render in a microtask. They are plain HTMLElement references — no wrapper, no proxy.
<div data-component="ref-demo">
<button @click="draw">Draw chart</button>
<canvas data-ref="canvas" width="420" height="180"></canvas>
</div>
Micra.define('ref-demo', {
state: { values: [42, 68, 29, 81] },
draw() {
const canvas = this.refs.canvas
const ctx = canvas.getContext('2d')
// draw bars with the canvas API
},
})
Events (@event and data-on)
@event is the preferred shorthand for binding DOM events — it expands to the same behavior as data-on="event:method". Both support modifiers: .prevent (preventDefault), .stop (stopPropagation), and .self (only fires when event.target is the element itself).
Bind multiple events on one element with comma separation: data-on="focus:open, blur:close". Listeners are attached once — re-renders don't duplicate them.
<!-- @event shorthand -->
<button @click="increment">Clicked <span data-text="clicks"></span> times</button>
<form @submit.prevent="submit">
<input data-model="name">
<button>Submit without reloading</button>
</form>
<input data-model="draft" @keydown.enter="commit">
<!-- data-on alternative -->
<button data-on="click:increment">Clicked <span data-text="clicks"></span> times</button>
Micra.define('event-demo', {
state: { clicks: 0, name: '', draft: '' },
increment() { this.state.clicks++ },
submit() { /* prevent default + update state */ },
commit(e) { if (e.key === 'Enter') { /* save */ } },
})
Lifecycle
onCreate() runs once after the first render in a microtask — refs are available, DOM is ready, and it's safe to make it async for data fetching. onDestroy() is for cleanup: clear timers, remove manual DOM listeners, destroy third-party widgets.
Subscriptions made with this.on() are automatically cleaned up on destroy — no manual unsubscribe needed. Mounting the same root twice returns the existing instance (idempotent).
const lifecycleDefinition = {
state: { stamp: '', status: 'Ready' },
onCreate() {
this.state.stamp = new Date().toLocaleTimeString()
},
onDestroy() {
console.log('destroyed')
},
}
const instance = Micra.mount('#target', lifecycleDefinition)
instance?.destroy()
Multiple Instances
A single Micra.define() registers a blueprint. Every data-component element gets its own isolated instance — independent state, its own $el, and auto-cleaned event subscriptions. This lets you put multiple dropdowns, tooltips, or accordions on one page without duplicating definitions.
Use this.prop(name, default) to read per-instance data-* attributes — useful for server-configured props. Instances communicate through the event bus: one emits, others listen.
| Task | Category | Status | Priority |
|---|---|---|---|
|
No tasks match the selected filters. |
|||
<!-- Three dropdowns, same definition, different props and events -->
<div data-component="multi-dropdown" data-kind="category" data-event="filter:category">...</div>
<div data-component="multi-dropdown" data-kind="status-filter" data-event="filter:status">...</div>
<div data-component="multi-dropdown" data-kind="priority" data-event="filter:priority">...</div>
<!-- Table component listens to events from all three dropdowns -->
<div data-component="filter-table">
<table>
<template data-each="filtered" data-key="id">
<tr>...</tr>
</template>
</table>
</div>
Micra.define('multi-dropdown', {
state: {
open: false,
label: '',
selectedLabel: '',
options: [],
},
onCreate() {
this.state.label = this.prop('label', 'Choose…')
this._event = this.prop('event', '')
const kind = this.prop('kind', 'category')
const optionMap = {
category: [
{ label: 'All categories', value: 'all' },
{ label: 'Design', value: 'Design' },
{ label: 'Development', value: 'Development' },
{ label: 'Marketing', value: 'Marketing' },
],
'status-filter': [
{ label: 'All statuses', value: 'all' },
{ label: 'Active', value: 'Active' },
{ label: 'Completed', value: 'Completed' },
{ label: 'On Hold', value: 'On Hold' },
],
priority: [
{ label: 'All priorities', value: 'all' },
{ label: 'High', value: 'High' },
{ label: 'Medium', value: 'Medium' },
{ label: 'Low', value: 'Low' },
],
}
this.state.options = optionMap[kind] || optionMap.category
this.outside = (e) => {
if (!this.$el.contains(e.target)) this.state.open = false
}
document.addEventListener('click', this.outside)
},
onDestroy() {
document.removeEventListener('click', this.outside)
},
toggle() {
this.state.open = !this.state.open
},
select(e) {
const value = e.currentTarget.dataset.value || ''
const option = this.state.options.find(o => o.value === value)
this.state.selectedLabel = option ? option.label : value
this.state.open = false
if (this._event) Micra.emit(this._event, value)
},
})
Micra.define('filter-table', {
state: { all: [], filtered: [], category: 'all', status: 'all', priority: 'all' },
onCreate() {
this.state.all = [...mockTasks]
this.state.filtered = [...mockTasks]
this.on('filter:category', (v) => { this.state.category = v; this._apply() })
this.on('filter:status', (v) => { this.state.status = v; this._apply() })
this.on('filter:priority', (v) => { this.state.priority = v; this._apply() })
},
_apply() {
let items = this.state.all
if (this.state.category !== 'all') items = items.filter(t => t.category === this.state.category)
if (this.state.status !== 'all') items = items.filter(t => t.status === this.state.status)
if (this.state.priority !== 'all') items = items.filter(t => t.priority === this.state.priority)
this.state.filtered = items
},
})
// Read all live instances at any time
// Array.from(Micra.instances()).forEach(([el, inst]) => console.log(inst.state))
Performance
Micra ships an in-browser benchmark page that measures the hot paths: mount cost, keyed diff under inserts / removes / reorders, full re-render of non-keyed lists, scheduler batching, and the expression evaluator (cold vs warm cache).
Numbers below are indicative — measured on a recent M-class Mac in the previewed window. Run bench.html on your own hardware for the truth.
<template data-each>