AphrodyBun GitHub

Runtime docs · Runtime · Interop & Tooling

WinUI 3

Open native WinUI 3 windows from Bun with bun:winui, load XAML, handle control events on the JS thread, and theme them with Fluent 2 tokens

bun:winui drives WinUI 3 (the Windows App SDK UI framework) from JavaScript. Bun loads the installed Windows App Runtime into the process, reads its .winmd metadata at run time and runs XAML on the JS thread. Every property, method and event of the WinUI controls can be reached this way, with no C#, no project file and no package identity.

It is Windows-only, built on the bun:winrt core, and needs the Windows App Runtime (1.4 or later):

winget install Microsoft.WindowsAppRuntime.1.8
app.ts
import winui from "bun:winui";

const app = winui.start();
const win = app.createWindow({
  title: "Hello",
  width: 400,
  height: 240,
  content: `<StackPanel Padding="24" Spacing="12">
    <TextBlock x:Name="label" Text="Hello WinUI"/>
    <Button x:Name="ok" Content="OK" Style="{StaticResource AccentButtonStyle}"/>
  </StackPanel>`,
});

win.find("ok")!.on("Click", () => win.find("label")!.set("Text", "Clicked"));
await win.closed;

Runtime

winui.runtime() adds the framework package to the process and describes it. On Windows 11 it uses dynamic dependencies (TryCreatePackageDependency/AddPackageDependency). Elsewhere it falls back to MddBootstrapInitialize2 from Microsoft.WindowsAppRuntime.Bootstrap.dll, which you can point to with bootstrapDll. By default Bun tries 1.8, 1.7, 1.6, 1.5 and 1.4 in that order. Pass version or set BUN_WINUI_VERSION (a comma-separated list) to change that.

import winui from "bun:winui";

if (!winui.isSupported()) console.log("no Windows App Runtime");
winui.runtime({ version: "1.8" });
// { version: "1.8", packageFamilyName: "Microsoft.WindowsAppRuntime.1.8_8wekyb3d8bbwe", path: "C:\\Program Files\\WindowsApps\\...", via: "dynamic-dependency", ... }

If no runtime is installed, it throws an error with code: "ERR_WINUI_RUNTIME_NOT_FOUND". On any other platform the code is ERR_WINUI_UNSUPPORTED.

Application and windows

winui.start(options) starts XAML on the current thread. It makes the thread a single-threaded apartment, creates a DispatcherQueue, the XAML Application (with the WinUI controls metadata provider) and XamlControlsResources, the Fluent styles of the controls. There is one application per process: later calls return the same one.

OptionDefaultDescription
versionnewestWindows App SDK release, such as "1.8"
themesystem"light" or "dark"
resourcestrueload XamlControlsResources
exitOnLastWindowClosedtruecall app.exit() when the last window closes
winrtbun:winrtWinRT core to use instead of bun:winrt

app.createWindow({ title, width, height, content, activate }) creates a Microsoft.UI.Xaml.Window. The size is in device-independent pixels. content is either XAML markup or an element. The window exposes hwnd, title, content, find(name), resize(), activate(), close() and the closed promise.

Window messages are pumped from a timer, so the event loop stays free: await, fetch, Bun.serve and timers keep running while windows are open. Event listeners run on the JS thread. app.exit() closes the windows, removes the listeners and shuts XAML down for the thread.

XAML and elements

app.load(xaml) is XamlReader.Load. Fragments that have no xmlns get the WinUI presentation namespace and x: added. app.create("Button", { Content: "OK" }) creates an element by type name.

Every XAML object is wrapped as an Element. Its members are resolved from the metadata of its runtime class and base classes:

const box = win.find("input")!;
box.get("Text"); // property
box.set("PlaceholderText", "Search"); // strings, numbers and booleans are boxed for Object members
box.call("Focus", 3); // method (FocusState.Programmatic)
const off = box.on("TextChanged", (sender, args) => console.log(sender!.get("Text")));
off(); // removes the listener

win.content!.append(`<ToggleSwitch x:Name="toggle"/>`); // Panel.Children
win.content!.tree(); // { type: "StackPanel", children: [{ type: "TextBox", name: "input" }, ...] }
win.find("ok")!.invoke(); // clicks through the automation peer

Collections typed IVector<T> (Panel.Children, ItemsControl.Items, ResourceDictionary.MergedDictionaries) have GetAt, get_Size, Append, InsertAt, RemoveAt and Clear:

win.find("list")!.get("Items").call("Append", "first item");

Fluent 2 tokens

@aphrody/bun-fluent carries the Fluent 2 design tokens of microsoft/fluentui (packages/tokens, MIT): colors, typography, radii, shadows and motion for the web light/dark and Teams themes. It exposes them as JS objects, as CSS custom properties, as a Tailwind v4 theme and as WinUI resources.

import { fluentWinuiResources } from "@aphrody/bun-fluent";

// WinUI theme brushes (AccentFillColorDefaultBrush, TextFillColorPrimaryBrush, ...) set to the Fluent 2 web palette.
app.application.get("Resources").get("MergedDictionaries").call("Append", app.load(fluentWinuiResources()));

For web UIs, fluent:theme.css plugs into bun-plugin-tailwind through schemeImport. It gives utilities such as bg-fluent-brand-background, text-fluent-neutral-foreground-1, rounded-fluent-medium, shadow-fluent-8 and fluent-body-1:

build.ts
import tailwind from "@aphrody/bun-plugin-tailwind";
import { fluentPlugin, fluentSchemeImport } from "@aphrody/bun-fluent";

await Bun.build({
  entrypoints: ["./index.html"],
  outdir: "dist",
  plugins: [fluentPlugin(), tailwind({ schemeImport: fluentSchemeImport() })],
});
style.css
@import "tailwindcss";
@import "fluent:theme.css";

winuiResources maps each WinUI theme resource to its Fluent 2 token and Material 3 token. docs/aphrody/merge/M-winui-m3.md has the full table.

Example

packages/bun-webos/examples/winui-app is a small notes window: XAML, a list, a theme switch and the Fluent brushes.