JSPM

  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 419
  • Score
    100M100P100Q90251F
  • License 0BSD

<1KB UI engine. Build big. Ship small, ship fast.

Package Exports

  • @tltdsh/mx

Readme

mx.js

Sub-1KB reactive DOM reconciliation engine for building massive platforms and user interfaces at scale. 849 bytes brotli. 4 globals, zero dependencies, no virtual DOM, no build step. Small enough to fit in an AI context window.

Documentation

Install

<!-- 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/mx@1.0.9/mx.min.js"></script>

npm / yarn / bun

npm i @tltdsh/mx

ESM (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 inside render()
  • dom.* = real HTMLElements for persistent references
  • $() always returns array of children
  • ??= protects $state from parent prop overwrites
  • Never addEventListener inside $() - use direct property assignment
  • Numbers auto-cast - no String() needed
  • Use _ => for unused arrow params
  • clearInterval(this._interval) at top of $() for timers
  • this._cache ||= new Map for persistent Maps

Limits

render(...children) uses rest parameters, which are subject to the browser's maximum function argument count. This varies: ~65,536 in V8 (Chrome/Edge/Node), ~500,000 in SpiderMonkey (Firefox), ~65,536 in JavaScriptCore (Safari). If you're rendering more than ~60K children, pass an array and spread it, or use a DocumentFragment. In practice this only matters for flat lists with tens of thousands of items - use virtual scrolling at that scale instead.

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.