UMG-style layout panels and UserWidgets for the web. Plain HTML tags for layout (verticalbox, overlay, canvaspanel, …), plus .umc single-file components with Unreal-like lifecycle, events, and a Vite plugin. No React/Vue required, custom elements in the light DOM.
User guides and tutorials live in docs/:
| Guide | Topics |
|---|---|
| Layout & positioning | Panel tags, fill, overlay, canvas anchors, layout gallery (index.html) |
| Component playground | Live .umc preview in VS Code, resizable stage, --- preview --- |
| UMC components | .umc sections, lifecycle, events, composition, Vite |
| Tutorials | First widget → composition → events → dynamic lists |
npm install slatehtml
# optional UI kit:
npm install slatehtml-uiimport"slatehtml";import"slatehtml-ui";// registers <slate-button>, pickers, …See packages/slatehtml-ui. The Matrix client app lives in a sibling repo (~/dev/matrix).
Or copy widget.css + widget.js into your project.
import"slatehtml";Injects the stylesheet and wires attributes (padding, fill, anchors, …) to CSS.
import"slatehtml/css";import"slatehtml/slate.js";<linkrel="stylesheet" href="./node_modules/slatehtml/widget.css" /><verticalboxpadding="24" gap="12"><textblocktext="Hello"></textblock><horizontalboxgap="8"><borderfillpadding="12"><textblocktext="fill"></textblock></border><borderfill="2" padding="12"><textblocktext="fill 2"></textblock></border></horizontalbox></verticalbox><scripttype="module">import"slatehtml/slate.js";</script>Canonical demo: root index.html, layout and positioning live in panel attributes; CSS is mostly kind chrome via --widget-* tokens. Prefer that pattern over raw flex/grid/position CSS.
Authors write bare tag names (verticalbox, textblock, …). With the Vite .umc loader those compile to umc-* custom elements (umc-verticalbox, …). Uncompiled HTML (npm run demo, or a plain link to widget.css) keeps bare names, widget.css matches both via :is(verticalbox, umc-verticalbox), and widget.js still enhances bare tags via MutationObserver.
Note: bare
<image>is rewritten by the HTML parser to<img>, so it never matched CSS. Prefer the compiledumc-imagepath (or writeumc-imageexplicitly in static HTML).
| Tag | Role |
|---|---|
horizontalbox / verticalbox / wrapbox | Flex row / column / wrap |
overlay | Stack children in one cell (halign / valign on children) |
canvaspanel | Absolute children + anchors + offsets |
scrollbox | Clipped scroll area |
gridpanel | CSS grid (columns; optional masonry / uniform) |
scalebox | Scale first child to fit |
border / spacer / sizebox | Chrome / flex spacer / size clamp |
textblock / image / progressbar / checkbox / slider / editabletext | Leaf widgets |
Common attributes:padding, gap, width, height, min-width, max-width, min-height, max-height, fill, halign, valign, anchors, top / left / right / bottom, background, border-color, kind.
Look vs layout: put size, spacing, alignment, fill, and anchors on the tags. Use --- style --- / page CSS for typography, colors, and --widget-background / --widget-border / --widget-radius / --widget-padding keyed off kind (same split as index.html).
Leaf extras:text, brush, tint, percent, checked, disabled, readonly, multiline.
Native leaf events (already wired by widget.js): changed (checkbox), percentchanged (slider), textchanged / committed (editabletext).
Single-file UserWidgets, HTML + CSS + JS in one file. Built for Vite via slatehtml/umc/vite.
--- html ---
<horizontalbox valign="center" padding="0 14" height="36" gap="10">
<textblock data-umc="title"></textblock>
<spacer></spacer>
<textblock data-umc="status" kind="mono"></textblock>
</horizontalbox>
--- style ---
self textblock[kind="mono"] {
font-family: "IBM Plex Mono", monospace;
font-size: 12px;
color: var(--muted);
}
--- script ---
export default defineUmc({
tag: "title-bar",
attrs: { title: "Slate", status: "Online" },
});
--- preview ---
<title-bar title="SlateHTML" status="Online"></title-bar>
| Section | Aliases | Purpose |
|---|---|---|
html | template | Markup stamped into the host (light DOM) |
style | css | Look-only CSS (string-inlined). Write self for the host tag; bare layout tags compile to umc-*. Hosts default to display: contents (stamped panel does layout, as in root index.html); don't restate flex/margin layout in CSS |
script | js | defineUmc({ … }), no import needed; defineUmc is injected |
preview | demo | Editor-only demo markup; ignored by Vite builds |
External files via a one-line link:
--- html ---
@ ./face.html
--- style ---
@ ./face.css
--- script ---
@ ./face.js
There is no component registry. Importing a .umc registers its custom element,
and the Vite loader auto-imports any custom tags in the HTML section, so you
don't write import "./user-name.umc" by hand:
--- html ---
<horizontalbox>
<user-display-picture></user-display-picture>
<message-meta></message-meta>
<message-text></message-text>
</horizontalbox>
--- script ---
export default defineUmc({ tag: "user-message", … });
Resolution order for each tag:
./tag.umcthen./tag.jsnext to the file- Search under the nearest ancestor folder named
widgets/ - Optional extra roots via
umc({ roots: […] })
Built-in SlateHTML tags (verticalbox, textblock, …, and their umc-*
forms) are skipped. The host's own tag is skipped. Explicit imports still work
and aren't duplicated. In .umc files, bare layout tags in HTML / CSS / script
are rewritten to umc-* at compile time (same as self → host tag).
// main.js, still just the rootimport"slatehtml/css";import"slatehtml/slate.js";import"./widgets/app/discord-app.umc";Plain .js widgets that build children in script (not HTML) still need their
own imports, auto-import only reads the --- html --- section and { tag: "…" }
create specs in the script. Prefer .umc with HTML composition when you can.
Widgets can live in any folder tree under widgets/:
widgets/
app/ discord-app · title-bar
matrix/ matrix-shell · matrix-scope · matrix-room-list · matrix-chat-roll · …
message/ user-message · message-meta · message-text · user-name · …
lib/ slate-button · popup-anchor
Parents pass state down in SynchronizeProperties and children report back with
events, see ../matrix/widgets/matrix/matrix-shell.umc.
<textblockdata-umc="title"></textblock>Copies the host’s title attribute (or the attrs default) onto that node’s text attribute. Override the target with data-umc-prop="…". Binding stops at nested custom-element boundaries, so parents don’t clobber children.
Mark where add / set / clear append:
<verticalboxdata-contentgap="8"></verticalbox>el.add({tag: "textblock",text: "hi"});el.add(existingNode,"plain text",[a,b]);el.set(/* replace */);el.clear();Hand-written widgets can extends WidgetElement (from slatehtml/umc) for the same API. Dynamic lists in .umc use el.set({ tag: "user-message", … }) / el.add(…) into a [data-content] region, see ../matrix/widgets/matrix/matrix-chat-roll.umc.
| Hook | When |
|---|---|
Initialize / OnInitialized | Once, first attach |
PreConstruct | Each attach, before template stamp |
(stamp + data-umc bind) | Built-in |
Construct | Each attach, after stamp/bind |
SynchronizeProperties | After Construct, and when observed attrs change |
Destruct / Destroyed | Detach |
Tick(el, api, dt) | Every frame while attached (dt in seconds) |
PascalCase or camelCase. Example:
exportdefaultdefineUmc({tag: "hud-clock",attrs: {title: "Clock"},Initialize(el){el._t0=performance.now();},Construct(el,{ attr }){console.log("up",attr("title"));},Tick(el,api,dt){// dt ≈ frame delta},Destroyed(){console.log("down");},});Map native DOM events to a public widget API. Anything with a reference can listen:
exportdefaultdefineUmc({tag: "slate-button",attrs: {text: "OK"},events: {click: "clicked",dblclick: "doubleclicked",mousedown: "pressed",mouseup: "released",},OnClicked(el,api,nativeEvent){console.log(api.attr("text"));},});constbtn=root.querySelector("slate-button");btn.addEventListener("clicked",(e)=>console.log(e.detail));// hyperscript / create()tags["slate-button"]({text: "OK",onClicked: (e)=>…});// fire your ownbtn.emit("selected",{id: 3});// or api.emit("selected", { id: 3 })Reference button: packages/slatehtml-ui/src/slate-button.umc.
Passed to lifecycle hooks:
| Method | Does |
|---|---|
attr(name, fallback?) | Read one host attr / default |
attrs(list?) | Read many |
bind(extra?) | Refresh data-umc bindings |
stamp(html?) | Replace host markup |
sync(extra?) | Re-stamp + bind |
emit(type, detail?) | Bubble a CustomEvent from the host |
import{defineConfig}from"vite";import{umc,singleFile}from"slatehtml/umc/vite";exportdefaultdefineConfig({// umc(): .umc → JS module (html/css inlined; preview stripped)// singleFile(): production build → one self-contained index.htmlplugins: [umc(),singleFile()],});import"./widgets/app/title-bar.umc";// registers <title-bar>import{defineUmc,emit,create,WidgetElement,installWidgetApi,stamp,bind,parseUmc,}from"slatehtml/umc";Inside .umc scripts, do not import defineUmc, the loader injects it.
The VS Code / Cursor extension is the component playground, live preview of .umc widgets. See Component playground.
npm run umc:link-vscodeThen Developer: Reload Window.
| Feature | How |
|---|---|
| Syntax highlighting | HTML / CSS / JS per section |
| Emmet | Works in html / preview blocks |
| Autocomplete | SlateHTML tags, attrs, hooks, APIs, sibling .umc tags, sorted above generic HTML. Inside events: { … }, suggests native keys and published names; OnClicked etc. come from that map. |
| Live preview | Title-bar icon, Ctrl+K V / Cmd+K V, or UMC: Open Preview to the Side |
Preview loads widget.css + widget.js + every .umc in the component’s folder (so composed children render). Re-renders as you type. Optional --- preview --- controls the demo markup; without it you get <your-tag></your-tag> with attrs defaults.
Workspace settings used by this repo:
{
"umc.preview.stylesheets": ["../matrix/discord.css"],
"html.customData": ["./slatehtml.html-data.json"],
"css.customData": ["./slatehtml.css-data.json"]
}import{enhance,enhanceTree,injectStyles,boot,start,configure,getSettings}from"slatehtml";enhance(el);// apply attrs on one elementenhanceTree(root);// walk a subtreeinjectStyles();// ensure stylesheet is in <head>boot();// enhance document + observe mutationsstart();// same as boot (alias for controlled init)configure({dragScroll: true});// click-drag scrollboxes (mouse/pen)getSettings();// { dragScroll }Per scrollbox: drag-scroll enables, drag-scroll="false" opts out. Touch still uses native panning. Drag can start on buttons (click only if you don't move); it skips pointer-owned controls (fields, sliders, menus) and selectable text glyphs (not empty padding in those boxes).
UI kit (slatehtml-ui/configure): icon provider and defaults for slate-icon.
import{configure,lucideSvg}from"slatehtml-ui/configure";import{fontAwesomeSvg}from"slatehtml-ui/icons/fontawesome";configure({iconSize: "18",iconStrokeWidth: "1.75",icons: (name,attrs)=>myIcon(name,attrs)??lucideSvg(name,attrs),// or: icons: fontAwesomeSvg, // FA names: user, fas:gear, fab:github});See the docs Settings page. Look (colors, chrome) stays CSS variables / kind, not configure().
Layout gallery (no Vite, all built-in tags). See Layout & positioning for a guided tour of each section:
npm run demoComponent gallery (Vite, built-in panels + every slatehtml-ui widget on one scroll page):
npm run uiMatrix client (sibling app at ~/dev/matrix):
cd ../matrix && npm install && npm run devUses matrix-js-sdk with slatehtml + slatehtml-ui.
| Path | Role |
|---|---|
widget.css / widget.js | Layout engine |
index.js / inject-styles.js | Default entry |
umc/ | .umc runtime + Vite plugin |
packages/slatehtml-ui/ | Reusable UI widgets (src/*.umc → dist/index.js via npm run build:ui) |
demo/ | Scrollable component gallery (npm run ui) |
editors/vscode-umc/ | Language extension |
test/ | Parser, preview, layout, reactivity tests |
slatehtml.html-data.json / slatehtml.css-data.json | Editor custom data |
MIT