Chapter 10: Spatial UI with UIKitML
Updated: Sep 4, 2026
IWSDK loads .uikitml source files directly at runtime through @drawcall/uikitml. You do not need a compilation plugin or generated JSON.
Use spatial UI for controls that must render inside the 3D world. Use regular DOM UI for browser-only overlays and forms.
Enable the feature in iwsdk.config.json:
{
"world": {
"features": {
"spatialUI": {
"kit": "horizon",
"preferredColorScheme": "system"
}
}
}
}
The kit value is horizon or default. The Horizon kit is the default. The preferredColorScheme value is system, light, or dark.
Create public/ui/main-menu.uikitml:
<style>
.panel-root {
width: 344px;
padding-top: 16px;
padding-right: 16px;
padding-bottom: 16px;
padding-left: 16px;
flex-direction: column;
}
.title {
font-size: 24px;
color: white;
}
</style>
<Panel class="panel-root">
<h1 class="title">Settings</h1>
<button id="xr-button">
<ButtonIcon>
<LogIn />
</ButtonIcon>
Enter XR
</button>
</Panel>
The runtime supports HTML-like elements and installed component sets. Use longhand padding properties because CSS padding shorthand is not supported.
Register the UIKitML asset
Add the source file to the asset module selected by iwsdk.config.json:
import { AssetType, defineAssets } from '@iwsdk/core';
const publicAssetUrl = (path: string): string =>
`${import.meta.env.BASE_URL}${path.replace(/^\/+/u, '')}`;
export default defineAssets({
'main-menu': {
name: 'Main menu',
type: AssetType.UIKitML,
url: publicAssetUrl('ui/main-menu.uikitml'),
},
});
The BASE_URL prefix keeps the asset URL valid when the application deploys below the site root.
Add the panel to a native scene
Reference the asset ID from a node in the active scene:
{
"id": "main-menu-panel",
"name": "Main Menu",
"content": {
"type": "asset",
"asset": "main-menu"
},
"transform": {
"position": [0, 1.6, -2],
"scale": 0.4
},
"components": {
"RayInteractable": {}
}
}
The asset module, runtime, and native scene editor use the same asset ID.
Get the loaded UIKitMLAsset by its scene-node ID, then subscribe to an element event:
import { UIKitMLAsset, createSystem } from '@iwsdk/core';
export class PanelSystem extends createSystem({}) {
init(): void {
const panel =
this.world.getSceneObject<UIKitMLAsset>('main-menu-panel');
const xrButton = panel?.getElementById('xr-button');
if (xrButton == null) {
return;
}
const launchXR = () => this.world.launchXR();
xrButton.addEventListener('click', launchXR);
this.cleanupFuncs.push(() =>
xrButton.removeEventListener('click', launchXR),
);
}
}
Register PanelSystem after World.create() resolves.
Declare a TrueType font in the .uikitml file. Relative URLs resolve from the document’s location:
<style>
@font-face {
font-family: 'Brand Sans';
src: url('./fonts/BrandSans-Regular.ttf');
font-weight: 400;
}
.title {
font-family: 'Brand Sans';
}
</style>
For public/ui/main-menu.uikitml, this URL resolves to public/ui/fonts/BrandSans-Regular.ttf. Include every glyph that the interface displays. The runtime reports missing glyphs and their code points.
Style light and dark modes
Use the :dark selector for dark-mode overrides:
<style>
.panel {
background-color: #ffffff;
}
.panel:dark {
background-color: #1a1a1a;
}
</style>
<Panel class="panel">
<h1>Settings</h1>
</Panel>
The runtime updates the document when the preferred color scheme changes.
The panel does not appear
Symptom: The scene loads, but the UI is not visible.
Solution: Confirm that spatial UI is enabled, the asset uses AssetType.UIKitML, the scene references the correct asset ID, and the panel is positioned in front of the viewer.
The document does not load
Symptom: The asset loader reports a missing or invalid document.
Solution: Confirm that the file is under public/ and that its URL includes import.meta.env.BASE_URL. Inspect the markup error reported by the loader.
Text contains missing glyphs
Symptom: The runtime warns about uncovered characters.
Solution: Add a licensed TrueType font that contains those characters, declare it with @font-face, and apply the family to the affected elements.
Symptom: The element renders but does not run its handler.
Solution: Confirm the element ID, wait for the UIKitMLAsset to load, attach the event listener, and add RayInteractable to the scene node.