WebUI Framework - AI Reference #
Single-page reference for LLMs. This page contains everything an AI coding assistant needs to generate correct WebUI Framework code. It covers the SSR model, template syntax, component authoring, CLI commands, and known constraints. Bookmark this page and feed it to your AI when working with WebUI.
What is WebUI Framework? #
WebUI is a language-agnostic server-side rendering framework. Templates are compiled to a binary Protocol Buffer at build time. At runtime, any backend (Rust, Node, Go, C#, Python) fills in JSON state and produces HTML. On the client, interactive Web Components hydrate as islands.
Key facts an AI must know:
- The server renders HTML from a compiled binary + JSON state. No JavaScript runs on the server.
- Only interactive components ship JavaScript to the browser.
- Templates are declarative: HTML for structure, CSS for styling, TypeScript for behavior. They are always in separate files.
- There is no JSX, no CSS-in-JS, no template literals, no virtual DOM.
The SSR Mental Model #
BUILD TIME SERVER RENDER CLIENT HYDRATION
โโโโโโโโโโโโโ โโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโ
HTML + CSS + TS โ protocol.bin โ Web Components
webui build + JSON state hydrate as islands
โ rendered HTML
Rules #
Every template binding must exist in the server state JSON.If your template uses
{{title}}, the server must provide{ "title": "..." }.Derived state belongs in the server or the template. Use template expressions like
items.lengthorstatus == 'active'for simple derivations. For complex values, compute on the server.The server is the source of truth for the initial render. The client takes over after hydration for user interactions.
Static content never ships JavaScript. Only components with event handlers, reactive state, or user input need client-side code.
Project Structure #
my-app/
โโโ src/
โ โโโ index.html โ Entry template
โ โโโ index.ts โ Hydration entry point
โ โโโ my-component/
โ โ โโโ my-component.html โ Component template
โ โ โโโ my-component.css โ Component styles (scoped)
โ โ โโโ my-component.ts โ Component behavior
โ โโโ other-widget/
โ โโโ other-widget.html
โ โโโ other-widget.css
โ โโโ other-widget.ts
โโโ data/
โ โโโ state.json โ Server state for dev
โโโ package.json
Component discovery rules:
- HTML files with a hyphen in the name are components (
my-card.htmlโ<my-card>) - CSS files with the same name are auto-paired (
my-card.css) - TypeScript files provide client-side behavior (
my-card.ts) - Discovery is recursive through subdirectories
The Tag #
The <template shadowrootmode="open"> wrapper is optional in component
HTML files. The build tool auto-injects it when absent.
Omit it for most components (the framework wraps your content automatically):
<!-- my-card.html -->
<h2>{{title}}</h2>
<p>{{description}}</p>
Include it when you need root host events on the shadow root itself:
<!-- todo-app.html -->
<template shadowrootmode="open"
@toggle-item="{onToggleItem(e)}"
@delete-item="{onDeleteItem(e)}"
>
<for each="item in items">
<todo-item id="{{item.id}}"></todo-item>
</for>
</template>
Root host events catch custom events bubbling up from child components. This is the delegated event pattern for parent-child communication.
Template Syntax #
Text binding #
<span>{{user.name}}</span>
<p>{{items.length}} items</p>
{{expr}}- HTML-escaped output (safe for user input){{{expr}}}- raw/unescaped output (only for trusted content)
Conditionals #
<if condition="isLoggedIn">
<p>Welcome back, {{username}}!</p>
</if>
<if condition="!hasItems">
<p>No items found.</p>
</if>
<if condition="status == 'active'">
<span class="badge">Active</span>
</if>
Supported operators: ==, !=, >, <, >=, <=, &&, ||, !
Constraints:
- Maximum 5 logical operators per expression
- Cannot mix
&&and||in the same expression - No parentheses for grouping
- No ternary operator (
? :)
Loops #
<for each="item in items">
<div>{{item.name}} - {{item.price}}</div>
</for>
- The collection must be a JSON array
- Nested loops are supported; outer loop variables remain accessible
- Components inside loops do NOT inherit loop variables. Pass data via attributes:
<for each="contact in contacts"> <contact-card name="{{contact.name}}" email="{{contact.email}}"></contact-card> </for>
Attributes #
<!-- Dynamic attribute -->
<a href="{{url}}">{{linkText}}</a>
<!-- Boolean attribute (rendered when truthy, omitted when falsy) -->
<button ?disabled="{{isLoading}}">Submit</button>
<input type="checkbox" ?checked="{{isSelected}}" />
<!-- Boolean attributes accept the same expressions as <if condition="...">.
Use comparisons against existing state instead of creating mirror observables. -->
<button ?disabled="{{currentIndex == 0}}">Prev</button>
<button ?disabled="{{currentIndex == items.length - 1}}">Next</button>
<option ?selected="{{item.id == selectedId}}">{{item.name}}</option>
<!-- Mixed static + dynamic -->
<img src="/img/{{user.avatar}}" alt="{{user.name}}" />
<!-- Complex/property binding -->
<my-widget :config="{{settings}}"></my-widget>
Events (client-side only) #
<button @click="{handleClick()}">Click me</button>
<input @keydown="{onKeydown(e)}" />
<div @mouseenter="{onHover()}" @mouseleave="{onLeave()}">Hover</div>
DOM references #
<input w-ref="searchInput" type="text" />
In the TypeScript class: searchInput!: HTMLInputElement;
Routes #
<route path="/" component="app-shell">
<route path="" component="home-page" exact />
<route path="users" component="user-list" exact />
<route path="users/:id" component="user-detail" exact />
</route>
Path & matching:
- Child paths are relative to parent (no leading
/) - Use
exacton leaf routes (no children) - Omit
exacton parent routes that have<outlet /> - Path params:
:id(required),:query?(optional),*path(catch-all)
Attributes on <route>:
| Attribute | Example | Description |
|---|---|---|
path | "users/:id" | URL path template (relative to parent) |
component | "user-detail" | Component tag to mount |
exact | (boolean) | Require exact path match (use on leaf routes) |
query | "action,to,subject" | Allowlist of query params set as component attributes (deny-by-default) |
keep-alive | (boolean) | Preserve DOM and local state across navigations |
cache-tags | "thread:{threadId},inbox" | Cache tag templates - {param} resolved at render time |
invalidates | "inbox,sent,counts" | Tags to auto-invalidate after mutation actions |
pending | "loading-skeleton" | Component for loading UI during slow navigations (>150ms) |
error | "error-display" | Component for error boundary on fetch failure |
All attributes are validated at build time. Referencing a non-existent pending or error component is a compile error.
State flow:
keep-aliveโ preserves DOM and local state. On reactivation, only param/query attrs are updated -setState()is NOT called- Route loaders:
static loader({ params, query, signal })on component class - fetches custom data instead of server state. Runs pre-commit. Falls back to server state on failure - Keep-alive + loader: DOM preserved, loader provides fresh data via
setState()on reactivation - Route actions:
static action({ formData, params, signal })on component class - handles<form method="post">. Returns{ invalidateTags?, state? }. Auto-invalidates cache with merged tags
Cache & preload:
- Preload on hover:
Router.start({ preload: true })- speculatively fetches on link hover - Tagged cache:
Router.start({ cache: { staleTime, gcTime, maxEntries } })- responses cached by path, tagged withcacheTags Router.invalidateTags(tags)- evict cache entries by tagRouter.invalidate(path?)- evict by path or all
Server headers:
X-WebUI-Inventoryheader: hex bitmask of loaded templates - server skips re-sending
Outlet #
<!-- Parent component template -->
<nav>...</nav>
<main><outlet /></main>
Component Class #
Every interactive component extends WebUIElement:
import { WebUIElement, attr, observable } from '@microsoft/webui-framework';
export class MyComponent extends WebUIElement {
// --- Decorators ---
// @attr: reflects to/from HTML attribute (kebab-case)
// String mode (default):
@attr label = 'Default';
// Boolean mode (present = true, absent = false):
@attr({ mode: 'boolean' }) disabled = false;
// @observable: reactive state, changes trigger DOM updates
@observable count = 0;
@observable items: Item[] = [];
// Derived state: computed in event handlers, not as a getter
@observable totalPrice = 0;
private recalcTotal(): void {
this.totalPrice = this.items.reduce((sum, i) => sum + i.price, 0);
}
// --- DOM refs (populated by w-ref="name" in template) ---
inputEl!: HTMLInputElement;
// --- Event handlers (referenced by @event in template) ---
onSubmit(): void {
const text = this.inputEl.value.trim();
if (!text) return;
this.items = [...this.items, { text, price: 0 }];
this.inputEl.value = '';
}
onKeydown(e: KeyboardEvent): void {
if (e.key === 'Enter') this.onSubmit();
}
// --- Custom events (child-to-parent communication) ---
onItemDelete(e: CustomEvent<{ id: string }>): void {
this.items = this.items.filter(i => i.id !== e.detail.id);
}
}
// Register as custom element
MyComponent.define('my-component');
Decorator reference #
| Decorator | Purpose | SSR? | Triggers DOM update? |
|---|---|---|---|
@attr | HTML attribute reflection | Yes (from JSON state) | Yes |
@attr({ mode: 'boolean' }) | Boolean attribute (present/absent) | Yes | Yes |
@observable | Reactive internal state | Yes (from JSON state) | Yes |
Component API #
| Method/Property | Description |
|---|---|
this.$emit(name, detail?) | Dispatch a CustomEvent that bubbles up |
this.$update() | Force a reactive update cycle |
this.$flushUpdates() | Synchronously flush pending updates |
setState(state) | Populate from router navigation state |
static define(tagName) | Register as a custom element |
Emitting custom events #
// Child component
this.$emit('item-selected', { id: this.id, name: this.name });
<!-- Parent template catches it -->
<child-component @item-selected="{onItemSelected(e)}"></child-component>
// Parent handler
onItemSelected(e: CustomEvent): void {
this.selectedId = e.detail.id;
}
Dynamic Component Loading #
Components like dialogs, overlays, and drawers are declared as routes but loaded on demand โ not during initial navigation. Declare them in the route tree so the build compiles them into the protocol:
<route path="/" component="app-shell">
<route path="" component="home-page" exact />
<route path="users/:id" component="user-detail" exact />
<!-- Compiled into protocol, but only loaded when needed -->
<route path="settings" component="settings-dialog" exact />
</route>
Then load on demand with Router.ensureLoaded before creating the element:
// Fetches template + CSS from /_webui/templates โ no FOUC
await Router.ensureLoaded('settings-dialog');
this.showSettings = true;
// Batch multiple in one request
await Router.ensureLoaded('modal-a', 'modal-b', 'drawer-c');
The component's template is not sent during initial SSR or partial
navigation โ collect_inventoryable_components only walks the matched
route, not siblings. Zero cost until requested.
If a user navigates directly to /settings (deep link), the component
renders normally in the outlet โ it works both ways.
Configure a custom endpoint if needed:
Router.start({
templateEndpoint: '/api/templates', // default: '/_webui/templates'
loaders: { ... },
});
Component CSS #
CSS is scoped per component via Shadow DOM. No CSS-in-JS.
/* my-component.css */
:host {
display: block;
padding: 1rem;
}
:host([disabled]) {
opacity: 0.5;
pointer-events: none;
}
:host([variant="primary"]) {
background: var(--colorBrandBackground);
}
/* Internal elements */
.header { font-weight: bold; }
.content { padding: 0.5rem; }
Rules:
:hoststyles the component root element:host([attr])styles based on attribute presence/value- Internal selectors are scoped to the shadow root
- Use CSS custom properties (
var(--name)) for theming - No styles leak in or out
Entry Template #
<!DOCTYPE html>
<html lang="en" dir="{{textdirection}}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{title}}</title>
</head>
<body>
<app-shell></app-shell>
<script type="module" src="/index.js"></script>
</body>
</html>
Hydration Entry Point #
// index.ts
import { WebUIElement } from '@microsoft/webui-framework';
// Import components to register them as custom elements.
// Registration triggers hydration automatically.
import './app-shell/app-shell.js';
import './user-card/user-card.js';
// Optional: listen for hydration completion
window.addEventListener('webui:hydration-complete', () => {
const total = performance.getEntriesByName('webui:hydrate:total', 'measure')[0];
console.log(`Hydration: ${total?.duration.toFixed(1)}ms`);
});
Hydration with Router #
// index.ts
import { WebUIElement } from '@microsoft/webui-framework';
import { Router } from '@microsoft/webui-router';
import './app-shell/app-shell.js';
// Start router after components are registered
Router.start({
loaders: {
'home-page': () => import('./pages/home-page.js'),
'user-list': () => import('./pages/user-list.js'),
'user-detail': () => import('./pages/user-detail.js'),
},
});
View Transitions #
The router wraps every client-side navigation in document.startViewTransition()automatically. Do not wrap Router.navigate() in your own startViewTransition()โ that would double-transition.
To customize the animation, assign view-transition-name to elements in your CSS
and target them with ::view-transition-old() / ::view-transition-new():
.content-area { view-transition-name: content; }
::view-transition-old(content) { animation: fade-out 100ms ease-out; }
::view-transition-new(content) { animation: fade-in 150ms ease-in; }
The router awaits updateCallbackDone (not .finished) so rapid navigations
supersede each other without queuing.
CLI Commands #
Build #
webui build ./src --out ./dist --plugin=webui
| Flag | Default | Description |
|---|---|---|
APP (positional) | . | App source directory |
--out <DIR> | required | Output directory |
--entry <FILE> | index.html | Entry HTML file |
--css <MODE> | link | link, style, or module |
--dom <MODE> | shadow | shadow or light |
--plugin <NAME> | none | webui or fast-v3 |
--components <PACKAGE> | none | Extra component sources (repeatable) |
Serve (dev server) #
webui serve ./src --state ./data/state.json --plugin=webui --watch
| Flag | Default | Description |
|---|---|---|
APP (positional) | . | App source directory |
--state <FILE> | required | JSON state file |
--port <PORT> | 3000 | Server port |
--watch | false | Enable live reload |
--servedir <DIR> | none | Static asset directory |
--entry <FILE> | index.html | Entry HTML file |
--css <MODE> | link | link, style, or module |
--dom <MODE> | shadow | shadow or light |
--plugin <NAME> | none | webui or fast-v3 |
--components <PACKAGE> | none | Extra component sources (repeatable) |
--api-port <PORT> | none | Proxy route requests to API server |
--theme <PACKAGE> | none | Design token theme (see below) |
Inspect #
webui inspect ./dist/protocol.bin
webui inspect ./dist/protocol.bin | jq '.fragments | keys'
- External Component Sources #
The --components flag discovers components from npm packages or local
directories outside your app folder. Repeatable.
What it accepts:
- npm package name -
@scope/my-widgetsormy-widget - Scoped prefix -
@scope(discovers ALL sub-packages undernode_modules/@scope/) - Local path -
./shared/componentsor/libs/ui-kit
Values starting with ., /, \, or a drive letter are treated as local
paths. Everything else is treated as an npm package name.
npm package requirements:
The package's package.json must have:
{
"name": "@scope/my-button",
"customElements": "./custom-elements.json",
"exports": {
"./template-webui.html": "./dist/template-webui.html",
"./styles.css": "./dist/styles.css"
}
}
| Field | Required | Purpose |
|---|---|---|
exports["./template-webui.html"] | Yes | Component HTML template |
exports["./styles.css"] | No | Component CSS |
customElements | Yes | Path to Custom Elements Manifest (provides tag name) |
Local path scanning works like app directory scanning: HTML files with hyphenated names are registered as components, matching CSS files are auto-paired.
Caching: npm results are cached at ~/.webui/cache/components/ and
invalidated when package.json changes. Local paths are always re-scanned.
# Single scoped package
webui build ./src --out ./dist --components @reactive-ui/button
# All packages under a scope
webui build ./src --out ./dist --components @reactive-ui
# Local shared library
webui build ./src --out ./dist --components ./shared/components
# Multiple sources
webui build ./src --out ./dist \
--components @reactive-ui \
--components ./shared/components
- Design Token Themes #
The --theme flag loads a token JSON file and injects resolved CSS custom
property declarations into the render state. Only available on webui serve.
What it accepts:
- Local JSON file -
./themes/dark.json - npm package -
@my-org/brand-tokens(looks fortokens.jsoninside the package) - npm package with subpath -
@my-org/brand-tokens/custom.json
How it works:
- Loads the JSON file (multi-theme or flat single-theme format)
- Filters tokens to only those actually used in your CSS (
var(--name)) - Expands transitive
var()references and detects cycles - Generates CSS declaration strings per theme
- Injects into state as
state.tokens.light,state.tokens.dark, etc.
Multi-theme format:
{
"themes": {
"light": {
"surface-page": "#ffffff",
"text-primary": "#111827"
},
"dark": {
"surface-page": "#171717",
"text-primary": "#fafafa"
}
}
}
Flat format (single theme, treated as "default"):
{
"surface-page": "#ffffff",
"text-primary": "#111827"
}
Template usage - use a comment-based signal to inject tokens:
<style>
:root {
<!--{{{tokens.light}}}-->
}
</style>
The handler resolves tokens.light from the state, outputting:
:root {
--surface-page: #ffffff;
--text-primary: #111827;
}
State JSON #
{
"title": "My App",
"user": { "name": "Alice", "role": "admin" },
"items": [
{ "id": "1", "label": "First", "done": false },
{ "id": "2", "label": "Second", "done": true }
],
"isAdmin": true,
"showBanner": false
}
Path resolution: title, user.name, items.0.label, items.length
Missing paths: text bindings render empty, <if> evaluates to false. No error.
Truthiness in Conditions #
| Value | Truthy? |
|---|---|
true | Yes |
false | No |
0 | No |
| Non-zero number | Yes |
"" (empty string) | No |
"false" (string!) | Yes (non-empty string) |
null / missing key | No |
Never use string "false" for boolean state. Use real booleans.
Things You CANNOT Do #
No ternary in templates.
{{x ? 'yes' : 'no'}}does not work. Use<if>blocks or boolean attributes instead.No function calls in bindings.
{{formatDate(item.date)}}does not work. Compute the value on the server or in an event handler.No mixed
&&and||.<if condition="a && b || c">is invalid. Split into nested<if>blocks.No parentheses in conditions.
<if condition="(a && b) || c">is invalid.No JavaScript in HTML templates. Templates are compiled to binary. Logic goes in the TypeScript class file, not the template.
No JavaScript in CSS. CSS is plain CSS in
.cssfiles. Use CSS custom properties for dynamic values.No computed getters for SSR state. If a value appears in the template, it must be in the server state JSON. Use
@observablewith explicit updates in event handlers.Components inside
<for>loops do NOT inherit loop variables.Pass data explicitly via attributes.No
importorrequirein templates. Components are discovered by file naming convention, not imports.No
this.querySelector()for reactive state. Use@observableand template bindings. Usew-refonly for imperative DOM access (focus, scroll, etc.).
Common Patterns #
Toggle visibility #
<button @click="{togglePanel()}">Toggle</button>
<if condition="isPanelOpen">
<div class="panel">Panel content</div>
</if>
@observable isPanelOpen = false;
togglePanel(): void { this.isPanelOpen = !this.isPanelOpen; }
List with add/remove #
<input w-ref="input" @keydown="{onKey(e)}" />
<for each="item in items">
<div>
{{item.text}}
<button @click="{remove(item.id)}">ร</button>
</div>
</for>
Note: remove(item.id) does not work as written because template event
handlers cannot pass arguments from loop scope. Instead, use a child
component that emits a custom event:
<for each="item in items">
<list-item id="{{item.id}}" text="{{item.text}}"
@remove-item="{onRemove(e)}">
</list-item>
</for>
Boolean attribute styling #
<button ?data-active="{{isActive}}" @click="{toggle()}">
{{label}}
</button>
button[data-active] { background: blue; color: white; }
button:not([data-active]) { background: transparent; }
Lazy-loaded dialog #
Declare the component as a route (so it's compiled), then load dynamically:
<!-- index.html โ settings-dialog is in the protocol but not navigated to -->
<route path="/" component="app-shell">
<route path="" component="home-page" exact />
<route path="settings" component="settings-dialog" exact />
</route>
// Shell component โ load template + CSS on demand
async onOpenSettings(): Promise<void> {
await Router.ensureLoaded('settings-dialog');
await import('./settings-dialog/settings-dialog.js');
this.showSettings = true;
}
<!-- Shell template โ create the element dynamically -->
<if condition="showSettings">
<settings-dialog @close="{onCloseSettings()}"></settings-dialog>
</if>
Derived state (prefer template expressions over shadow observables) #
Anywhere an expression works (<if condition="...">, ?boolAttr="{{...}}", text
bindings via path lookups), compare existing state directly. Do not introduce
extra observables that just mirror a comparison or sentinel of other state, and
do not bake per-item flags like isCurrent / isDisabled into the SSR JSON
when a single comparison can derive them.
<!-- DO: derive in the template -->
<if condition="items.length">
<span>{{items.length}} items</span>
</if>
<button ?disabled="{{currentIndex == 0}}">Prev</button>
<button ?disabled="{{currentIndex == totalItems}}">Next</button>
<for each="app in apps">
<option ?selected="{{app.slug == currentApp.slug}}">{{app.name}}</option>
</for>
<!-- DON'T: shadow observables that mirror a comparison -->
<!-- @observable hasItems = false; // mirrors items.length > 0 -->
<!-- @observable prevDisabled = true; // mirrors currentIndex == 0 -->
<!-- @observable nextDisabled = false; // mirrors currentIndex == totalItems -->
<!-- DON'T: per-item flags in JSON state that mirror a comparison -->
<!-- { "apps": [{ "slug": "...", "isCurrent": true }, ...] }
Just compare against the selected slug/id in the template. -->
Loop variables (e.g. app) compose with outer component state (e.g.currentApp) inside the same expression, so per-iteration flags are almost
never needed.
Text bindings only do path lookups โ they can't do arithmetic. If you need{{currentIndex + 1}} for a 1-based display, that's a legitimate @observable(or precomputed in the SSR state).
Route-scoped state #
Each route handler should return only the state that route's component needs:
// GET /inbox -> only inbox data
{ "threads": [...], "selectedFolder": "inbox" }
// GET /settings -> only settings data
{ "theme": "dark", "language": "en" }
package.json #
{
"scripts": {
"build": "webui build ./src --out ./dist --plugin=webui",
"dev": "webui serve ./src --state ./data/state.json --plugin=webui --watch"
},
"dependencies": {
"@microsoft/webui": "latest",
"@microsoft/webui-framework": "latest"
}
}
Add @microsoft/webui-router if using client-side navigation.
Language Integration (Server Side) #
WebUI renders from any backend. The server loads protocol.bin once
and renders with JSON state per request.
Rust #
let protocol = WebUIProtocol::from_protobuf(&fs::read("dist/protocol.bin")?)?;
let state = json!({ "title": "Home", "items": items_vec });
let mut handler = WebUIHandler::new();
handler.handle(&protocol, &state, &options, &mut writer)?;
Node.js #
import { render } from '@microsoft/webui';
const protocol = readFileSync('./dist/protocol.bin');
const html = render(protocol, JSON.stringify(state), 'index.html', req.url);
Python (FFI) #
ptr = lib.webui_render(html_bytes, json_bytes)
result = ctypes.cast(ptr, c_char_p).value.decode("utf-8")
lib.webui_free(ptr)
Go (cgo) #
ptr := C.webui_render(cHTML, cJSON)
defer C.webui_free(ptr)
result := C.GoString(ptr)
C# (P/Invoke) #
IntPtr ptr = webui_render(html, dataJson);
string result = Marshal.PtrToStringUTF8(ptr);
webui_free(ptr);
Server Template Endpoint #
For Router.ensureLoaded(), expose GET /_webui/templates?t=tag1,tag2:
let result = route_handler::render_component_templates(&protocol, &tags, &inv);
// Node native addon
const result = renderComponentTemplates(protocolBuf, JSON.stringify(tags), invHex);
// @microsoft/webui npm package
import { renderComponentTemplates } from '@microsoft/webui';
const result = renderComponentTemplates(protocolBuf, ['settings-dialog'], invHex);