Skip to main content

@realitycollective/iwsdk-uiextensions

Windowing, docking, layout regions and extra controls for Meta's Immersive Web SDK (@iwsdk/core).

This is the Meta IWSDK adapter - and the reference implementation - for the engine-free @realitycollective/webxr-uiextensions core, which it re-exports in full: one dependency gets IWSDK apps the whole surface. (A sibling @realitycollective/xrblocks-uiextensions adapter binds the same core to Google XR Blocks, experimentally.)

Reuse, not recreation. The IWSDK already ships an excellent spatial UI stack - UIKitML markup, @pmndrs/uikit rendering, Follower/ScreenSpace anchoring, grab/ray/poke interaction. This package adds the missing layer above it:

FeatureWhat you get
WindowsTitle-bar chrome (pin / dock / minimize / close, each opt-in), focus & z-ordering, hide/show, a per-world WindowManager with typed events that is also the API for driving a window from code
Hand menusA hand-locked window rides a hand (left, right or whichever is raised), anchored above the fingertips, at the wrist, or on the thumb or little-finger side, shown while the palm faces you; a vertical stack of buttons that sizes to its content, driving other windows through the manager
Dock statesworld-locked (place in space) ⇄ body-follow (lazy follow) ⇄ head-locked, realised with the IWSDK's own Follower/ScreenSpace
ManipulationDrag windows by the title bar with the far ray or a near grab (controller squeeze, hand pinch), powered by @pmndrs/handle, the same library behind IWSDK grabbing; billboard-while-dragging, drop-to-dock
Layout regionsNamed regions (row / column / grid slots) windows snap into; regions can themselves follow the player
Controlsdata-uix markup upgrades: stepper, toggle, expandable multi-line label, log/list view - plus everything UIKitML already has (buttons, inputs, textareas, images, and the horizon kit's Slider/Checkbox/…)

Everything is authored in plain UIKitML (HTML/CSS-like) - no new markup language, no custom renderer, no wrapper widgets around things the IWSDK already does.

Install

npm install @realitycollective/iwsdk-uiextensions
# peers: @iwsdk/core >=0.5.0 <0.6.0 and three >=0.170.0 (every IWSDK app already has both)

Quick start

import { World } from '@iwsdk/core';
import {
registerUIExtensions,
createUIWindow,
createDockRegion,
DockMode,
} from '@realitycollective/iwsdk-uiextensions';

const world = await World.create(container, {
features: { spatialUI: true },
});

const windows = registerUIExtensions(world); // registers all systems, returns the WindowManager

createDockRegion(world, { id: 'wall', flow: 'column', position: [1.5, 1.8, -1.5] });

createUIWindow(world, {
id: 'status',
title: 'Player Status',
config: './ui/status.uikitml', // UIKitML source; IWSDK 0.5 parses it at runtime
dockMode: DockMode.BodyFollow, // follows until the user pins it
pinnable: true, // title-bar buttons are off unless asked for
minimizable: true,
});

windows.events.on('closed', (w) => console.log(`${w.title} closed`));

// The manager is also how code changes a window - from a hand menu, say:
windows.hide('status'); // and show(), toggleHidden()
windows.togglePin('status'); // or setDockMode(id, DockMode.WorldLocked)
windows.dockTo('status', 'wall'); // undock(id), returnHome(id)
windows.setChrome('status', { close: true, dock: true });
windows.close('status'); // destroys the entity

Window markup

Windows are ordinary UIKitML panels; the chrome is discovered by well-known element ids (only the ids are contractual - restyle freely):

<div id="uix-window" class="my-window">
<div id="uix-titlebar" class="my-titlebar"> <!-- drag surface -->
<span id="uix-title" class="my-title">.</span>
<div id="uix-pin">PIN</div> <!-- follow ⇄ placed; label auto-syncs to PIN/UNPIN -->
<div id="uix-dock">DOCK</div> <!-- return to home (spawn region / placement) -->
<div id="uix-minimize">MIN</div>
<div id="uix-close">X</div>
</div>
<div id="uix-content">
<!-- window body -->
</div>
</div>

Use <div>s (not <button>s) for chrome buttons: with a component kit registered, lowercase <button> resolves to the kit's Button component, whose intrinsic sizing fights compact title-bar chrome.

Controls markup

Annotate any element with data-uix and the UIControlsSystem upgrades it - in any panel, not just windows:

<div data-uix="stepper" data-uix-id="health" data-uix-min="0" data-uix-max="100" data-uix-step="10">
<button data-uix-role="decrement">-</button>
<span data-uix-role="value">.</span>
<button data-uix-role="increment">+</button>
</div>
import { panelControlsFor } from '@realitycollective/iwsdk-uiextensions';

const controls = panelControlsFor(document); // the panel's UIKitDocument
controls.stepper('health').events.on('change', (hp) => setHealth(hp));

UIKitML note: every dynamic-text element needs a literal placeholder child (<span data-uix-role="value">.</span>) or no Text node is created.

Every title-bar button is off by default. Keep all four in the markup, then enable the ones a window should have with closable, minimizable, pinnable and dockable at spawn, or later with windows.setChrome(id, { pin: true }). A disabled button is hidden and its click ignored; enabling one needs no rewiring.

Interaction model

  • Drag the title bar with the ray (or mouse on desktop) to move a window; it billboards toward you while dragging and settles facing you when released. A press only becomes a drag after dragDelay seconds (default 0.3, per-window on UIWindow) - shorter presses stay clicks, and the chrome buttons swallow their presses entirely, so PIN/DOCK/MIN/X never fight the drag gesture.
  • Near grab the title bar to pick the window up: squeeze with a controller, or pinch with a tracked hand while it is on the bar. A near grab is a deliberate gesture, so it drags at once with no hold delay. This does not need features.grabbing: UIDragSystem enables IWSDK's near grab pointer itself, lists every movable title bar as a target for it each frame, and forwards a hand pinch to it only while the hand is on a title bar, so pinching anywhere else still means what your app decided. Pass registerUIExtensions(world, { nearDrag: false }) to keep the ray as the only way to move windows.
  • Drop a window inside a region's snap radius to dock it into the next slot; drag it out again to undock.
  • Pin toggles body-followworld-locked ("place in space").
  • Dragging a following window implicitly places it - pin re-attaches it.
  • Hide takes a window out of view and out of reach (no ray or poke can hit it) while keeping its dock mode, region slot and minimized state; show brings it back exactly where it was and in front. Minimize collapses the body but leaves the title bar drawn.
  • Poke (near touch) is guarded. IWSDK's own touch pointer presses and releases on an unsigned distance to the panel, so a finger pushed through a panel and pulled back out fires two clicks, and a finger arriving from behind presses. UITouchGuardSystem drives the two touch pointers from the core's TouchPress state machine instead: a press only when the fingertip enters from the front (2 cm), a hold however deep it goes and whichever way it comes back, a release only on coming back out past 3 cm or on losing contact, and no second press until that release. Every poke target in the app gets this, IWSDK's own panels included. A press that starts on one button and ends over another clicks neither (the release lands where the finger is; IWSDK clicks only when both ends are the same element), and IWSDK's 800 ms click window still applies. Tune with registerUIExtensions(world, { touchGuard: { pressDistance, releaseDistance, allowFromBehind } }), or touchGuard: false for IWSDK's own behaviour.

Hand menus

Spawn a window with dockMode: DockMode.HandLocked and it becomes a hand menu in the manner of MRTK 2's: it rides a hand and shows while that palm is raised toward you. handMenu says how:

host.createWindow({
id: 'menu',
config: './ui/hand-menu.uikitml',
dockMode: DockMode.HandLocked,
handMenu: {
hand: 'left', // 'left' | 'right' | 'either' (whichever palm is raised)
anchor: 'above', // 'above' fingertips | 'inside' (thumb) | 'outside' | 'wrist'
anchorDistance: 0.12, // meters from the palm
offset: [0, 0, 0], // extra hand-local nudge
palmGate: true, // show only while the palm faces you
palmAngle: 60, // how far off square the palm may be, degrees
},
});

Every field is optional; the defaults are the values shown. windows.setHandMenu(id, { hand: 'right' }) changes them at runtime. The hand pose is the player rig's grip space, so a controller's grip or a tracked hand both work, and the panel always turns to face you. While the gate is shut the menu is neither drawn nor hittable, and hide() still wins over an open gate. Pinning or dragging a hand menu makes it an ordinary world-locked window where it was.

Use HAND_MENU_SNIPPET from the core as the markup starting point: the same uix-window / uix-content ids, no title bar, a vertical stack of buttons that sizes to its content. Examples/basic-window/ is a window whose manipulation buttons live on such a menu.

Driving windows from code

registerUIExtensions returns the WindowManager, and it is the one API app code needs to change a window - a hand menu, a keyboard shortcut, a voice command. Every call is applied by the systems, and the same calls work on the XR Blocks adapter:

CallEffect
hide(id) / show(id) / toggleHidden(id)Out of view and unhittable; back in place and in front
minimize(id) / restore(id) / toggleMinimized(id)Collapse / expand the body
togglePin(id) / setDockMode(id, mode)Follow the player or stay put
dockTo(id, regionId) / undock(id)Into a region slot (world-locked) / out of it
returnHome(id)Back to the spawn region, or the spawn placement and mode (what DOCK does)
setChrome(id, { pin, dock, minimize, close })Enable or disable title-bar buttons at runtime
focus(id)Bring to the front
close(id)Destroy the window's entity

| setHandMenu(id, { hand, anchor, ... }) | Move a hand menu to the other hand or another anchor |

The record is always what the scene shows: a drag that docks a window, or a PIN click, is written back into windows.get(id), and every change emits a typed event (hidden, shown, regionChanged, returnHome, chromeChanged, handMenuChanged, alongside the existing ones) so a menu can keep its labels honest. See Examples/basic-window/.

See Examples/ (shipped in this package) and the deployable showcase client in the repository for complete, working demonstrations of every feature.

Windows and panel readiness

createUIWindow returns the ECS entity, which is what you want when you are going to add components to it. When you want the PANEL, use the scene host: createWindow gives you a handle that resolves itself.

import { createSceneHost, getPanelHandle } from '@realitycollective/iwsdk-uiextensions';

const host = createSceneHost(world); // call after registerUIExtensions(world)

const status = host.createWindow({
id: 'status', // optional - omit and you get uix-window-<n>
title: 'Player Status',
config: './ui/status.uikitml',
});

status.panel; // undefined until IWSDK attaches the document
status.onReady((panel) => { // runs once, immediately if it is already there
panel.getElementById('uix-title');
});
status.entity; // still the entity, for ECS work

getPanelHandle(entity) does the same lookup for an entity you already hold, and returns undefined while the document is still loading.

Do not poll getPanelHandle on a timer. It is a single synchronous read, not a wait, and there is no deadline you can safely guess: the markup is fetched over the network and parsed over later frames, so a cold cache on a headset takes far longer than a warm one on a desktop. A poll that gives up early leaves a window that draws correctly and responds to nothing, with no error and no log line, which is close to undiagnosable from the outside. Every window has a readiness signal already, so use one.

Across a whole scene, subscribe to the host instead:

host.onPanelReady(({ id, panel, kind }) => {
if (kind === 'panel') {
// A bare PanelUI entity with no UIWindow: `id` is its config path.
return;
}
wireMyWindow(id, panel);
});

onPanelReady covers windows spawned by createUIWindow too, not only by host.createWindow. So code that already holds factory entities does not have to change how it spawns them: create the host once, subscribe, and match on the id you passed to the factory.

const entity = createUIWindow(world, { id: 'status', config: './ui/status.uikitml' });

createSceneHost(world).onPanelReady(({ id, panel }) => {
if (id === 'status') wireStatus(panel);
});

Pass an id to createUIWindow if you intend to match on one. Without it the window has no id to announce, so it arrives as kind: 'panel' with its config path as the id, and a listener filtering on kind === 'window' will silently never see it. host.createWindow differs here: it invents uix-window-<n> when you omit the id.

createSceneHost(world) returns the same host every time it is called for a world, so separate modules can each ask for it without coordinating or passing it around.

Bare panels are announced as well as managed windows, which is how devtools and hand-built PanelUI entities show up in the same stream. supportsStandalonePanels is false on this host: IWSDK owns panel lifecycles through the ECS, so createPanel() throws rather than half-working. Spawn a window instead.

Headless core

All decision logic (window manager, dock state machine, region slot math, drag math, control models) lives in @realitycollective/webxr-uiextensions - pure TypeScript with no engine imports, tested at 100% coverage. The ECS systems in this package are thin appliers of that core onto @iwsdk/core components.

Live demos

License

MIT © Reality Collective

Classes

ClassDescription
Emitter-
ExpandableLabelHandle-
ExpandableModel-
HoldToDrag-
LogModel-
LogViewHandle-
PanelControls-
RegionRegistry-
StepperHandle-
StepperModel-
ToggleHandle-
ToggleModelToggle model - pure boolean state for the data-uix="toggle" control.
TouchPress-
UIControlsSystem-
UIDockRegionSystem-
UIDockSystem-
UIDragSystem-
UITouchGuardSystem-
UIWindowSystem-
WindowManager-

Interfaces

InterfaceDescription
CreateDockRegionOptions-
CreateWindowOptionsOptions for createUIWindow.
DockRecipeEngine-agnostic description of what a dock mode requires.
DockTransitionA transition plan: what to add and what to remove, in engine terms.
DragSession-
ExpandableOptionsExpandable label model - pure truncation/expansion state for the data-uix="expandable-label" control (a multi-line label that collapses to a preview with an ellipsis and a "more/less" affordance).
HandMenuOptions-
HandMenuPlacementWhat the adapter applies this frame.
HandPoseSourceSupplies a hand's pose each frame as a WebXR GRIP space, the frame hand-menu.ts documents (-Z toward the thumb, +Y up the arm, the palm at -X on the right hand and +X on the left). A controller's grip and a tracked hand's gripSpace both are one; hand JOINT spaces are not, and must be converted. Returns undefined while that hand is not tracked; a hand menu on it is then hidden. An adapter without hands at all (a desktop) supplies no source and falls back to body-follow placement for hand-locked windows.
HoldUpdate-
IwsdkSceneHostThe engine-agnostic surface an app needs to build a UI: spawn windows and regions from portable data, observe when panels become wireable, and reach the shared WindowManager.
IwsdkWindowHandleA window spawned by IwsdkSceneHost.createWindow.
LogEntryLog model - pure ring buffer + viewport for the data-uix="log-view" control (scrollable log/list windows).
LogModelOptions-
OpenWindowOptions-
PanelHandleA live spatial panel created from compiled UIKitML JSON. The root is traversable with the core's walk/findRole helpers and the data-uix control upgraders - identical markup works on every adapter.
PanelHostCreates spatial panels - the engine-specific half of UIKitML rendering.
PanelReadyEventA window whose panel has finished loading and is ready to be wired. Delivered by WindowHost.onPanelReady.
PointerInputSourceDelivers press-move-release for one interaction source (a controller ray, a hand pinch, a mouse). The core's hold-to-drag and drag-math consume these; the adapter decides what constitutes press/release.
RegionCandidate-
RegionDefinition-
RegisteredRegion-
RegisterOptions-
SceneDescriptorA complete, engine-free scene definition.
SceneRegionOne dock region in a scene.
SceneTargetWhat an adapter must provide for applyScene to build a scene. Both shipped adapters implement this; a new adapter only needs these two methods to gain full scene portability.
SceneWindowOne window in a scene.
StepperOptionsStepper model - pure numeric state for the data-uix="stepper" control.
TouchPressOptionsTouch press - the press / hold / release state machine for a near (poke) pointer against a panel, pure logic with no engine imports.
TouchSampleWhat the adapter measured this frame. undefined means no contact at all.
TouchUpdateThe transitions the adapter acts on this frame.
UixElementStructural view of a uikit element as the controls layer needs it.
WindowChromeWhich title-bar buttons are enabled. Keys match the chrome element ids in WINDOW_CHROME_IDS. A disabled button is hidden and its click ignored. Every button is OFF unless the app turns it on.
WindowHandleA window an adapter spawned, before its panel necessarily exists.
WindowHostThe engine-agnostic surface an app needs to build a UI: spawn windows and regions from portable data, observe when panels become wireable, and reach the shared WindowManager.
WindowHostContractCaseOne check a WindowHost implementation must pass. run returns silently on success and throws an Error describing the failure otherwise, so any test runner can host it.
WindowHostContractSetupEverything a case needs to drive one adapter. Build a FRESH one per case: cases spawn windows of their own and do not clean up after themselves.
WindowManagerEvents-
WindowOptionsBaseThe window options every adapter understands.
WindowRecord-

Type Aliases

Type AliasDescription
ControlHandle-
DockModeValue-
Hand-
HandMenuAnchorWhere the panel sits relative to the palm.
HandPosesThis frame's tracked hands; a hand that is not tracked is absent.
HoldPhaseHold-to-drag threshold - pure timing state for title-bar dragging.
ListenerMinimal typed event emitter.
PointerSampleOne pointer/ray interaction stream, engine-normalised: a world-space origin (ray origin or touch point) and a normalised direction. It is the Input package's RayTuple, which is what lets a provider written against @realitycollective/webxr-input feed this contract unchanged.
RegionFlow-
TouchPhase-
Vec3Layout regions - pure slot math for docking windows into named regions.

Variables

VariableDescription
DEFAULT_HAND_MENU-
DEFAULT_REGION-
DEFAULT_TOUCH_PRESS-
DockModeDock state machine - pure logic, no engine imports.
ELLIPSIS-
HAND_MENU_SNIPPETReference hand-menu markup: the same root and content ids, no title bar, a vertical stack of buttons that sizes to its content. Spawn it with dockMode: 'hand-locked' and it rides the hand; without a title bar there is nothing to drag, which is what a hand menu wants. Give each button an id and wire it to a WindowManager call.
HandAnchorHandMenuOptions.anchor as an enum object for the component schema.
HandChoiceHandMenuOptions.hand as an enum object for the component schema.
NO_CHROME-
RegionFlowTypeRegion flow options mirrored as an enum object for the component schema.
UIDockedToPresent on a window entity while it is docked into a region.
UIDockRegionA named layout region that docked windows snap to and are laid out within. Place the entity where the region should live (it can itself carry a Follower to make a body-locked region).
UIWindowMarks a panel entity as a managed window with chrome, focus, docking and drag behaviour. Pair with PanelUI whose markup contains the window chrome elements (see WINDOW_CHROME_IDS / the Examples folder).
UIWindowStateInternal bookkeeping the systems keep on window entities - which dock mode has actually been applied to engine components, and whether chrome wiring has run for the current PanelDocument.
uixComponentSetPass to spatialUI.componentSets so <uix-*> elements parse on IWSDK 0.5. Without it, a panel using any control fails to parse and never attaches.
WINDOW_CHROME_IDSWindow chrome conventions.
WINDOW_CHROME_SNIPPET-

Functions

FunctionDescription
anchorOffsetHand-local offset for an anchor. The thumb is -Z and the fingertips -Y for both hands in the grip frame, so no mirroring is needed.
applySceneApply a descriptor to an adapter. Regions are created before windows so a window that spawns docked (region: 'x') always finds its region.
attrBoolean-
attrNumber-
attrString-
beginDragBegin a drag: capture how far along the ray the grab landed and where the window origin sits relative to the grab point.
captureDropWhich region captures a window dropped at point? The nearest region whose snapRadius contains the point and which still has capacity; undefined when the drop lands in open space (the window stays world-locked where it was released).
createDockRegion-
createSceneHostThe host for a world: built on the first call, and the same instance every call after that. Call AFTER registerUIExtensions(world).
createUIWindow-
dragPositionWindow position for the current ray. rayDirection must be normalized (pointer rays from the input layer already are).
evaluateHandMenuThe per-frame entry point an adapter calls for each hand-locked window.
faceViewerOrientation whose local +Z (the face of a panel) points from position to viewer, upright against world +Y. Straight above or below the viewer there is no upright, so world +Z stands in for "up" there.
faceViewerYawYaw (radians, three.js Y-up convention) that turns the window's -Z face toward viewer - the "PivotY" billboard used while dragging so a window never gets parked edge-on. Returns current when the viewer is directly above/below (degenerate on the Y axis).
findRole-
findRolesAll descendants (including self) declared as <uix-{role}>.
getPanelHandleThe live panel on an entity, or undefined while IWSDK is still loading its document. Use it to reach a panel from an entity you already hold; onPanelReady stays the way to be TOLD when one appears.
handMenuPoseThe menu's world pose for a hand: anchored in the hand frame, turned to face the viewer.
hasCapacity-
isDockMode-
isPalmFacing-
minimizeLabelForWhat the minimize affordance should read for a window's current state - the label always names what the NEXT click does, matching pinLabelFor: - open → "MIN" (click collapses the content) - minimized → "MAX" (click restores it)
normalize-
normalizeRegion-
palmFacingCosine of the angle between the palm normal and the direction to the viewer: 1 is palm square on to the viewer, -1 is the back of the hand.
palmNormalThe palm normal in the hand frame: -X on the right hand, +X on the left.
panelControlsForControls of an upgraded panel; undefined before UIControlsSystem ran.
pickHandPick the hand for this frame: the configured one when it is tracked, or for either the tracked hand whose palm faces the viewer most. The gate is applied here too, so a fixed hand that is tracked but turned away still yields undefined.
pinLabelForWhat the pin affordance should read for a window's current state: - dragging → "PIN" (the window is loose in your hand) - placed/world-locked → "UNPIN" (click releases it to follow) - following → "PIN" (click pins it where it is)
planTransitionCompute the minimal set of engine changes to move between dock modes. Returns undefined for a no-op (same mode).
quaternionFromBasisQuaternion for the rotation whose columns are the given orthonormal axes.
recipeFor-
regionRegistryFor-
registerUIExtensions-
resolveHandMenuFill a partial set of options from the defaults.
resolveTouchPress-
rotateRotate a vector by a unit quaternion [x, y, z, w].
sampleOfThis frame's signed distance from the fingertip to the surface it is over, positive in front. The surface normal is the intersection's local normal (uikit panels report +Z) taken to world space; a target with no normal is treated as faced from the front, so a plain mesh keeps IWSDK's behaviour.
sceneConfigPathsEvery distinct panel config path in a scene (for preloading).
slotOffsetLocal-space offset of slot index within a region.
tagOfThe custom-element tag this element was declared with, lowercased, or undefined for a plain built-in element.
togglePinnedThe "pin" affordance on a window's title bar toggles between following the player and being placed in space. Head-locked and hand-locked windows unpin to world-locked too - pinning something rigidly to the user's face is never the toggle target you want, and a hand menu pinned in place is simply a window again.
upgradeExpandableLabel-
upgradeLogView-
upgradePanelUpgrade every data-uix control under root and remember the result against documentKey (the panel's UIKitDocument). Idempotent per key.
upgradeStepper-
upgradeToggle-
validateSceneValidate a descriptor: unique window/region ids, and every region reference resolvable. Returns the problems found (empty = valid) rather than throwing, so callers can report them all at once.
walkDepth-first traversal over uikit element children.
windowHostContractCasesThe shared host conformance suite. An adapter's test file is a loop:
windowManagerFor-