@tltdsh/mx
v1.0.7
Published
Sub-1KB reactive DOM reconciliation engine for building massive platforms and user interfaces at scale. 862 bytes brotli, zero dependencies, no build step. Small enough for AI context windows.
Downloads
798
Maintainers
Keywords
Readme
mx.js
Sub-1KB reactive DOM reconciliation engine for building massive platforms and user interfaces at scale. 862 bytes brotli. 4 globals, zero dependencies, no virtual DOM, no build step. Small enough to fit in an AI context window.
Install
CDN (recommended)
<!-- jsDelivr (multi-CDN, recommended) -->
<script src="https://cdn.jsdelivr.net/npm/@tltdsh/mx"></script>
<!-- unpkg -->
<script src="https://unpkg.com/@tltdsh/mx"></script>Pinned version
<script src="https://cdn.jsdelivr.net/npm/@tltdsh/[email protected]/mx.min.js"></script>npm / yarn / bun
npm i @tltdsh/mxESM (bundler)
import { mx, dom, define, components } from '@tltdsh/mx'ESM (dynamic import from CDN)
const { mx, dom, define, components } = await import('https://cdn.jsdelivr.net/npm/@tltdsh/mx/mx.mjs')Works in any modern browser or Deno - no install, no bundler, one line.
4 Globals
| Global | Returns | Use for |
|--------|---------|---------|
| mx.tag(attrs?, ...children) | Description array | Inside render() - efficient reconciliation |
| dom.tag(attrs?, ...children) | HTMLElement | Persistent references, calling .$() later |
| define(name, { $() {} }) | void | Register a component |
| components | object | Component registry |
CamelCase auto-converts to kebab-case: mx.priceChart() → <price-chart>.
Quick start
define('counter', {
$({ count = 0 }) {
return [
mx.button({ onclick: _ => this.$({ count: count + 1 }) }, '+'),
mx.span(count),
mx.button({ onclick: _ => this.$({ count: count - 1 }) }, '-')
]
}
})
document.body.render(mx.counter({ count: 10 }))Examples
SVG sparkline
define('sparkline', {
$({ history = [], width = 80, height = 24 }) {
if (!history.length) return []
let min = Math.min(...history), max = Math.max(...history)
let range = max - min || 1
let points = history.map((v, i) =>
(i / (history.length - 1) * width).toFixed(1) + ',' +
(height - ((v - min) / range * (height - 2) + 1)).toFixed(1)
).join(' ')
return mx.svg({ viewBox: '0 0 ' + width + ' ' + height, width, height, fill: 'none' },
mx.polyline({ points, stroke: '#0071e3', 'stroke-width': '1.5', 'stroke-linecap': 'round' })
)
}
})Sortable table
define('data-table', {
$({ rows = [], sortKey = 'name', sortDir = 1 }) {
let sorted = [...rows].sort((a, b) => {
let va = a[sortKey], vb = b[sortKey]
return sortDir * (typeof va === 'string' ? va.localeCompare(vb) : va - vb)
})
let header = (label, key) =>
mx.th({ onclick: _ => this.$({ sortKey: key, sortDir: sortKey === key ? -sortDir : 1 }) },
label, sortKey === key ? (sortDir > 0 ? ' ▲' : ' ▼') : null
)
return mx.table(
mx.thead(mx.tr(header('Name', 'name'), header('Value', 'value'))),
mx.tbody(...sorted.map(r => mx.tr(mx.td(r.name), mx.td(r.value))))
)
}
})Keyed list with persistent DOM
define('drink-builder', {
$({ drinks = [], onadd, onremove }) {
this._rowMap ||= new Map
for (let [id] of this._rowMap)
if (!drinks.find(d => d.id === id)) this._rowMap.delete(id)
for (let d of drinks) {
let el = this._rowMap.get(d.id)
if (!el) { el = dom.drinkRow(); this._rowMap.set(d.id, el) }
el.$({ drink: d, onremove })
}
return [
!!drinks.length && mx.div({ class: 'list' },
...drinks.map(d => this._rowMap.get(d.id))
),
mx.button({ onclick: _ => onadd?.() }, '+ Add drink')
]
}
})Toast notifications
let toastContainer = null
function toast(message, type = 'info') {
if (!toastContainer) {
toastContainer = dom.div({ class: 'toast-container' })
document.body.append(toastContainer)
}
let el = dom.div({ class: 'toast toast-' + type },
mx.span(message),
mx.button({ onclick() { el.remove() } }, '×')
)
toastContainer.append(el)
setTimeout(_ => el.remove(), 4000)
}
toast.success = msg => toast(msg, 'success')
toast.error = msg => toast(msg, 'error')How it works
render(...children) reconciles children left-to-right against existing DOM:
mx.*()descriptions → create/reuse elements by tag, apply attrs, recurse- Real DOM nodes → identity-match by reference (keyed lists)
- Strings / numbers → text nodes (auto-cast, no
String()needed) null/false→ skipped (conditional rendering)
Excess old nodes are removed. That's it.
Attributes
mx.div({ class: 'active' }) // setAttribute
mx.input({ '.value': text }) // el.value = text (property)
mx.button({ disabled: true }) // setAttribute(name, '')
mx.button({ disabled: null }) // removeAttribute
mx.button({ onclick: handler }) // el.onclick = handler (auto-cleaned)value, checked, selected set both attribute and property automatically.
Keyed lists
Create persistent elements with dom(), store in a Map, render by reference:
let rowMap = new Map(
data.map(d => [d.id, dom.tableRow({ data: d })])
)
// Nodes move by identity - internal state preserved on reorder
container.render(...data.map(d => rowMap.get(d.id)))Tips
mx.*= descriptions (arrays) for insiderender()dom.*= real HTMLElements for persistent references$()always returns array of children??=protects$statefrom parent prop overwrites- Never
addEventListenerinside$()- use direct property assignment - Numbers auto-cast - no
String()needed - Use
_ =>for unused arrow params clearInterval(this._interval)at top of$()for timersthis._cache ||= new Mapfor persistent Maps
TypeScript
Full type definitions included. Provides autocomplete for all HTML/SVG elements and attributes.
import { mx, dom, define, type MxChild, type MxAttrs } from '@tltdsh/mx'License
0BSD - do whatever you want.
