Interactivity #
WebUI uses Islands Architecture for client-side interactivity. Each Web Component is a self-contained island with its own HTML template, scoped CSS, and TypeScript behavior. Only components that need interactivity ship JavaScript - everything else stays as static server-rendered HTML.
Component Files #
Every interactive component consists of three separate files. Templates are declarative - no JavaScript mixing.
my-counter/
โโโ my-counter.html โ Template (structure and bindings)
โโโ my-counter.css โ Styles (scoped via Shadow DOM)
โโโ my-counter.ts โ Behavior (TypeScript class)
- HTML defines what the component renders and where dynamic values appear
- CSS styles the component in isolation - Shadow DOM prevents leaking
- TypeScript defines JS-visible reactive properties, event handlers, and component logic
Components that do not need client-side behavior can omit the TypeScript file:
product-card/
โโโ product-card.html
โโโ product-card.css
Create a custom element only for an Interactive Island: event handlers, custom
lifecycle code, imperative methods, or state that TypeScript code reads or
mutates. @observable and @attr are optional; add them when JavaScript needs
to access the value or when the value is part of the component's public API.
When HTML-only components receive server or route state, make sure the browser
entry imports @microsoft/webui-framework somewhere. The framework root
installs the minimal static host runtime and only claims compiler-owned
HTML-only components whose templates need hidden state or observed host
attributes. Fully static HTML-only components stay as plain SSR DOM.
The Component Class #
Every interactive component extends WebUIElement and registers itself as a custom element:
import { WebUIElement, attr, observable } from '@microsoft/webui-framework';
export class MyCounter extends WebUIElement {
@attr label = 'Count';
@observable count = 0;
increment(): void {
this.count += 1;
}
}
MyCounter.define('my-counter');
The matching template (my-counter.html):
<button @click="{increment()}">
{{label}}: {{count}}
</button>
And scoped styles (my-counter.css):
:host {
display: inline-block;
}
button {
padding: 0.5rem 1rem;
font-size: 1rem;
cursor: pointer;
}
The <template> Tag #
The <template shadowrootmode="open"> wrapper is optional in component HTML files. The build tool auto-injects it when it is not present.
Without <template> (most components):
<!-- my-counter.html -->
<button @click="{increment()}">{{label}}: {{count}}</button>
The framework wraps this in a <template shadowrootmode="open"> during build.
With <template> (root host events):
<!-- todo-app.html -->
<template shadowrootmode="open"
@toggle-item="{onToggleItem(e)}"
@delete-item="{onDeleteItem(e)}"
>
<h1>{{title}}</h1>
<div class="todo-list">
<for each="item in items">
<todo-item id="{{item.id}}" title="{{item.title}}"></todo-item>
</for>
</div>
</template>
When you include the <template> tag explicitly, the framework uses yours instead of auto-injecting one. The main reason to include it is to attach root host events - event listeners on the shadow root itself that catch events bubbling up from child components (@toggle-item, @delete-item above). This is the delegated event pattern for parent-child communication.
Decorators define how properties behave and how they connect to the template.
@attr - HTML Attributes #
Use @attr for values passed from a parent element via HTML attributes. These are part of the component's public API.
String mode (default):
@attr label = 'Default Label';
<my-counter label="Items"></my-counter>
Boolean mode - attribute presence means true, absence means false:
@attr({ mode: 'boolean' }) disabled = false;
<!-- disabled = true -->
<my-button disabled></my-button>
<!-- disabled = false -->
<my-button></my-button>
@observable - Reactive State #
Use @observable for internal state that changes over time. When an observable value changes, the framework automatically updates any template bindings that reference it.
@observable count = 0;
@observable items: Item[] = [];
@observable isOpen = false;
Observable changes are synchronous and targeted - only the specific DOM nodes bound to the changed property are updated.
You do not need @observable for values that are only read by the template.
Add @observable when TypeScript code needs to read or mutate the value, for
example in an event handler.
Derived State #
For derived values like "has items?" or "total count", use template expressions directly instead of computed properties:
<!-- Use dot-path expressions in the template -->
<if condition="items.length">
<span>{{items.length}} items</span>
</if>
The condition evaluator supports dot paths (items.length), comparisons (count > 0), truthiness, and negation (!isEmpty). This keeps derived state declarative and works on both server and client.
For complex derived state that can't be expressed in template syntax, compute it on the server and provide it in the JSON state, or compute it in an event handler and store it in an @observable.
Template Syntax for Interactivity #
Reactive Text #
Use double curly braces to bind property values into the template:
<span>{{label}}: {{count}}</span>
<p>Hello, {{user.name}}!</p>
Event Binding #
Attach event handlers with @event syntax:
<!-- Call a method -->
<button @click="{increment()}">Add</button>
<!-- Access the event object -->
<input @keydown="{onKeydown(e)}" />
<!-- Pass repeat-scope values and literals -->
<for each="item in items">
<button @click="{selectItem(item.id, 'details', e)}">
{{item.name}}
</button>
</for>
<!-- Multiple events on one element -->
<div @mouseenter="{onHover()}" @mouseleave="{onLeave()}">
Hover me
</div>
Components that use @event must have authored .ts or .js code that
defines a WebUIElement for the tag; HTML-only components are declarative only.
Event handlers use method-call syntax only. Arguments can be:
efor the native DOM event- Dotted component or repeat-scope paths such as
item.id - String, number, boolean, and
nullliterals
General JavaScript expressions and nested function calls are not parsed in templates. Compute those values in the component class or pass a supported path.
Invalid handler syntax โ a general expression such as @click="e.preventDefault()",
or a bare name like @click="{closeMenu}" โ fails the build with an actionable
error that names the offending component and element.
DOM References #
Use w-ref to get a direct reference to a DOM element:
<input w-ref="{inputEl}" type="text" />
<button @click="{focusInput()}">Focus</button>
inputEl!: HTMLInputElement;
focusInput(): void {
this.inputEl.focus();
}
w-ref must use braces to bind to a component property โ w-ref="{inputEl}"
(or the unquoted w-ref={inputEl}), never w-ref="inputEl". The build fails
with an actionable error otherwise.
Conditional Rendering #
Render content based on expressions:
<if condition="count > 0">
<p>You have {{count}} items.</p>
</if>
<if condition="!isLoggedIn">
<a href="/login">Sign in</a>
</if>
Boolean Attributes #
Toggle HTML attributes with the ? prefix:
<button ?disabled="{{isLoading}}">Submit</button>
<input ?checked="{{isSelected}}" type="checkbox" />
<details ?open="{{isExpanded}}">...</details>
Property Bindings #
Use the : prefix to pass rich values directly to child DOM properties:
<profile-card :config="{{settings}}"></profile-card>
For client-created component trees, WebUI applies initial property bindings before child connectedCallback methods run. This lets a child read a parent-provided property during setup. If the parent has not provided a value, the child can initialize a fallback in connectedCallback; later parent updates still flow through the live binding.
List Rendering #
Iterate over arrays with <for>:
<ul>
<for each="item in items">
<li>{{item.name}} - {{item.price}}</li>
</for>
</ul>
Event Handling Patterns #
Direct Method Calls #
The simplest pattern - call a method when an event fires:
@observable count = 0;
increment(): void {
this.count += 1;
}
<button @click="{increment()}">+1</button>
Using the Event Object #
Access the native DOM event by passing e:
onKeydown(e: KeyboardEvent): void {
if (e.key === 'Enter') {
this.submit();
}
}
<input @keydown="{onKeydown(e)}" />
Passing Values from Repeats #
Handlers inside a <for> block can receive the current item through a dotted
path. The framework captures the active repeat scope during hydration and
resolves the argument when the event fires:
selectItem(id: string, e: MouseEvent): void {
e.preventDefault();
this.selectedId = id;
}
<for each="item in items">
<button @click="{selectItem(item.id, e)}">
{{item.title}}
</button>
</for>
Custom Events and Parent-Child Communication #
Components communicate upward by emitting custom events with this.$emit():
Child component (color-picker.ts):
export class ColorPicker extends WebUIElement {
@observable selectedColor = '';
selectColor(color: string): void {
this.selectedColor = color;
this.$emit('color-change', { detail: { color } });
}
}
Parent template catches the event:
<color-picker @color-change="{onColorChange(e)}"></color-picker>
<p>Selected: {{currentColor}}</p>
Parent class handles the event:
onColorChange(e: CustomEvent): void {
this.currentColor = e.detail.color;
}
This pattern keeps components decoupled - the child doesn't know who is listening, and the parent reacts declaratively.
Loading Static Component Assets #
When you are not using @microsoft/webui-router, components hidden behind
inactive routes or deferred UI can still be loaded from static files. Build the
root components as assets:
webui build ./src --out ./dist --plugin=webui \
--emit-component-assets settings-dialog,mail-thread
Each requested root writes one ESM module such as <tag>.webui.js next to
protocol.bin. The module carries the component's template, styles, and
dependency closure; it does not contain route inventory state.
During development, pass the same flag to webui serve so these roots are
validated and served without a separate build step:
webui serve ./src --state ./data/state.json --plugin=webui \
--emit-component-assets settings-dialog,mail-thread --watch
The dev server parses and validates each root on every build. HTML and
theme-token errors in a lazily loaded component fail the build instead of being
missed because the component is outside the initial route tree. The dev server
serves <tag>.webui.js from memory and rebuilds it on change.
Load the asset before creating or revealing the component:
import { WebUIElement } from '@microsoft/webui-framework';
import { settingsAssets } from './lazy-assets.js';
export class AppShell extends WebUIElement {
panelSlot!: HTMLDivElement;
async openSettings(): Promise<void> {
settingsAssets.preload('settings-dialog');
this.panelSlot.replaceChildren(await settingsAssets.create('settings-dialog'));
}
}
// lazy-assets.ts
import { defineComponentAssets } from '@microsoft/webui-framework/component-asset.js';
export const settingsAssets = defineComponentAssets({
'settings-dialog': {
asset: '/settings-dialog.webui.js',
module: () => import('./settings-dialog/settings-dialog.js'),
data: async () => await (await fetch('/settings-dialog-data.json')).json(),
},
});
defineComponentAssets() exposes preload(tag) and create(tag).
preload(tag) starts the component's template, styles, JavaScript module, and
optional data together. Components can then fetch their own data in their class
code and expose it through @observable fields when JavaScript needs to read or
mutate it. Concurrent requests for the same asset share one in-flight load.
create(tag) creates the element after template/module work is ready. Use
create(tag, { awaitData: true, dataTimeoutMs: 150 }) only when a component must
wait briefly for state before mounting. Use a manifest helper when you want the
fastest path: it lets the shell start the template asset, JS chunk, and data
fetch in parallel.
Do not put <settings-dialog> in an SSR-reachable <if> block for this pattern.
If the server state ever makes that condition true, the component is part of the
initial SSR graph instead of being loaded only from the static asset.
Styling #
CSS is scoped to each component via Shadow DOM. Styles in one component cannot leak into or be affected by another.
The :host Selector #
Style the component's root element with :host:
:host {
display: block;
padding: 1rem;
border: 1px solid #e0e0e0;
}
Attribute-Based Styling #
Style the component differently based on its attributes with :host([attr]):
:host([variant="primary"]) {
background: #0078d4;
color: white;
}
:host([disabled]) {
opacity: 0.5;
pointer-events: none;
}
Scoping Rules #
- Styles defined in a component's
.cssfile only apply inside that component's shadow root - External page styles do not penetrate into the component
- No CSS-in-JS - styles stay in
.cssfiles, separate from behavior - Use CSS custom properties (
--my-color) to allow external theming
SSR + Interactivity Lifecycle #
Understanding the lifecycle helps you write components that work correctly from the first paint through interactive use.
1. Server renders HTML #
The handler renders the component's template using JSON state data. No JavaScript runs. The output includes Declarative Shadow DOM:
<my-counter>
<template shadowrootmode="open">
<style>/* scoped styles */</style>
<button>Count: 0</button>
</template>
</my-counter>
2. Browser displays content #
The browser parses the HTML and renders it immediately. The user sees fully styled content - no loading spinner, no blank page, no flash of unstyled content.
3. JavaScript loads and components hydrate #
The framework detects the existing Declarative Shadow DOM roots and upgrades elements in place:
- Bindings are wired to class properties
- Event handlers are attached
@observableproperties become reactive- The component is now interactive
4. User interacts #
From this point on, interactions are handled entirely on the client. Changes to @observable properties trigger targeted DOM updates without a server round-trip.
When NOT to Hydrate #
Not every component needs JavaScript. Hydrating a component that has no interactivity adds unnecessary bytes and processing time.
Skip hydration for:
- Static content pages - about, docs, marketing, legal. The server renders them perfectly.
- Read-only data displays - lists, tables, cards with no user interaction. Server-rendered HTML is sufficient.
- Layout components - headers, footers, sidebars with only links. Standard
<a>tags work without JS.
Hydrate when a component needs:
- Event handlers (
@click,@keydown,@input) - Reactive state updates (
@observableproperties that change) - User input handling (forms, search, filters)
- Client-side data manipulation (sorting, filtering, pagination)
The goal is minimal JavaScript: hydrate only what the user will interact with, and let the server handle everything else.