Skip to content

Repository files navigation

Micro-UI

An Open Tech Foundation project

A small runtime for AI-generated micro-apps

Visit the Micro-UI site · Read the docs

Micro-UI Screenshot

A small functional JavaScript UI library for AI agents to generate lightweight, interactive micro-apps.

Features

  • Lightweight — zero dependencies, no build step. ~7.1 KB gzipped for the core; ~7.8 KB with the store, which nothing in the core imports, so a build that never touches it never pays for it.
  • Simple state management — use plain variables or the built-in store for shared state.
  • Smooth updates — changes are batched and applied efficiently, no flicker or jank.
  • Form-friendly — inputs, video, canvas, focus, and scroll position all survive re-renders.
  • Stable lists — reorder, add, or remove items without losing element state.
  • Dynamic attributes — bind classes, styles, and props with simple template expressions.
  • Secure by default — interpolated content is inserted as text, never parsed as HTML.
  • Fault tolerant — one broken component won't crash the rest of your app.
  • AI-ready — minimal API surface that agents can generate without a toolchain.

How it works

Micro-UI parses each html template once and caches the resulting static description on the template literal itself, so re-renders only re-evaluate the ${...} bindings rather than rebuilding a description of the whole tree.

Rendering produces a small virtual tree, which is diffed against the previous one and applied to the DOM. It is a virtual DOM, deliberately a minimal one: no compiler, no signals, no fiber or scheduler, and one reconciliation root per custom element rather than a single app-wide tree.

Installation

pnpm add @opentf/micro-ui
# or
npm i @opentf/micro-ui
# or
yarn add @opentf/micro-ui
# or
bun add @opentf/micro-ui

Import

CDN

jsDelivr

<link rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/@opentf/micro-ui/dist/styles.min.css">

<script type="module">
  import { define, html, update } from
    "https://cdn.jsdelivr.net/npm/@opentf/micro-ui/dist/index.min.js";
</script>

unpkg

<link rel="stylesheet"
      href="https://unpkg.com/@opentf/micro-ui/dist/styles.min.css">

<script type="module">
  import { define, html, update } from
    "https://unpkg.com/@opentf/micro-ui/dist/index.min.js";
</script>

esm.sh

import { define, html, update } from
  "https://esm.sh/@opentf/micro-ui?min";

npm

import { define, html, update } from "@opentf/micro-ui";

Quick Start

<script type="module">
import { define, html, update } from "https://esm.sh/@opentf/micro-ui?min";

define("x-counter", (el, props) => {
  let count = Number(props.count || 0);

  return () => html`
    <button onclick=${() => { count++; update(el); }}>
      Count: ${count}
    </button>
  `;
});
</script>

<x-counter count="0"></x-counter>

Usage

Lifecycle

define("x-demo", (el, props) => {
  let data = [];

  // Post-render setup & cleanup
  onReady(() => {
    const interval = setInterval(() => update(el), 1000);
    return () => clearInterval(interval); // cleanup on disconnect
  });

  // Error handling
  onError((target, err, phase) => {
    console.error(`Failed in ${phase}:`, err);
  });

  return () => html`<p>${data.length} items</p>`;
});

State & updates

define("x-counter", (el) => {
  let count = 0;

  return () => html`
    <button onclick=${() => { count++; update(el); }}>
      ${count}
    </button>
  `;
});

Composition

define("x-child", (el, props) => {
  return () => html`<span>${props.name}</span>`;
});

define("x-parent", (el) => {
  let name = "World";
  return () => html`<x-child name=${name}></x-child>`;
});

API

define(tag, setup)

Registers a custom element. setup(el, props) runs once on connect and must return a render function.

define("x-greeting", (el, props) => {
  let name = props.name || "World";
  return () => html`<h2>Hello, ${name}</h2>`;
});

Attributes and props

props holds the element's attributes as strings. It is the same object for the lifetime of the component, and it is refreshed from the DOM on every render — so read it inside the render function to always see current values:

define("x-greeting", (el, props) => {
  const initial = props.name;                        // captured once, never updates
  return () => html`<h2>Hello, ${props.name}</h2>`;  // read per render, always current
});

Micro-UI has no automatic reactivity. Changing an attribute does not re-render, exactly like changing a variable or a store value does not. Call update(el):

el.setAttribute("name", "Ada");
update(el);                       // now it re-renders, with name = "Ada"

Passing objects, arrays and callbacks

props mirrors attributes, and attributes are strings. A binding whose value is not a string is set as a DOM property on the child instead, which is how the platform itself separates the two:

define("x-row", (el) => {
  // el.item is the object the parent passed; el.onsave is the callback
  return () => html`<li>${el.item.label}</li>`;
});

define("x-list", () => () =>
  html`<ul>${rows.map((r) =>
    html`<x-row item=${r} save=${() => remove(r.id)}></x-row>`)}</ul>`);

The value is set before the child's setup runs, so it is available immediately. Such bindings never appear in props.

An object or array binding is compared by identity: hand the child a new object and it re-renders, hand it the same one and it does not. So replace the object when its contents change, rather than mutating it in place.

A callback is read when it is called, not when the child renders, so a new closure each render — the normal case — updates the property without re-rendering the child. Calling it always runs the latest one.

Attribute names starting with on are event bindings, not props. Name a callback prop something else (save, not onsave) if you want the child to call it rather than the DOM to fire it.

There is no observedAttributes or attributeChangedCallback — an attribute changed from outside is picked up by the next update(), not observed as it happens. Attributes bound from a parent template are the exception: the parent's own re-render already patches and updates the child for you.

define("x-parent", () => () => html`<x-child label=${label}></x-child>`);
// updating the parent updates x-child too — no explicit update(child) needed

html

Tagged template that produces a renderable tree. Supports text, attributes, events, keyed lists, conditionals, and nesting. Text interpolations are inserted as text nodes, never parsed as markup.

html`<button onclick=${handler} class="btn ${active}">Click</button>`

Two restrictions are worth knowing up front, because both fail in ways that do not look like your mistake:

An on* attribute must be an interpolated function, never a string of code. onclick="doThing()" throws with a message telling you so — including inside html.raw, where an inline handler in markup is exactly the injection this library is built to refuse.

html`<button onclick=${() => doThing()}>ok</button>`   // a handler
html`<button onclick="doThing()">no</button>`          // throws

A tag name cannot be interpolated. Each template is parsed once and cached on the literal, so a ${...} where a tag name belongs is text, not a tag — it renders as escaped characters rather than raising. Write the tag literally, or build the element with document.createElement.

html`<${tag}>hi</${tag}>`     // renders the literal text "<x-foo>hi</x-foo>"

html.raw

Trusted HTML opt-in — bypasses escaping. Never pass user input.

html.raw`<div>${trustedMarkup}</div>`

cx(...) / sx(...)

Compose a class or style string. A binding that is not a string becomes a DOM property, and el.class and el.style are not writable that way — so class=${["a", "b"]} sets nothing at all. These turn the shapes you reach for into the string the DOM wants.

import { cx, sx } from "@opentf/micro-ui";

html`<button class=${cx("ui-btn", active && "is-active", { "is-busy": busy })}>`
// class="ui-btn is-active"

html`<div style=${sx({ color: "red", marginTop: "8px" }, hidden && { display: "none" })}>`
// style="color: red; margin-top: 8px; display: none"

cx takes strings, arrays and { name: on } objects, nested to any depth, and drops everything falsy — so a conditional class is just cond && "is-on".

sx takes strings, arrays and { prop: value } objects. camelCase keys become kebab-case (marginTop → margin-top), a custom property is left alone, and a nullish, false or empty value drops that declaration while 0 survives. Numbers are written as they are: there is no automatic px, because guessing which properties take a unit is a table this library would rather not carry.

With mount(..., { dev: true }), handing class or style a raw array or object warns and names the helper you wanted.

onReady(callback)

Registers a callback to run once the component has rendered and is in the document. Must be called synchronously inside setup. Returning a function from it registers a cleanup, run when the element is genuinely removed — a move or a re-parent does not count.

"A move" means the element is back in the document by the next microtask — parent.appendChild(node) and anything else that removes and re-inserts synchronously. A gap wider than that, an await between the two, is a removal followed by a fresh mount: cleanups run, setup runs again, and the component's state starts over. Virtualised lists and animated re-parenting should move the node in one go.

define("x-clock", (el) => {
  let now = new Date();
  onReady(() => {
    const id = setInterval(() => { now = new Date(); update(el); }, 1000);
    return () => clearInterval(id);
  });
  return () => html`<time>${now.toLocaleTimeString()}</time>`;
});

Each callback is isolated: one that throws is reported through onError with the "ready" phase and does not stop the others, or cost you their cleanups.

onError(handler)

Registers an error handler for this component. Must be called synchronously inside setup. Receives the element, the Error, and the phase it happened in — "setup", "ready", "render" or "reconcile".

define("x-risky", (el) => {
  onError((target, err, phase) => report(`${phase}: ${err.message}`));
  return () => html`<p>${mightThrow()}</p>`;
});

Errors do not travel to a parent component — every component is its own boundary. Throws from an event handler are not caught here; they reach window.onerror like any other listener.

update(el)

Triggers a re-render. Multiple calls are batched into a single update.

A component that has failed is retried: update() clears the error box and renders again, on the assumption that whatever threw has been fixed. If it throws again, the error box comes back. A failure in setup is the exception — setup runs once per element and cannot be re-run, so the same error is reported again rather than the component being blanked.

flush()

Immediately processes pending updates synchronously.

update(el);
flush(); // DOM is now up to date

mount(el, tag, options?)

Clears el, creates <tag> inside it, and returns the new element.

const app = mount(document.getElementById("app"), "x-app");

options.dev turns on developer diagnostics for the whole app:

mount(document.getElementById("app"), "x-app", { dev: true });

With dev off — the default — a component that throws renders a neutral message rather than the error text:

<div data-micro-ui-error><pre>Something went wrong.</pre></div>

A thrown error can carry a URL, a token or an internal path, and the error box renders wherever the failing component sits — which may be somewhere a user is looking. So the message is withheld from the page unless you ask for it.

Nothing is lost either way: the full error always goes to console.error, and onError handlers always receive the real Error object. Turn dev on while developing to see the message in the page as well.

dev also turns on the warnings for mistakes that otherwise have no symptom at all:

Warning What it catches
class=${...} was given an array… An array or object handed to class or style, which the DOM ignores — the attribute is silently left unset. Points at cx / sx.
<x-row> was removed with 1 live store subscription… A component torn down while a store.subscribe it made is still live. Nothing re-renders, because the instance is gone — but the listener keeps the element alive, so a page that mounts and unmounts these accumulates detached DOM. Return the unsubscribe from onReady.

Both are dev-only: with dev off nothing is tracked and nothing is logged. A store listener that throws is reported either way — it used to be swallowed, which left the component simply not updating.

store.get(key) / store.set(key, value)

Simple key-value store. Get with store.get, set with store.set. Does not trigger DOM updates — connect to components via store.subscribe.

store.set("counter", 0);
store.get("counter"); // 0
store.set("counter", 1);

store.set(key, value, { path })

Set a nested value by dot-separated path. Immutably clones the object tree along the path — the value you passed in is never mutated.

store.set("form", { name: "", email: "" });
store.set("form", "Ada", { path: "name" });
store.get("form", { path: "name" }); // "Ada"

Arrays are cloned as arrays, so numeric segments address list items and the container stays a list:

store.set("todos", { items: ["a", "b", "c"] });
store.set("todos", "z", { path: "items.0" });
store.get("todos").items;          // ["z", "b", "c"] — still an array

store.subscribe(key, fn)

Listen for changes to a store key. Returns an unsubscribe function. Call inside onReady to connect store changes to component re-renders.

define("x-counter", (el) => {
  onReady(() => store.subscribe("counter", () => update(el)));
  return () => html`<span>${store.get("counter")}</span>`;
});

store.del(key) / store.del(key, { path })

Delete a store key (resets to undefined) or remove a nested key from an object value. Notifies subscribers.

store.del("counter");                   // full key deleted
store.del("form", { path: "name" });   // only removes "name", rest intact
store.del("todos", { path: "items.1" }); // splices index 1 out of the array

store.clear()

Reset every key to undefined and notify all subscribers. Existing subscriptions stay live — a component subscribed before clear() still receives later set() calls for its key.

Security

Text is never parsed as HTML

Every ${value} text interpolation is inserted as a DOM text node. No HTML parser runs on that path, so injected markup cannot become elements and scripts cannot execute:

const userInput = '<script>alert("xss")</script>';
html`<p>${userInput}</p>` // renders as literal text, not markup

A boolean is a condition, never content: both true and false render nothing, so ${a > b} does not print the word "true" into the page and ${cond && html...} reads the way it looks. Use ${String(flag)} when the word is what you meant. 0, "" and NaN are values, and render.

Values are displayed exactly as written — they are not entity-encoded, so &, <, >, " and ' render as themselves:

html`<p>${"Tom & Jerry"}</p>`   // shows: Tom & Jerry
html`<p>${"5 < 10"}</p>`        // shows: 5 < 10

URL attributes are refused when they would run script

Text is the boundary for content, but an attribute the browser navigates to is a second door. href=${untrusted} used to be written verbatim, so a javascript: URL out of a database ran on the next click, with the page's origin.

An href, src, action, formaction, ping, srcdoc or xlink:href whose value would execute is not set at all, and the refusal is reported to the console:

html`<a href=${"javascript:alert(1)"}>click</a>`   // no href is set
html`<a href=${"/search?q=javascript:alert(1)"}>`  // fine — it is a path

The scheme is read the way the parser reads it, so JaVaScRiPt:, a leading space and java\tscript: are all the same URL. mailto:, tel:, blob: and data: images are left alone; only javascript:, vbscript: and data:text/html are refused.

If you meant it — a javascript: URL of your own — use an onclick handler instead. There is no way to opt back in, because there is no version of this that is safe with a value you did not write.

Trusted HTML opt-in (html.raw)

When you need to render trusted markup, use html.raw:

html.raw`<b>${boldText}</b>` // parsed as real HTML

html.raw still stringifies interpolated values, but the entire result is treated as markup and parsed via innerHTML. Never pass user-controlled input to html.raw.

Error isolation

Every component is its own boundary. A throwing setup, render, or reconcile is caught where it happens: that component is replaced with an error box and its own onError handlers run, while everything around it keeps working.

Errors do not travel up. A parent cannot catch or render a fallback for a failing child — the child shows its own error box in place, and the parent's onError is not called. There is no ancestor error boundary.

Errors thrown from event handlers are outside this: they are ordinary DOM event callbacks and reach window.onerror, not onError.

A throwing onReady callback is isolated too: the remaining callbacks still run, the rendered UI is left alone (the component itself rendered fine), and the failure is reported to onError with phase "ready".

  • The failed element is replaced with a small inline error box (<div data-micro-ui-error>).
  • The instance is marked errored — further update() calls are no-ops.
  • The host page and all sibling components continue running normally.
  • Recovery requires removing the element from the DOM and creating a fresh one.
define("x-safe", (el) => {
  onError((target, err, phase) => {
    console.error(`Error in ${phase}:`, err);
  });

  return () => {
    if (Math.random() > 0.5) throw new Error("boom");
    return html`<p>Works</p>`;
  };
});

Micro-UI Utils

A small, semantic, framework-free CSS utility and component layer for building Micro-UI apps.

No Tailwind. No Bootstrap. No build step required.

import "@opentf/micro-ui/styles.css"; // full bundle (tokens + base + components)

Split imports — pay only for what you use:

import "@opentf/micro-ui/styles/tokens.css";     // vars only
import "@opentf/micro-ui/styles/base.css";        // reset + layout
import "@opentf/micro-ui/styles/components.css"; // buttons, forms, cards...

Or via CDN:

<link rel="stylesheet" href="https://unpkg.com/@opentf/micro-ui/dist/styles.min.css">

Class Reference

Category Classes
Layout ui-container, ui-stack, ui-row, ui-grid, ui-grid-2/3/4, ui-wrap, ui-center, ui-between, ui-grow
Spacing ui-gap-{1,2,3,4,5,6,8,10,12}, ui-p-{0,2,3,4,5,6,8}, ui-px-{4,6}, ui-py-{2,4,6}, ui-mt-{2,4,6,8}, ui-mb-{2,4,6,8}
Typography ui-title, ui-heading, ui-text, ui-muted, ui-label, ui-caption, ui-error-text, ui-link
Buttons ui-btn, ui-btn-primary, ui-btn-secondary, ui-btn-ghost, ui-btn-danger, ui-btn-success, ui-btn-sm, ui-btn-lg, ui-btn-icon
Forms ui-field, ui-input, ui-textarea, ui-select, ui-checkbox, ui-radio, ui-switch
Surfaces ui-card, ui-card-flat, ui-card-hover, ui-panel, ui-section
Badges ui-badge, ui-badge-primary/success/warning/danger/info
Alerts ui-alert, ui-alert-info/success/warning/danger
Progress ui-progress, ui-progress-bar, ui-progress-success/danger
Loading ui-spinner, ui-spinner-sm/lg
Avatar ui-avatar, ui-avatar-sm/lg
Lists ui-list, ui-list-item, ui-list-item-hover
Tables ui-table-wrap, ui-table, ui-table-hover
Navigation ui-tabs, ui-tab, ui-breadcrumbs, ui-pagination
Menus ui-menu, ui-menu-item, ui-menu-divider
Dialogs ui-modal, ui-dialog, ui-dialog-header/body/footer
Drawer ui-drawer, ui-drawer-left/right, ui-drawer-header/body
Tooltip ui-tooltip, ui-tooltip-content
Empty State ui-empty, ui-empty-icon
Skeleton ui-skeleton
Status ui-status, ui-status-success/warning/danger/info
Drag & Drop ui-draggable, ui-dropzone, ui-dragging
Utilities ui-hidden, ui-visible, ui-w-full, ui-rounded-*, ui-shadow-*, ui-relative/absolute/fixed/sticky
States is-active, is-disabled, is-loading, is-invalid, is-dragover, is-dragging

Theming

Override CSS custom properties for theming:

:root {
  --ui-primary: #2563eb;
  --ui-primary-hover: #1d4ed8;
}

Dark mode — automatic via @media (prefers-color-scheme: dark) in tokens. Override it on any element with data-theme="dark" or data-theme="light"; an explicit attribute always wins over the system preference (and you can theme individual containers).

Layers — all rules are in @layer micro-ui.* so your app CSS wins without !important:

@layer micro-ui, app; /* app layer after micro-ui */

Opt-in CSS — package.json has "sideEffects": ["*.css"] so JS tree-shakes, import CSS only where needed.

Full CSS Documentation


Development

This repo is a pnpm workspace driven by tsr. Every task — build, test, lint, demo — is declared in tasks.toml at the root and can be run from any directory in the repo. Run things through tsr; reaching past it to the underlying tool is the usual way to lose an afternoon (see Gotchas).

Prerequisites

Tool Needed for Install
Node 22+ the toolchain nodejs.org, or nvm install 22
pnpm workspace dependencies corepack enable
tsr every task below curl -fsSL https://raw.githubusercontent.com/Open-Tech-Foundation/tsr/main/install.sh | bash
esdev build and demo curl -fsSL https://raw.githubusercontent.com/Open-Tech-Foundation/ES-Runtime/main/install.sh | bash
chromium (optional) tsr test:browser, which skips without it your package manager, or set CHROME_BIN

tsr and esdev are standalone binaries, not npm dependencies — they land in ~/.tsr/bin and ~/.es-runtime/bin, and both need to be on your PATH. .github/workflows/ci.yml does exactly this and is the shortest complete description of a working environment.

Setup

git clone https://github.com/Open-Tech-Foundation/Micro-UI.git
cd Micro-UI
pnpm install
tsr check     # typecheck + lint + format + the full test suite

Tasks

tsr --list prints them all; tsr <task> --dry-run shows the command a task would run without running it.

Task What it does
tsr check The gate: typecheck + lint + fmt:check + test + test:demo + test:website + test:browser. Run it before every commit.
tsr test The DOM suite — esdev's test runner against esdev's native DOM (esdev test --dom), 600+ tests across 30 files today.
tsr test:demo The demo's split-math suite — pure logic, plain esdev test with no DOM flag.
tsr test:website The website content suite — static checks against the marketing sources, plain esdev test.
tsr test:website:e2e The built docs site in a real browser, over the DevTools protocol. Builds first (website:build); skips if no browser is installed.
tsr test:browser test.html in a real browser, over the DevTools protocol. Skips if no browser is installed.
tsr typecheck tsc --noEmit over packages/*.
tsr lint Biome lint.
tsr fmt / tsr fmt:check Biome format — fmt rewrites files, fmt:check only reports.
tsr build Types, dist/index.js, dist/index.min.js, both CSS bundles, and the package README.
tsr demo The demo workbench on http://localhost:5173.
tsr website The marketing site's dev server. tsr website:build writes its static build.
tsr ci check then build — what GitHub Actions runs on every PR.

Repo layout

packages/micro-ui/
  src/            the library — ~1,700 lines, zero dependencies
  test/dom/       the entire test suite (esdev's `--dom` runner)
  docs/           CSS utility reference
  dist/           build output (git-ignored, not committed)
demo/src/         demo apps, one file per feature area
tasks.toml        task definitions for tsr
biome.json        lint + format rules
CHANGELOG.md      curated by hand; release notes are generated from it

The source, module by module

Module Role
html.ts The html tag. Looks up the parsed template for this literal, builds a vnode tree from the current values.
template.ts Parses each template once into a static description, with a \ue000 marker wherever a ${...} sits. Cached on the template literal itself.
vdom.ts Clones that description into a vnode tree, substituting values and flattening nested fragments and arrays.
reconcile.ts Diffs the previous vnode tree against the new one and patches the DOM: attributes, events, child components' props, and keyed/unkeyed child lists.
dom.ts DOM primitives — element creation, setProp's attribute-vs-property rules, namespace correction.
define.ts Wraps a setup function in a Custom Element. One reconciliation root, and one error boundary, per element.
update.ts update() and flush() — microtask batching, plus render-loop detection.
lifecycle.ts onReady / onError, collected during setup.
state.ts The WeakMaps: instance records, template cache, the pending-render set.
store.ts The optional shared store — get/set/subscribe/del/clear, including path writes.
ns.ts SVG namespace resolution and the camelCase SVG tag names createElementNS will not canonicalize for you.
raw.ts html.raw, the explicit trusted-HTML escape hatch.
error.ts The per-component error boundary and its on-page box.
mount.ts mount(el, tag, options).

Tests

tsr test                                       # the DOM suite
tsr test:demo                                  # the demo's split-math suite
tsr test:website                               # the website content suite
cd packages/micro-ui
esdev test --dom --file=test/dom/keyed-lis.test.mjs                          # one file
tsr test -- "packages/micro-ui/test/dom" "swapping two rows"                 # one test, by name

Every test file imports only what it uses from runtime:test and the library (../../src/index.ts). esdev's --dom flag installs its test-only DOM globals — document, customElements, HTMLElement and friends — before each file runs, so the library, which reaches for them at module scope, loads straight in with no harness. Each file generates random tag names — a custom element name can only be defined once per registry, and tests must not collide.

Each test file runs in its own process, so every one gets isolated module state. A test that depends on dev mode should still call mount(host, tag, { dev: true }) itself, and one that depends on it being off should pass { dev: false } explicitly. That makes the test's contract clear and keeps it independent of runner defaults.

Write the test so it fails against the bug. Asserting the final DOM often passes either way: keyed-lis.test.mjs counts insertBefore calls, and lifecycle.coverage.test.mjs counts how many times setup ran, because that is where the respective bugs actually lived.

Gotchas

  • Don't reach for npx. npx esdev build src/index.ts --out=dist/index.min.js --minify resolves a different esdev out of the npx cache and fails with ENOENT: … /build/, then with a bogus SyntaxError: Export 'define' is not defined. Nothing is wrong with the repo — tsr build works. The same applies to Biome and tsc: use tsr lint, tsr fmt, tsr typecheck.
  • dist/ is not committed. It is a build artifact, produced by the release workflow. Never include it in a PR; measure bundle size by building locally (tsr build:js:min, then gzip -c packages/micro-ui/dist/index.min.js | wc -c). That figure is the core and the store. For the core alone — what a tree-shaking build pays when it never imports store — build an entry with the store line removed from src/index.ts; nothing in the core imports it.
  • There are two READMEs. packages/micro-ui/README.md is generated from this file by tsr build:readme and published to npm. Edit this one.

Before you open a PR

  • tsr check is green.
  • The change has a test. Unit tests for logic, a DOM test under packages/micro-ui/test/dom/ for anything user-facing, plus the edge and error cases.
  • CHANGELOG.md has an entry under ## [Unreleased], saying what broke and why — the release notes are generated from it verbatim.
  • Commits follow Conventional Commits (fix(reconcile): …). See AGENTS.md for the full contributor rules.

Releases are automated: a merge to main runs release.yml, which builds with tsr build, publishes @opentf/micro-ui to npm with provenance, tags v{version}, and cuts a GitHub Release from the curated changelog.

What This Is Not

Micro-UI renders and updates the DOM. Everything else you use the platform for:

  • No router — one reconciliation root per custom element, so islands drop into whatever page or framework already routes.
  • No data-fetching layer — call fetch in setup and put the result in a store; there is no query cache, retry, or invalidation to learn.
  • No SSR or hydration — client islands only.
  • No compiler, build step, or devtools — templates are parsed at runtime.

For a large app with deep routing and server rendering, reach for React, Vue, or Angular instead.

License

MIT

About

A small functional JavaScript UI library for AI agents to generate lightweight, interactive micro-apps.

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages