LearnInteractivity

Interactivity #

Add a TypeScript or JavaScript file when a component needs event handlers, browser APIs, or client-owned state. Its HTML and CSS stay in paired files; the optional @microsoft/webui-framework runtime connects that behavior to server-rendered content.

WebUI uses Trusted Types automatically for compiled templates. Applications control enforcement through CSP; raw state HTML is not promoted through the compiler policy.

Component Files #

Keep structure, styles, and behavior separate:

my-counter/
├── my-counter.html   ← Template (structure and bindings)
├── my-counter.css    ← Authored styles
└── my-counter.ts     ← Behavior (TypeScript class)
  • HTML defines what the component renders and where dynamic values appear
  • CSS styles the component. Light DOM uses the owning tree's global cascade; Shadow components use the browser's native boundary
  • 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

Write a custom element class only for authored behavior. @observable and @attr are optional; add them when JavaScript needs the value or it is part of the component's public API.

The sibling .ts or .js file is the authored behavior boundary. With manifest-enabled projection, only @observable and @attr fields opt into initial state hydration; ordinary template roots remain in the trusted SSR DOM. Without a client module, bindings, conditionals, and loops still render on the server, but the component contributes no projected keys. If the framework is loaded, it can later activate the compiled template for browser-applied state or soft navigation. Without any projection manifest, the server preserves full state. See Hydration for the full contract.

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>

Styles for the default Shadow DOM mode (my-counter.css):

:host {
  display: inline-block;
}

button {
  padding: 0.5rem 1rem;
  font-size: 1rem;
  cursor: pointer;
}

Supply { "label": "Count", "count": 0 } in the initial server state and import the component module from your browser entry. Class field defaults do not replace the data needed for SSR.

Deferring offscreen components #

For a component repeated beyond the initial viewport, put the offscreen policy on its root template:

<!-- todo-row.html -->
<template w-render="lazy" w-reserve-block-size="72px">
  <!-- Component content -->
</template>

Import the optional coordinator once before component registration modules:

import '@microsoft/webui-framework/lazy-hydration.js';
import './todo-row.js';

This combines visibility-deferred hydration with content-visibility: auto. Use w-hydrate="lazy" alone when rendering containment would break layout. On an instance, w-hydrate="eager" keeps rendering deferral but hydrates immediately; w-render="eager" disables both. Put setup that needs bindings or refs in hydratedCallback().

See Lazy Component Policy for all combinations.

The <template> Tag #

Unwrapped components default to Shadow DOM. Build with --dom light to render unwrapped content directly into the component host.

A sole bare top-level <template> explicitly selects Light DOM and is unwrapped even when the build fallback is Shadow. A template with attributes or w-render/w-hydrate is not a mode selector; nested templates remain ordinary inert template content.

Light DOM (--dom light):

<!-- my-counter.html -->
<button @click="{increment()}">{{label}}: {{count}}</button>

The paired CSS remains authored/global in the owning CSS tree. Use ordinary selectors such as my-counter; Shadow-only :host and ::slotted selectors fail in effective Light CSS.

Authored Shadow island (including inside a Light build):

<!-- 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>

The open template must be the sole top-level element and wrap the complete component. Use it when the component needs native <slot> composition, a native Shadow boundary, or root host events - listeners on the component root that catch events bubbling up from child components (@toggle-item, @delete-item above). closed, another value, invalid placement, multiple wrappers, or extra top-level content fails the build. A native <slot> fails only when the component's effective mode is Light.

To reach a root binding from a child component, an event must bubble and be composed whenever any Shadow boundary exists between the child and the listener. this.$emit() always sets both, including for a Light component nested in another component's Shadow tree. A hand-built new CustomEvent('my-event') defaults to neither and will never arrive - bind it on the child element instead, or pass { bubbles: true, composed: true } yourself.

A root binding lives 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 events), which stop at the shadow boundary because they identify one specific inner element; bind those per element - <input @change="{onChange(e)}">.

Since the listener is on the host, e.target is the host for events raised inside the shadow tree. Use e.composedPath()[0] to get the element that was actually hit.

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>

When build-time projection is enabled, @attr property names are included in initial state metadata. If SSR already emitted the corresponding host attribute, that attribute is authoritative during hydration. Projected state fills the property only when the host attribute is absent. After a component is connected and hydrated, assigning an @attr property reflects its host attribute synchronously. Before connection, values are stored and reflected on the first mount, never in the constructor.

@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 values are readable immediately. Changes to bindings are targeted and batched in a microtask, so multiple assignments in one turn normally cause one DOM update. Call $flushUpdates() if code needs pending template updates synchronously.

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.

React to Property Changes #

Implement a <property>Changed(oldValue, newValue) method to synchronize behavior with an @attr or @observable property:

disabledChanged(_oldValue: boolean | undefined, newValue: boolean): void {
  this.internals.ariaDisabled = String(newValue);
}

Each initialized property with a callback is reconciled once after this component's DOM, event bindings, and w-ref fields are ready, with undefined as the initial old value and the final field, attribute, or SSR value as the new value. Callbacks do not run during construction or deferred hydration. After mounting, each assignment invokes its callback synchronously with the previous and new values; template bindings still batch in a microtask. Changes while disconnected are reconciled on reconnection without repeating the initial callback. Constructor-created ElementInternals is available in these callbacks. For work that runs only once regardless of property values, use hydratedCallback(). Optional resources and deferred descendants may still need readiness checks.

Callback errors propagate. The failed callback is not retried; call $flushUpdates() or reconnect the element to resume other pending callbacks.

Initial Hydration State #

For the initial page, build-time projection can narrow state to top-level @observable and @attr values from authored components. Template bindings still render on the server, but they do not automatically become JavaScript state. Only components reachable on the active route contribute projected values. Without a projection manifest, WebUI intentionally sends full state.

See Hydration for HTML-only components, soft navigation, and payload behavior.

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 do not provide application event handlers.

Event handlers use method-call syntax only. Arguments can be:

  • e for the native DOM event
  • Dotted component or repeat-scope paths such as item.id
  • String, number, boolean, and null literals

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, such as a general expression like @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.

w-ref is scalar. Reusing a name inside <for> writes the same property, so the last wired occurrence wins; repeat keys and positions do not create an indexed ref collection. For lookup by item identity, author a stable id and use the owning document or shadow root's getElementById(), or move the ref into an item component.

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>

How Event Bindings Are Wired #

Every @event gets its own listener on the element it is written on. Bindings are never delegated to a shared root listener, which has two consequences worth knowing:

  • Non-bubbling events work. @focus, @blur, @mouseenter, @load, @error, and @toggle fire normally. A shared root listener could never see them, because those events do not travel up the tree.
  • stopPropagation() behaves as written. An ancestor stopping an event as it bubbles cannot suppress a handler bound to the element the event started on.

For a single listener instead of one per row, use root host events and find the row with e.composedPath(). Direct bindings are simpler when the handler needs the loop item's state.

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', { 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;
}

The child emits data without depending on the parent's implementation.

Loading Static Component Assets #

When you are not using @microsoft/webui-router, 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 \
  --component-assets-out ./.webui

WebUI writes stable intermediate ESM inputs into .webui: one module for each asset-owned component and a thin <tag>.webui.js root entry with static imports for its closure. Import the root from application code and let the application bundler decide final chunks, hashes, public paths, caching, and delivery. Entry-reachable dependencies remain in the normal application bundle and protocol. Asset-only fragments and component records do not remain in protocol.bin.

Static component assets and <route> cannot be used in the same build. Use @microsoft/webui-router for routed components, or use component assets for non-routed deferred UI.

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 \
  --component-assets-out ./.webui \
  --metafile ./component-assets-meta.json --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 SSR tree. The dev server refreshes the generated ESM inputs after each successful rebuild and also serves them over HTTP. Immutable dependencies remain on disk until consumers of older roots have finished. Stop readers before cleaning the generated directory; a fixed number of watch rebuilds is not a safe cleanup boundary. Unchanged inputs keep their modification times. A successful watch build atomically replaces --metafile; a failed build preserves the previous valid graph and generated files.

Load the asset before creating or revealing the component:

import { WebUIElement, observable } from '@microsoft/webui-framework';
import {
  defineComponentAsset,
  preloadComponentAssetStyles,
} from '@microsoft/webui-framework/component-asset-runtime.js';

const loadSettings = () => {
  preloadComponentAssetStyles('settings-dialog');
  return import('../../.webui/settings-dialog.webui.js')
    .then(module => defineComponentAsset(module.default));
};

export class AppShell extends WebUIElement {
  @observable loadError = '';
  panelSlot!: HTMLDivElement;

  preloadSettings(): void {
    void loadSettings().then(asset => asset.preload()).catch(() => {});
  }

  async openSettings(): Promise<void> {
    try {
      const [asset, state] = await Promise.all([
        loadSettings(),
        fetch('/settings-dialog-data.json').then(response => {
          if (!response.ok) throw new Error(`Settings data: HTTP ${response.status}`);
          return response.json();
        }),
      ]);
      const dialog = await asset.create();
      (dialog as HTMLElement & { setState(value: unknown): void }).setState(state);
      this.panelSlot.replaceChildren(dialog);
      this.loadError = '';
    } catch (error) {
      console.error('Settings loading failed:', error);
      this.loadError = 'Settings could not load. Reload the page to try again.';
    }
  }

  reloadPage(): void {
    location.reload();
  }
}

Render that error in the shell's template with an explicit recovery action:

<if condition="loadError">
  <p role="alert">{{loadError}}</p>
  <button @click="{reloadPage()}">Reload page</button>
</if>

Import stable generated root paths, not content-hashed stylesheet filenames. preloadComponentAssetStyles(root) prepares Shadow styles before the dynamic import; automatic style preloading requires HTML rendered through WebUI. defineComponentAsset() prepares the component and returns a reusable facade. Load the root and application data together when first-use latency matters.

Stylesheet failures are reported and do not leave the component permanently hidden, though it may render unstyled. A failed native module import can stay failed until the page reloads. Let your bundler handle chunk recovery, or show an explicit Reload page action when opening fails. Do not reload during speculative hover/focus preloading; keep real open failures visible and preserve their diagnostic details.

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. Always load the normal application entry bundle first; component assets treat all entry-reachable templates as external prerequisites and fail clearly when one is missing.

Styling #

Keep styles in the ordinary paired .css file. For an effective Light component, CSS is authored/global in the owning CSS tree: selectors are not rewritten or marker-scoped. For effective Shadow roots, the browser provides native style scoping.

The :host Selector (Shadow DOM only) #

Style an effective Shadow component's root element with :host:

:host {
  display: block;
  padding: 1rem;
  border: 1px solid #e0e0e0;
}

In effective Light DOM, target the component tag directly instead:

my-card {
  display: block;
  padding: 1rem;
}

Attribute-Based Styling #

In Shadow DOM, style component attributes with :host([attr]):

:host([variant="primary"]) {
  background: #0078d4;
  color: white;
}

:host([disabled]) {
  opacity: 0.5;
  pointer-events: none;
}

The equivalent global Light selector is my-card[variant="primary"] or my-card[disabled]. :host, :host-context, and ::slotted in effective Light CSS fail with unsupported-light-css; use ordinary selectors or opt the component into Shadow DOM.

CSS Ownership Rules #

  • Light DOM uses authored/global selectors and normal inheritance/cascade
  • Light CSS can reach other Light DOM in the same Document or ShadowRoot
  • Use deliberate names, @layer, and custom properties for global composition
  • Shadow DOM provides a native style boundary
  • Shadow-only selectors in Light components fail the build
  • No CSS-in-JS - styles stay in .css files, separate from behavior
  • Use CSS custom properties (--my-color) to allow external theming

SSR + Interactivity Lifecycle #

The server provides initial content; browser behavior attaches afterward.

1. Server renders HTML #

The handler renders the component's template using JSON state data. No JavaScript runs. In a Light build, unwrapped content is emitted directly:

<my-counter>
  <button>Count: 0</button>
</my-counter>

The default build and authored Shadow islands emit Declarative Shadow DOM with styles installed inside the shadow root.

2. Browser displays content #

The browser can display the HTML before component JavaScript loads. It still needs to download resources, parse the document, calculate styles, and paint. Check CSS delivery and loading states rather than assuming a flash-free render.

3. JavaScript loads and components hydrate #

The framework detects the existing Light or Shadow SSR DOM and upgrades elements in place:

  • Bindings are wired to class properties
  • Event handlers are attached
  • @observable properties become reactive
  • Existing host attributes take precedence over projected @attr state
  • The component is now interactive

4. User interacts #

Changes to @observable properties update their bindings without requiring a server round trip. Event handlers can still call your backend when needed.

Setting observable state during setup #

The server owns the first paint, and the framework trusts the HTML it produced. Hydration wires bindings to the existing DOM instead of re-rendering it. A value you write before hydration finishes, in an @observable field initializer, the constructor, or before you call super.connectedCallback(), updates the property's backing field but cannot touch the DOM yet, so it is dropped. The property can then disagree with what is on screen.

Development builds warn when the framework detects a mismatch:

[WebUI] Hydration mismatch on <my-counter>: "count" changed at or before
super.connectedCallback() to a value that differs from the server-rendered DOM…

Follow these rules to stay correct:

  • A value that must appear in the first render belongs in the SSR state. Provide it in the JSON state so the server renders it; the client then hydrates against a matching DOM.
  • Assign post-hydration state from hydratedCallback(), where @observable writes flow through live bindings on ordinary, streamed, and client-created components.

On a normal buffered page or client-created mount, super.connectedCallback() hydrates synchronously. A progressive streaming host can connect while its checkpoint or generated parent span is incomplete, so the same call returns while hydration is still deferred. hydratedCallback() is the cross-mode lifecycle: WebUI invokes it synchronously exactly once after the first successful hydration or mount. Its once-latch is set before author code, so reconnecting the element or throwing from the callback does not retry it.

Buffered pages rely on loading authored component code with a parser-inserted, non-async ES module script or a classic defer script. If a classic script blocks parsing, place it after every SSR instance it may upgrade. An opt-in progressive streaming page instead loads an early async module and gates each component until its complete streaming occurrence or generated span commits.

Descendants must not structurally mutate a containing WebUI component's SSR subtree before that component hydrates. Inserting, removing, or reordering nodes can shift compiled element indices before WebUI wires them.

export class MyCounter extends WebUIElement {
  @observable count = 0;

  constructor() {
    super();
    // ✗ Wrong: this runs before hydration, so the write is dropped and warns.
    // this.count = 3;
  }

  protected override hydratedCallback(): void {
    // ✓ Correct: this hook runs after hydration in every mode.
    this.count = 3;
  }
}

If count should already read 3 in the server-rendered HTML, seed it in the SSR state instead of assigning it on the client at all.

Development warning #

The mismatch warning is removed from webui-press production builds. If you bundle the framework yourself, define __WEBUI_DEV__ as false in production:

esbuild app.ts --bundle --minify --define:__WEBUI_DEV__=false

When NOT to Hydrate #

Do not add an authored module merely to render data.

Skip hydration for:

  • Static content pages - about, docs, marketing, legal.
  • 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 (@observable properties that change)
  • User input handling (forms, search, filters)
  • Client-side data manipulation (sorting, filtering, pagination)

Scriptless components can still activate for soft navigation or browser-applied state when the framework is loaded. Avoid an empty class; it is not required for either capability.