Authoritative WebUI framework reference for generating correct application code - template-first authoring rules, template syntax, styling, interactivity, routing, state JSON, and anti-patterns.
WebUI Framework - AI Reference
Single-page reference for LLMs. Everything an AI coding assistant needs to
generate correct WebUI code. Read the Rules first - they are the constraints
that most often get violated. Deep-dive links are indexed at the bottom.
Install this reference into your agent with
npx skills add microsoft/webui --skill webui-reference - see
AI Coding Agents.
Rules
These four rules decide almost every authoring question. When in doubt, re-read
them before writing code.
1. The template is the UI
All UI structure lives in .html template files. There is no other way to
create UI.
Never document.createElement(), innerHTML, insertAdjacentHTML,
appendChild, cloneNode, or new DOMParser() to build UI.
Show and hide with <if>. Repeat with <for>. Swap regions with <outlet>.
If the markup you need does not exist yet, add it to the template and gate it
with <if> - do not build it from JavaScript.
The single exception is mounting a lazily loaded component, which has no
template representation until it is fetched. See
Lazy component mounting.
2. CSS owns all styling and animation
All styling lives in .css files. All animation is declarative CSS.
Never el.style.color = ..., classList.add/remove/toggle,
setAttribute('style', ...), adoptedStyleSheets, or injected <style> tags.
To style from state, bind an attribute in the template and select on it in CSS:
?data-active="{{isActive}}" plus [data-active] { ... }.
Animate with transition, @keyframes, @starting-style, and view
transitions. Never element.animate(), requestAnimationFrame tweening,
setInterval timers, or a JavaScript animation library.
3. JavaScript is opt-in, and only for interactivity
A component needs no.ts file at all unless something interactive happens
in it. Scriptless components still render bindings, <if>, and <for> on the
server, and still activate for browser state and soft navigation.
WebUIElement, @observable, and @attr are optional. Add them only
when JavaScript actually reads or writes the value, or when the value is part
of the component's public API that another module sets.
A value that only appears in the template belongs in the server state JSON,
not in an @observable.
JavaScript is for event handlers, network calls, focus management, and
imperative browser APIs. Nothing else.
4. Use the web platform
Prefer a built-in HTML element or modern CSS feature over a hand-built one.
<dialog> over a div-based modal. popover over a JS dropdown.
<details> over a JS accordion.
BUILD TIME SERVER RENDER CLIENT HYDRATION
--------------- --------------- -----------------
HTML + CSS + TS -> protocol.bin -> Web Components
webui build + JSON state hydrate as islands
-> rendered HTML
WebUI is a language-agnostic server-side rendering framework. Templates
compile to a binary Protocol Buffer at build time. At runtime any backend
(Rust, Node, Go, C#, Python) supplies JSON state and produces HTML. On the
client, interactive components hydrate as islands.
Every template binding should exist in the server state JSON. If the
template uses {{title}}, the server must provide { "title": "..." }.
Missing text and attribute paths render empty. A missing condition identifier
is falsy, so path is false and !path is true. No error is raised.
Derived state belongs in the template or the server. Use expressions like
items.length or status == 'active'. Compute complex values server-side.
The server is the source of truth for the initial render. The client
takes over after hydration for user interactions.
Scriptless components are dormant, not dead. Their bindings render on the
server and contribute no initial bootstrap state. Compiler-owned hosts can
still activate for browser-applied state, parent property writes, or soft
navigation. Events and lifecycle code need a same-named .ts or .js.
Hydration state is client-facing. It reduces CPU and bytes but is not a
secrecy boundary. Never put credentials or private tokens in render state.
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 <- Optional: only if interactive
| \- static-widget/
| |- static-widget.html <- Scriptless: no .ts needed
| \- static-widget.css
|- data/state.json <- Server state for dev
\- dist/
Component discovery:
HTML files with a hyphen in the name are components
(my-card.html -> <my-card>).
CSS files with the same name are auto-paired.
A same-named .ts or .js file opts the component into authored behavior.
Most components should not have one.
Discovery is recursive through subdirectories.
Template syntax
HTML structure
Write browser-valid HTML nesting. Native void tags are matched
case-insensitively, and direct <col> / <tr> runs receive the browser-implied
<colgroup> / <tbody> in compiled client metadata. When an <if> or <for>
controls table columns or rows, author the corresponding <colgroup> or
<tbody> explicitly so both SSR hydration markers stay in the same browser
parsing context.
Constraints: max 5 logical operators per expression; cannot mix && and
|| in one expression; no parentheses for grouping; no ternary; no
arithmetic.
Each side of a comparison is either a literal (number, quoted string, true,
false) or a dotted state path. Anything else is read as a path, so
{{currentIndex == items.length - 1}} looks up a key literally named
items.length - 1, finds nothing, and the condition is silently false. Have the
server send a precomputed lastIndex (or isLast) instead. Bare .length on an
array or string does work: <if condition="items.length > 3">.
Loops
<foreach="item in items"><div>{{item.name}} - {{item.price}}</div></for><foreach="item in reorderableItems"><todo-rowkey="{{item.id}}"title="{{item.title}}"></todo-row></for>
The collection must be a JSON array.
Nested loops are supported; outer loop variables remain accessible.
Repeats reconcile by array position by default; item attributes never act as
keys, and data-key is an ordinary application attribute.
Add compiler-only key="{{item.id}}" to the first concrete child to preserve
identity across reorder. Leading <if> wrappers are transparent; key
directly on <if>, <for>, or <outlet> is invalid.
key="{{item}}" supports arrays of unique string or finite-number primitives.
Key paths must be rooted at the loop variable or the build fails with
invalid-for-key. Duplicate or invalid runtime keys warn once and fall back
to positions.
Components inside loops do NOT inherit loop variables. Pass data via
attributes:
<foreach="contact in contacts"><contact-cardname="{{contact.name}}"email="{{contact.email}}"></contact-card></for>
Attributes
<!-- Dynamic attribute --><ahref="{{url}}">{{linkText}}</a><!-- Boolean attribute (rendered when truthy, omitted when falsy) --><button ?disabled="{{isLoading}}">Submit</button><inputtype="checkbox" ?checked="{{isSelected}}" /><!-- Boolean attributes accept the same expressions as <if condition="...">.
Compare against existing state instead of creating mirror observables. --><button ?disabled="{{currentIndex == 0}}">Prev</button><button ?disabled="{{currentIndex == lastIndex}}">Next</button><option ?selected="{{item.id == selectedId}}">{{item.name}}</option><!-- Mixed static + dynamic --><imgsrc="/img/{{user.avatar}}"alt="{{user.name}}" /><!-- Complex/property binding --><my-widget:config="{{settings}}"></my-widget>
?attr is also the styling hook. Bind a data-* attribute and select on it in
CSS rather than touching classList from JavaScript.
Property bindings use : to write directly to DOM properties. For
client-created trees, initial property bindings are applied before a child's
connectedCallback runs, so children can read parent-provided values during
setup and still receive later updates through the live binding.
Handler arguments can be e, dotted component or repeat-scope paths, or
string/number/boolean/null literals. Nested JavaScript expressions are not
parsed in templates. An @event requires a .ts file on that component.
Each @event gets its own listener on the element it is written on - bindings
are never delegated to a shared root. Non-bubbling events (@focus, @blur,
@mouseenter, @load, @error, @toggle) therefore work, and an ancestor's
bubble-phase stopPropagation() cannot suppress them.
DOM references
<inputw-ref="{searchInput}"type="text" />
The braces are required. A non-braced w-ref="searchInput" fails the build
with invalid-w-ref. Declare the property in the class:
searchInput!: HTMLInputElement;
Use w-ref only for imperative browser APIs - focus(), scrollIntoView(),
showModal(), showPopover(), measurement. Never use it to read or write
state that a template binding could express.
w-ref is scalar. Reusing one ref name inside <for> overwrites the same
property, so the last wired occurrence wins; repeat key and position do not
create an indexed ref collection. For item lookup, use a stable authored id
with Document.getElementById() / ShadowRoot.getElementById(), or put the ref
inside an item component.
The <template> tag
Unwrapped components default to Shadow. In a --dom light build they use
authored/global Light DOM:
A sole bare top-level <template> is also an explicit Light wrapper and is
unwrapped, even when the build fallback is Shadow. Templates with attributes or
w-render/w-hydrate are not mode selectors; nested templates remain inert
template content.
In a Light build, use a sole top-level
<template shadowrootmode="open"> when the component must remain Shadow for a
native <slot>, native isolation, CSS-heavy frequent restyling, or root host
events:
<!-- todo-app.html --><templateshadowrootmode="open"
@toggle-item="{onToggleItem(e)}"
@delete-item="{onDeleteItem(e)}"
><foreach="item in items"><todo-itemid="{{item.id}}"></todo-item></for></template>
The wrapper must contain the complete component. closed, a dynamic or invalid
value, placement on another element, multiple declarations, or extra top-level
content fails the build. <slot> is a build error only when the effective mode
is Light.
Root host events catch custom events bubbling up from child components. To
cross any Shadow boundary between the child and the listener, an event must
bubble and be composed; this.$emit() always sets both so Light components
nested in a Shadow tree work too. A hand-built new CustomEvent(name) defaults
to neither and will never reach the root - pass
{ bubbles: true, composed: true } or bind it on the child element.
The binding sits on the host element, so it also catches events targeted at the
host itself - what host-interactive components (host tabindex, presentational
shadow content) rely on. It does not see non-composed events (change,
submit, select, media); bind those per element.
One root listener also serves an arbitrarily large <for>, so this is the way
to trade per-row listeners for a single handler on a very long list. For rows
inside the shadow tree e.target is the host, so use e.composedPath()[0] to
find the element that was hit.
Keep ordinary paired component CSS. Light CSS is authored/global in its owning
Document or ShadowRoot; selectors, keyframes, and cascade layers are not
rewritten. Shadow components retain native Shadow CSS scoping. No CSS-in-JS or
styles written from script.
:host styles the component root only in Shadow DOM. In Light DOM, target
the component tag directly, for example my-card or my-card[disabled].
Light DOM preserves normal inheritance and global cascade. Parent rules can
reach Light descendants and matching selectors can affect other Light
components in the same CSS tree.
:host, :host-context, and ::slotted fail with
unsupported-light-css in effective Light components; use ordinary
selectors or author an open Shadow root.
data-wl and data-wl-* are ordinary author attributes; WebUI no longer
generates or reserves them for Light CSS.
Use CSS custom properties for theming. Nested fallbacks like
var(--primary, var(--fallback)) are also discovered as tokens.
Malformed CSS fails the build, including unterminated var() calls,
comments, strings, and unmatched delimiters.
Reactive styling
State drives styling through bound attributes, never through script.
Animating in/out of display: none, popover, <dialog>
color-mix() / light-dark() / oklch()
Derived colors, dual themes without JS
@layer
Predictable cascade ordering
content-visibility: auto
Long lists without virtualization code
text-wrap: balance
Headline typography
:user-valid / :user-invalid
Form feedback without validation JS
Modern HTML to prefer
Element / attribute
Replaces
<dialog> + showModal()
Custom modal with overlay and focus trap
popover / popovertarget
JS dropdown, menu, tooltip, toast
<details><summary>
JS accordion or disclosure
<datalist>
JS autocomplete
inert
Manual tabindex juggling
loading="lazy" / decoding="async"
IntersectionObserver image loaders
<progress> / <meter>
Div-based bars
Native constraint validation
Hand-rolled validators
Progressive enhancement only
These are not Baseline across all engines. Use them as an enhancement layer that
degrades cleanly, never as load-bearing layout or behavior.
Feature
Guard with
anchor-name / position-area
@supports (anchor-name: --x), fall back to static placement
Scroll-driven animations
@supports (animation-timeline: view())
field-sizing: content
A sensible fixed width / rows default
text-wrap: pretty
Harmless when ignored
Customizable <select> / <selectedcontent>
The native <select> rendering
Never polyfill these in JavaScript. If the fallback is unacceptable, use a
Baseline approach instead.
Animation
Animation is CSS. There is no supported JavaScript animation path.
/* Enter and exit for a popover or dialog */.menu-panel {
transition: transform 200ms ease, overlay 200ms allow-discrete,
display 200ms allow-discrete;
transform: translateX(-100%);
}
.menu-panel:popover-open {
transform: translateX(0);
}
@starting-style {
.menu-panel:popover-open {
transform: translateX(-100%);
}
}
@media (prefers-reduced-motion: reduce) {
.menu-panel { transition: none; }
}
Always honor prefers-reduced-motion.
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.
Declare the animations in the entry template's document-level <style>.
::view-transition-* pseudo-elements live on the document root and cannot be
reached from inside a shadow root:
<!-- index.html --><style>
::view-transition-old(page-content) {
animation: 200ms ease-out both fade-out;
}
::view-transition-new(page-content) {
animation: 300ms ease-in both fade-in;
}
@keyframes fade-in { from { opacity: 0; } }
@keyframes fade-out { to { opacity: 0; } }
</style>
While the router is active it installs a nonce-bearing
@view-transition { navigation: none; } override, because automatic
cross-document transitions conflict with intercepted routes that fall back to
SSR document requests. Router.destroy() removes the override. The router
awaits updateCallbackDone (not .finished) so rapid navigations supersede
each other without queuing.
Interactivity
Start scriptless
Most components should have only .html and .css. A scriptless component
renders bindings, <if>, and <for> on the server and contributes no bootstrap
state to the client.
<!-- user-card.html - no .ts file, nothing to add --><h2>{{user.name}}</h2><p>{{user.title}}</p><ifcondition="user.isAdmin"><spanclass="badge">Admin</span></if>
When to add a .ts file
Add one only when at least one of these is true:
The template has an @event handler.
The template has a w-ref you need for an imperative browser API.
You need connectedCallback / disconnectedCallback lifecycle work.
You need to fetch data or call a browser API.
The component exposes imperative methods or a public property API.
If none apply, do not create the file.
When to add @observable or @attr
Same test, one level down. These decorators exist to connect a value to
JavaScript - not to make it render.
@observable - only when TypeScript in this component reads or writes the
value after hydration. A value that is rendered once and never changed on the
client belongs in the server state JSON.
@attr - only when the value is part of the component's public API:
another component, a parent template, or the router sets it as an HTML
attribute.
// Justified: the click handler mutates it, the template renders it.@observable count = 0;
increment(): void { this.count += 1; }
// Justified: a parent template sets label="..." on this element.@attr label = '';
// NOT justified: nothing in TypeScript touches it - put it in state JSON.@observable heading = 'Welcome';
// NOT justified: mirrors an expression the template can evaluate.@observable hasItems = false; // use <if condition="items.length">@observable prevDisabled = true; // use ?disabled="{{currentIndex == 0}}"
This combines visibility-deferred hydration with content-visibility: auto.
The reservation is required and should approximate one instance's normal block
size. Use <template w-hydrate="lazy"> only when hydration should defer but
rendering containment is unsafe.
For one offscreen SSR island whose module graph should remain unloaded until
use, explicitly override the hydration trigger:
This keeps content-visibility active while interaction defers JavaScript and
heap. It is a singleton interaction boundary, not a repeated-item policy.
Visible app shells should use w-hydrate="interaction" and put
w-render="lazy" on offscreen descendant component types. Never combine
w-render="lazy" with w-hydrate="lazy" because it is redundant.
Import the optional coordinator once before component definitions:
On an instance, w-hydrate="eager" keeps rendering deferral but hydrates
immediately; w-render="eager" disables both. Use hydratedCallback() for work
that requires bindings or refs. Missing coordinator or browser support falls
back to eager hydration. Visibility-deferred hydration does not delay image
fetching; use native loading="lazy" and reconcile an already-complete w-ref
image from hydratedCallback() when component state depends on @load or
@error.
To defer a routed application until interaction, mark its root template:
The compiler marks the root. Hover stores one bounded raw route partial;
pointer/focus/keyboard/click intent imports components and router concurrently;
navigation adopts the response without refetching or parsing templates early.
Non-router apps use installInteractionHydration({ load }). This policy trades
first-interaction latency for lower startup JS/heap and cannot preserve
transient user activation or closed-shadow click targets.
The router remains framework-agnostic: FAST or any other runtime starts through
onIntent and passes the same prepared handle after its own hydration is ready.
Decorator
Purpose
SSR?
Triggers DOM update?
@attr
HTML attribute reflection
Yes; an existing SSR host attribute wins
Yes
@attr({ mode: 'boolean' })
Boolean attribute (present/absent)
Yes; host presence wins
Yes
@observable
Reactive state used by TypeScript
Yes (from JSON state)
Yes
Method / property
Description
this.$emit(name, detail?)
Dispatch a bubbling CustomEvent
this.$update()
Force a reactive update cycle
this.$flushUpdates()
Synchronously flush pending updates
protected hydratedCallback()
Run synchronously once after the first successful hydration or client mount
static define(tagName)
Register as a custom element
defineComponentAssets(manifest)
Lazy component asset graphs from stable URLs or bundler importer callbacks, with compiler-owned Shadow Link preloading through preload(tag) / create(tag)
Importing a component module registers it as a custom element, which triggers
hydration. Nothing else is required.
The hydration boundary
Never write @observable values before hydration. During SSR hydration the
server-rendered DOM is trusted and not re-rendered, so a value set in a field
initializer, the constructor, or before super.connectedCallback() cannot
reach the DOM. The write is dropped and the runtime logs a [WebUI] Hydration mismatch warning (development-only; stripped from production via
__WEBUI_DEV__).
If the value must appear in the first render, put it in the SSR state JSON.
Otherwise assign it in hydratedCallback(). On buffered SSR and client-created
mounts, super.connectedCallback() hydrates synchronously, but streamed hosts
and visibility-policy hosts (without an eager instance override) can return
while still deferred.
hydratedCallback() is the cross-mode signal: it runs synchronously exactly
once after the first successful hydration or mount, and reconnects or callback
exceptions do not retry it. Once a host has deferred, later state writes
are retained and replayed; this exception does not make constructor or
pre-super.connectedCallback() writes safe.
Load buffered definitions through a parser-inserted, non-async ES module script
or a classic defer script. Descendants must not structurally mutate a
containing WebUI component's SSR subtree before it hydrates - insertion,
removal, or reordering shifts compiled element indices.
Progressive streaming hydration
<boundary> is a compile-time checkpoint directive for progressive sessions.
It is valid in entries and reusable components, including runtime conditions,
outlets, and selected route content.
name is required, non-empty, static, and unique within its entry or
component owner. It
cannot contain a {{binding}}.
Boundaries may appear inside reusable components, true <if> paths, and
selected route content. Authored boundaries may not contain another authored
boundary directly or transitively.
A boundary-bearing subtree reached from a <for> body fails with
boundary-in-repeat, including declarations reached through a component,
condition, route, or outlet. A <for> may be wholly inside one boundary, and
boundaries before or after a <for> are valid.
A component-owned declaration reached from multiple static callsites in one
entry traversal requires key; it must resolve to a unique live string or
finite number. Independent entries that each reach it once do not.
Never author <webui-hydrate>. It is reserved generated runtime output.
Put the async application module in <head> before boundary content and
import @microsoft/webui-framework/streaming.js before component
registration modules.
start(state), resume(instanceId, state, mode), and advance() return a
step with bytes, optional runtime descriptor
{ instanceId, declarationId, owner, name, key }, and done.
Drive the step state exactly: descriptor present means resume; no descriptor
with done == false means advance; done == true means complete.
resume writes only the pending occurrence through its checkpoint.
advance writes the following parent or shell bytes through the next
occurrence or terminal. No sibling boundary is needed to split an early
component child from its parent tail.
update(instanceId, patch) accepts only a committed updatable occurrence.
It is valid between that occurrence's resume and advance, and calls
setState() without inserting markup, rerunning hydration, or rerunning
hydratedCallback().
State resolution across a suspension is lexical locals, resume state, then
the frozen projected parent state.
render_streaming projects its one state value once for the complete
response. Host-driven stream_response sessions use each state as
an overlay for newly resolved occurrence data.
Malformed directives use stable diagnostics:
missing-boundary-name, invalid-boundary-name,
duplicate-boundary-name, missing-boundary-key,
invalid-boundary-key, nested-boundary, boundary-in-repeat,
boundary-crosses-scope, and authored-webui-hydrate. Malformed browser
records fail closed and release discoverable deferred state within fixed
bounds.
Lazy component mounting
This is the only sanctioned place to insert an element from JavaScript,
because a lazily loaded component has no template representation until fetched.
Prefer the template-driven form, which needs no DOM insertion at all:
With the framework loaded, ensureLoaded() also waits for bounded Link
stylesheet cache warming or a native-link fallback decision. Warmup bytes are
never applied; the first mounted instance remains guarded until the browser
validates its native link and can seed the shared constructable sheet. Redirect,
service-worker, authored <style>, or inaccessible-CSSOM cases keep the native
link rather than risking different response-base or cascade semantics.
If an authoritative native link fails, WebUI reports the error, keeps the link
native, releases the temporary guard, and completes hydration so the component
remains visible and usable even if it is unstyled.
The compiler stores final Link stylesheet hrefs in the protocol. Shadow builds
publish them as inert head JSON that preload(tag) consumes automatically;
Light builds emit them as document stylesheets. Authored code therefore keeps
only the stable root asset URL and never invents or hardcodes a content-hashed
stylesheet filename.
Automatic Shadow intent preloading requires HTML rendered through the WebUI
handler or Protocol, which emits #webui-component-assets. Using build
artifacts without rendering the protocol preserves the guarded native mount but
does not provide early compiler-owned style preloading.
Assets keep entry-owned templates external, inline dependencies used by one
asset root, and split dependencies shared by multiple roots into deduplicated
dynamic chunks. Do not copy generated chunk filenames into the manifest; each
root asset carries its own dynamic imports. create(tag) waits for the template
graph and module, then creates the element. Failed asset or authored module work
is evicted so a later preload(tag) or create(tag) retries. The normal entry
bundle must load first. Component assets cannot be combined with <route>; use
the router for routed components.
Initial SSR delivers CSS only for the matched route chain; inactive route
styles remain deferred until navigation. Matched route CSS targeting the
Document is applied before </head>. ShadowRoot-targeted Link CSS is preloaded
from the head and applied inside its owning root. Static request-reachable
Shadow roots are preloaded the same way.
FAST 2/3 plugins require effective Shadow components. Any effective Light
component fails with fast-light-dom-unsupported; use the WebUI plugin for
global Light DOM.
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
query
"action,to,subject"
Allowlist of query params set as attributes (deny-by-default)
keep-alive
(boolean)
Preserve DOM and local state across navigations
cache-tags
"thread:{threadId},inbox"
Cache tag templates resolved at render time
invalidates
"inbox,sent,counts"
Tags auto-invalidated after mutation actions
pending
"loading-skeleton"
Loading UI for slow navigations (>150ms)
error
"error-display"
Error boundary on fetch failure
All attributes are validated at build time. Referencing a non-existent pending
or error component is a compile error.
Route loaders (static loader({ params, query, signal })) and actions
(static action({ formData, params, signal }), enabled by
Router.start({ actions: true })) live on the component class. Cache and
preload are optional runtime tiers; a default Router.start() does not load the
cache module.
Every route intended for partial navigation must register a custom element.
Scriptless templates are registered by the compiler-owned host runtime. If a
route remains unregistered after template publication and loader resolution, the
router navigates the document so the server can render it.
Missing paths: text bindings render empty. In conditions, a missing
identifier is falsy, so path is false and !path is true. No error.
Route-scoped state. Each route handler should return only the keys that
route's template binds to. Sending full app state on every route wastes
bandwidth and render time.
Reserved $webui state
The top-level "$webui" object is reserved for trusted host HTML emitted at
document boundaries:
{"$webui":{"headEnd":"<link rel=\"preload\" as=\"image\" href=\"/hero.avif\">","bodyStart":"<!-- immediately after <body> -->","bodyEnd":"<script src=\"/livereload.js\"></script>"}}
All three members are optional strings. headEnd, bodyStart, and bodyEnd
are emitted raw immediately before </head>, after <body>, and before
</body>, respectively. Missing, empty, null, or non-string members are
ignored. WebUI strips the reserved object from hydration and partial-navigation
state, so client-side templates and code cannot read it.
Never put request-derived or otherwise untrusted content in $webui. The
values are not escaped and can create an XSS vulnerability. See
Integrations for host-specific rendering details.
Truthiness
Value
Truthy?
true
Yes
false
No
0
No
Non-zero number
Yes
"" (empty string)
No
"false" (string!)
Yes (non-empty string)
[] (empty array)
No (server) / Yes (client) - always use .length
{} (empty object)
No (server) / Yes (client) - test a real field
null / missing key
No
Never use the string "false" for boolean state. Use real booleans.
Never test a bare array or object for truthiness. The server evaluator treats
empty collections as falsy; the compiled client condition is plain JS !!value,
where [] and {} are truthy. Writing <if condition="items"> means SSR and
hydration can disagree. Write <if condition="items.length"> instead - that
agrees on both sides.
<!-- RIGHT --><ifcondition="items.length">...</if><button ?disabled="{{currentIndex == 0}}">Prev</button><option ?selected="{{app.slug == currentApp.slug}}">{{app.name}}</option>
Loop variables compose with outer component state in the same expression, so
per-item flags like isCurrent in the SSR JSON are almost never needed.
Text bindings do path lookups only. If you need {{currentIndex + 1}} for a
1-based display, that is a legitimate @observable or a precomputed state key.
Reading DOM instead of state
// WRONGconst value = this.shadowRoot.querySelector('.count').textContent;
Use @observable for state TypeScript changes, template bindings for output,
and w-ref only for imperative APIs.
Hand-built platform primitives
<!-- WRONG --><divclass="modal-backdrop" @click="{close()}"><divclass="modal"role="dialog">...</div></div><!-- RIGHT --><dialogw-ref="{dialogEl}" @close="{onClose()}">...</dialog>
showModal() gives focus trapping, inert backdrop, Escape handling, and
top-layer stacking for free.
Also not supported
No ternary in templates.{{x ? 'yes' : 'no'}} does not work.
No function calls in bindings.{{formatDate(item.date)}} does not work.
Compute on the server or in an event handler.
No mixed && and || in one condition. Split into nested <if> blocks.
No parentheses in conditions.
No JavaScript in HTML templates. Templates compile to binary.
No JavaScript in CSS. Use custom properties for dynamic values.
No computed getters for SSR state.
Components inside <for> do NOT inherit loop variables.
No import or require in templates. Components are discovered by file
naming convention.
Non-braced w-ref fails the build. Use w-ref="{name}".
Pre-flight checklist
Before emitting WebUI code, confirm:
No createElement, innerHTML, appendChild, or insertAdjacentHTML,
except mounting a lazily loaded component.
No .style.x =, classList, setAttribute('style'), or
adoptedStyleSheets.
No element.animate(), requestAnimationFrame tweening, or animation
library. Animation is in .css.
Every .ts file exists because of an event, w-ref, lifecycle hook,
fetch, or public API - not by default.
Every @observable / @attr is read or written by TypeScript, or is
public API. Otherwise it moved to the state JSON.
No observable mirrors an expression the template can evaluate.
Every {{binding}}, <if>, and <for> path exists in the state JSON.
Every w-ref uses braces.
A built-in element was considered before a hand-built one
(<dialog>, popover, <details>).
::view-transition-* rules are in the entry template, not component CSS.
prefers-reduced-motion is honored wherever motion is used.
No conditions mix && with ||, use parentheses, or use a ternary.
Every native <slot> resolves to Shadow DOM; in a --dom light build its
component authors a sole top-level <template shadowrootmode="open">.
A sole bare top-level is an explicit Light wrapper and is
unwrapped even when the build fallback is Shadow.
Build and run
# Dev server with live reload
webui serve ./src --state ./data/state.json --plugin=webui --watch
# Production build
webui build ./src --out ./dist --plugin=webui
# Inspect the compiled protocol
webui inspect ./dist/protocol.bin
Common flags on both commands: --entry, --css <link|style|module>,
--dom <shadow|light> (default shadow),
--css-bundle (merge component stylesheets into shared chunks; not valid with
--css module), --components, --theme,
--projection-manifest, --emit-component-assets, --metafile,
--format json.
Unwrapped components default to Shadow. Under --dom light, they use
authored/global Light DOM while a sole top-level
<template shadowrootmode="open"> remains a Shadow island. A sole bare
<template> explicitly selects Light and is unwrapped.
Authoring mistakes fail the build with a structured diagnostic carrying a stable
code, source location, snippet, and a help: fix. Branch on the code, never
the message. --format json emits one JSON object per error on stdout.
x error: invalid <for> each expression [invalid-for-each]
--> index.html:67:5
each="person inpeople"
help: use the form each="item in collection", e.g. each="todo in todos"
Full flag tables, exit codes, and the error-code list:
CLI Reference.
WebUI Press named regions
In a WebUI Press template, use paired regions for fallback markup and
self-closing regions for empty insertion points:
A matching regions entry in .webui-press/config.json may replace or clear
the HTML, add object state beneath the dotted regions.* path, and add a
page-scoped scriptFile. Omit html/htmlFile to retain the fallback. Regions
resolve before component discovery and compilation; do not manually register
their components elsewhere. See WebUI Press named regions
for the stable built-in region list and full configuration contract.
FAST authored templates. The fast-v2 and fast-v3 plugins are pinned to
FAST major versions 2 and 3, respectively; fast is a deprecated alias for
fast-v2. With either versioned plugin, a component file authored as one
<f-template name="..."> wrapping one direct inner <template> is recognized:
a non-empty name sets the component tag (else the filename without
.template.html is kept), <f-repeat> and <f-when> provide repetition and
conditions, and client bindings (@event, :property, f-ref, f-slotted, f-children) may
be authored directly on the root <template>. The verbatim FAST filename
<component>.template.html is discovered even though its stem has no hyphen,
registering under the authored name. In npm packages, the FAST discovery
plugin uses Custom Elements Manifest declarations and generated sibling
<component>.template.html files; optional styles use
<component>.styles.css or <component>.css. Wrapper shadow options include
shadowrootmode and shadowrootdelegatesfocus. A leading generated
{{styles}} marker is reserved for CSS injection. A directive takes only
value; unsupported FAST syntax fails the build. Without a FAST plugin,
<f-template> markup passes through unchanged. See
Plugins for the complete public authoring and
package-layout contract.
Add @microsoft/webui-router for client-side navigation. Passing
--projection-manifest narrows hydration state to exactly the @observable and
@attr fields your bundle uses; omitting it keeps full state.
Server integration
Any backend loads protocol.bin once and renders with JSON state per request.
For one Rust endpoint that serves both initial documents and router partials,
pass the complete per-response options through ServeRequest. Always attach a
fresh document CSP nonce before calling the helper:
For Rust progressive hydration, create a StreamingResponse over a
FlushWriter. Keep one bounded worker as the response owner, cap admitted
renders before spawning it, and configure the transport's flush timeout.
Honor HTTP write backpressure and cap concurrent streams. The CLI uses a
capacity-one command channel and matches each boundary target by
descriptor owner, name, and optional key (plus optional
declarationId). It keeps response-local instance IDs, the compiled protocol,
and browser-facing bytes in Rust. A resume control commits boundary-only bytes;
the CLI then calls advance internally for the following parent bytes. The
control stream needs no advance record. After the backend sends the resume for
the final descriptor and closes its body, the CLI's final advance completes
the response. Returning JSON keeps the buffered state path.
If the backend refuses a stream request (non-success status such as a 503
from its own concurrency cap), no response bytes were sent, so webui serve logs
one warning and renders the page from fallback state rather than replacing the
app with the upstream error body. A failure after the stream is live still
fails the response, because bytes already flushed cannot be rewound.
Equivalent APIs exist for WebAssembly, Python (native microsoft-webui
package), Go (cgo), and C#. For Router.ensureLoaded(), expose
GET /_webui/templates?t=tag1,tag2 backed by
render_component_templates(&tags, &inv).
A component-local boundary uses generated parent spans. Its early marked child
may hydrate before the opaque parent tail in light or shadow DOM.
webui:boundary-hydrated is emitted only when
window.__WEBUI_STREAMING_DEBUG__ === true; its detail.kind is
checkpoint, span, update, or terminal. Every commit also emits an
unconditional performance.mark() (webui:boundary:<id>,
webui:boundary:<id>:update, webui:span:<id>,
webui:streaming:terminal) that tooling can read retroactively.
webui:hydration-complete fires only after the terminal record and eager
pending hydration work complete. Visibility-deferred lazy roots do not keep
this one-shot startup event open.
window.__WEBUI_STREAMING_SLICE_MS__ opts into a time-sliced drain that
yields between boundaries. Use it only when an intermediary coalesces the
response into one chunk; it costs total hydration time.
A semantic flush hands bytes to the HTTP transport. Server adapters,
compression, proxies, and CDNs can still buffer them.