@realitycollective/xrblocks-uiextensions
EXPERIMENTAL adapter for Google XR Blocks and plain three.js. It hosts the same @realitycollective/webxr-uiextensions core as the IWSDK adapter, with the same UIKitML panels, window chrome and window manager, inside any three.js WebXR scene. An XR Blocks Script gives you exactly that kind of scene.
Maturity: the IWSDK adapter is the most complete one, and this adapter is built to match it. It now has nearly all the same windowing features, and its desktop path is verified in a real browser (panels render, mouse clicks reach uikit controls, WASD/jump/ crouch move the camera). It has still had NO on-device pass on Android XR hardware - treat the XR Blocks path specifically as unverified.
Feature matrix vs the IWSDK adapter
| Feature | IWSDK | XR Blocks / three.js (this package) |
|---|---|---|
| UIKitML panel hosting (runtime interpret, scale-to-fit) | ✅ | ✅ UixPanelDocument |
| Window lifecycle + chrome (focus/PIN/DOCK/MIN/X, all opt-in, pin labels) | ✅ | ✅ UixWindowHost |
Driving windows from code (WindowManager: hide/show, dockTo/undock/returnHome, setChrome, close) | ✅ | ✅ every manager event applied |
Portable scene descriptors (applyScene) | ✅ | ✅ implements SceneTarget |
Panel-ready wiring (onPanelReady) | ✅ | ✅ implements WindowHost |
| Follow mode (body-follow, yaw-only, eased) | ✅ | ✅ pure follow-math |
Hand menus (hand-locked, palm gate, anchors) | ✅ from the player rig | ✅ from a HandPoseSource; webxrHandPoseSource(renderer.xr) reads the session's input sources, and without hands the menu follows the body |
| Dock regions (wall/belt, slots, follow) | ✅ | ✅ createRegion, manager.dockTo (host.dock forwards) |
| Desktop mouse input (hover, click, drag-to-look) | ✅ | ✅ via @pmndrs/pointer-events |
| Desktop locomotion (WASD, jump, crouch, sprint) | n/a | ✅ DesktopControls |
| XR select-ray click forwarding | ✅ | ✅ minimal (forwardClick) |
Bare panels (createPanel) | ⬜ ECS owns the lifecycle | ✅ supportsStandalonePanels is true |
Title-bar ray drag (@pmndrs/handle) | ✅ | ⬜ roadmap (movable is accepted and ignored) |
| Title-bar near grab (squeeze / pinch) | ✅ | ⬜ roadmap (needs drag) |
| Guarded poke (one press per touch, front only) | ✅ UITouchGuardSystem over IWSDK's touch pointers | ⬜ no near touch here yet; the core TouchPress is ready for it |
| Drop-to-dock by dragging | ✅ | ⬜ roadmap (needs drag) |
| System keyboard text input | ✅ | ⬜ untested on Android XR |
Required renderer setup (read this first)
uikit draws panel backgrounds, borders and text glyphs all as transparent meshes, stacked by renderOrder. three.js sorts transparent objects by camera distance by default, which is meaningless for coplanar UI layers - at grazing angles or close range a panel background can sort in front of its own text and labels silently vanish. uikit also clips panel content with local clipping planes, which three.js ignores unless enabled.
Apply both settings to any renderer you create:
import { configureRendererForUikit } from '@realitycollective/xrblocks-uiextensions';
const renderer = new WebGLRenderer({ antialias: true });
configureRendererForUikit(renderer); // transparent sort + local clipping
IWSDK does this internally, which is why panels look right there with no setup. A hand-rolled three.js host must do it explicitly, and under XR Blocks you should apply it to the renderer xb.init() creates.
Usage in an XR Blocks Script
import * as xb from 'xrblocks';
import {
DockMode,
connectUIExtensions,
forwardClick,
} from '@realitycollective/xrblocks-uiextensions';
class MyScript extends xb.Script {
async init() {
this.uix = connectUIExtensions({ scene: this, camera: xb.camera });
const config = await fetch('./ui/my-window.json').then((r) => r.json());
this.uix.createWindow({
id: 'status',
title: 'Status',
config,
dockMode: DockMode.BodyFollow,
});
}
update() {
this.uix.update(xb.getDeltaTime());
}
onSelectStart(event) {
/* raycast from event.target, then forwardClick(intersections) -
see demos/webxr-multiplatform for the complete wiring */
}
}
xb.add(new MyScript());
await xb.init();
Nothing here imports xrblocks - the glue binds to plain three.js shapes (scene: Object3D, camera), so the same host works in a hand-rolled three.js WebXR app.
Window options and handles
createWindow takes the portable WindowOptionsBase fields plus config, so an option means here what it means on the IWSDK adapter. Two notes specific to this host:
idis optional. Omit it and the window is nameduix-window-<n>.movableis accepted and recorded, but nothing acts on it yet: this host has no title-bar drag of its own, so there is no gate to close. It is in the options so a scene descriptor written for IWSDK loads here unchanged.- The four chrome flags (
closable,minimizable,pinnable,dockable) are off unless set, as on IWSDK;host.manager.setChrome(id, {...})changes them later.host.manager.hide/show,dockTo/undock/returnHomeandcloseall take effect here, so a menu written against the manager needs no host-specific code. handMenuanddockMode: 'hand-locked'make a hand menu. Passxr: renderer.xrtoconnectUIExtensions(orhandPoseto the host) so it rides the session's tracked hands; on a page that also serves a desktop the source reports no hands outside a session and the menu follows the body until one starts.
The handle it returns satisfies the core WindowHandle and adds the three.js specifics:
const handle = host.createWindow({ title: 'Status', config });
handle.id; // 'uix-window-1'
handle.group; // the scene-graph node - position and rotate freely
handle.document; // the UixPanelDocument
handle.panel; // the same document, under the portable name
handle.onReady((panel) => wire(panel)); // fires straight away here
onReady fires synchronously because uikitml interprets the markup during createWindow; only LAYOUT is async. It still returns an unsubscribe function, so code that runs on both adapters has one shape. supportsStandalonePanels is true: createPanel(config) gives an unmanaged panel with no chrome and no window record.
Known constraint: three versions
xrblocks@0.19 declares a peer of three@^0.184, while IWSDK mandates the super-three@0.181 fork used workspace-wide. Vite resolves a single three per bundle so the pairing works in practice, but npm's peer check cannot express it - this workspace uses legacy-peer-deps (see the root .npmrc). Revisit when IWSDK's three catches up.
Testing
npm test # scale/follow/pointer math + a headless host lifecycle suite
Demo
demos/webxr-multiplatform - detects the platform and boots this adapter on Android XR (or via ?uix-engine=xrblocks anywhere, including XR Blocks' desktop simulator).
Live demos
- Showcase: webxr-uiextensions.pages.dev
- Multiplatform lab: webxr-uix-lab.pages.dev
License
MIT © Reality Collective
Classes
| Class | Description |
|---|---|
| DesktopControls | - |
| Emitter | - |
| ExpandableLabelHandle | - |
| ExpandableModel | - |
| HoldToDrag | - |
| LogModel | - |
| LogViewHandle | - |
| PanelControls | - |
| RegionRegistry | - |
| StepperHandle | - |
| StepperModel | - |
| ToggleHandle | - |
| ToggleModel | Toggle model - pure boolean state for the data-uix="toggle" control. |
| TouchPress | - |
| UixPanelDocument | A 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. |
| UixWindowHost | The 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. |
| WindowManager | - |
Interfaces
| Interface | Description |
|---|---|
| CreateRegionOptions | - |
| CreateWindowOptions | Options for UixWindowHost.createWindow. |
| DesktopControlsOptions | - |
| DockRecipe | Engine-agnostic description of what a dock mode requires. |
| DockTransition | A transition plan: what to add and what to remove, in engine terms. |
| DragSession | - |
| EngineContext | The slice of an XR Blocks Script / three.js app the host binds to. |
| ExpandableOptions | Expandable 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 | - |
| HandMenuPlacement | What the adapter applies this frame. |
| HandPoseSource | Supplies 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 | - |
| LocomotionOptions | - |
| LocomotionState | Mutable locomotion state. x/z are ground position; y is eye height. |
| LogEntry | Log model - pure ring buffer + viewport for the data-uix="log-view" control (scrollable log/list windows). |
| LogModelOptions | - |
| MoveInput | Which movement intents are active this frame. |
| OpenWindowOptions | - |
| PanelHandle | A 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. |
| PanelHost | Creates spatial panels - the engine-specific half of UIKitML rendering. |
| PanelReadyEvent | A window whose panel has finished loading and is ready to be wired. Delivered by WindowHost.onPanelReady. |
| PointerInputSource | Delivers 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 | - |
| RegionHandle | - |
| RegisteredRegion | - |
| SceneDescriptor | A complete, engine-free scene definition. |
| SceneRegion | One dock region in a scene. |
| SceneTarget | What 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. |
| SceneWindow | One window in a scene. |
| StepperOptions | Stepper model - pure numeric state for the data-uix="stepper" control. |
| TouchPressOptions | Touch press - the press / hold / release state machine for a near (poke) pointer against a panel, pure logic with no engine imports. |
| TouchSample | What the adapter measured this frame. undefined means no contact at all. |
| TouchUpdate | The transitions the adapter acts on this frame. |
| UikitRenderer | The slice of WebGLRenderer this needs - keeps the helper testable. |
| UixElement | Structural view of a uikit element as the controls layer needs it. |
| UixWindowHostOptions | - |
| WebXRFrameAccess | The slice of three's WebXRManager (renderer.xr) a hand pose source needs. Structural, so anything with the same three methods will do. |
| WindowChrome | Which 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. |
| WindowHost | The 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. |
| WindowHostContractCase | One 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. |
| WindowHostContractSetup | Everything 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 | - |
| WindowOptionsBase | The window options every adapter understands. |
| WindowRecord | - |
| XrBlocksWindowHandle | A window spawned by UixWindowHost.createWindow. |
Type Aliases
| Type Alias | Description |
|---|---|
| ControlHandle | - |
| DockModeValue | - |
| Hand | - |
| HandMenuAnchor | Where the panel sits relative to the palm. |
| HandPoses | This frame's tracked hands; a hand that is not tracked is absent. |
| HoldPhase | Hold-to-drag threshold - pure timing state for title-bar dragging. |
| Listener | Minimal typed event emitter. |
| PointerSample | One 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 | - |
| Vec3 | Layout regions - pure slot math for docking windows into named regions. |
Variables
| Variable | Description |
|---|---|
| DEFAULT_HAND_MENU | - |
| DEFAULT_LOCOMOTION | - |
| DEFAULT_REGION | - |
| DEFAULT_TOUCH_PRESS | - |
| DockMode | Dock state machine - pure logic, no engine imports. |
| ELLIPSIS | - |
| HAND_MENU_SNIPPET | Reference 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. |
| NO_CHROME | - |
| NO_INPUT | - |
| WINDOW_CHROME_IDS | Window chrome conventions. |
| WINDOW_CHROME_SNIPPET | - |
Functions
| Function | Description |
|---|---|
| anchorOffset | Hand-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. |
| applyScene | Apply a descriptor to an adapter. Regions are created before windows so a window that spawns docked (region: 'x') always finds its region. |
| approach | Move current toward target by alpha, returning the new position. |
| approachAlpha | Frame-rate-independent exponential approach: fraction of the remaining distance to cover this frame, for smoothing speed speed (1/s) over delta seconds. 0 when delta/speed are non-positive, approaches 1 for large steps. |
| attrBoolean | - |
| attrNumber | - |
| attrString | - |
| beginDrag | Begin a drag: capture how far along the ray the grab landed and where the window origin sits relative to the grab point. |
| cameraHeadPoseSource | HeadPoseSource backed by a three.js camera. |
| captureDrop | Which 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). |
| configureRendererForUikit | - |
| connectUIExtensions | Create a window host bound to an XR Blocks Script / three.js context. |
| createLocomotionState | - |
| distanceSquared | Squared distance between two points (cheap tolerance checks). |
| dragPosition | Window position for the current ray. rayDirection must be normalized (pointer rays from the input layer already are). |
| evaluateHandMenu | The per-frame entry point an adapter calls for each hand-locked window. |
| faceViewer | Orientation 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. |
| faceViewerYaw | Yaw (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 | - |
| findRoles | All descendants (including self) declared as <uix-{role}>. |
| fitScale | Uniform scale that fits a panel of naturalWidth × naturalHeight (UIKit cm units) into targetWidth × targetHeight meters. Returns undefined when any input is not yet usable (layout not measured, zero target). |
| followTarget | World-space target position for a following window. |
| forwardClick | Dispatch a click to the interactive element (if any) under the first hit of an intersection list (Raycaster.intersectObject(panel, true) order). Returns the element that received the event, for host-side bookkeeping. |
| handMenuPose | The menu's world pose for a hand: anchored in the hand frame, turned to face the viewer. |
| hasCapacity | - |
| intentForKey | Map a KeyboardEvent.code to the movement intent it drives. |
| isDockMode | - |
| isPalmFacing | - |
| isTextEntryTarget | True when a key event is being typed into a DOM text field - <input>, <textarea>, <select> or anything contenteditable. |
| minimizeLabelFor | What 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) |
| moveDirection | Ground-plane movement direction in WORLD space for a given yaw. |
| normalize | - |
| normalizeRegion | - |
| palmFacing | Cosine 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. |
| palmNormal | The palm normal in the hand frame: -X on the right hand, +X on the left. |
| panelControlsFor | Controls of an upgraded panel; undefined before UIControlsSystem ran. |
| pickHand | Pick 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. |
| pickInteractive | Walk up from a hit object to the nearest ancestor that can receive events and belongs to a uikit tree (identified by the dataUid UIKitML stamps into userData). Pure - testable with stub objects. |
| pinLabelFor | What 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) |
| planTransition | Compute the minimal set of engine changes to move between dock modes. Returns undefined for a no-op (same mode). |
| quaternionFromBasis | Quaternion for the rotation whose columns are the given orthonormal axes. |
| recipeFor | - |
| resolveHandMenu | Fill a partial set of options from the defaults. |
| resolveTouchPress | - |
| rotate | Rotate a vector by a unit quaternion [x, y, z, w]. |
| rotateOffsetByYaw | Rotate an offset by a yaw angle (about +Y). |
| sceneConfigPaths | Every distinct panel config path in a scene (for preloading). |
| slotOffset | Local-space offset of slot index within a region. |
| stepLocomotion | Advance the locomotion state by delta seconds. |
| tagOf | The custom-element tag this element was declared with, lowercased, or undefined for a plain built-in element. |
| targetStanceHeight | The eye height being eased toward, ignoring any jump arc. |
| togglePinned | The "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 | - |
| upgradePanel | Upgrade every data-uix control under root and remember the result against documentKey (the panel's UIKitDocument). Idempotent per key. |
| upgradeStepper | - |
| upgradeToggle | - |
| validateScene | Validate 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. |
| walk | Depth-first traversal over uikit element children. |
| webxrHandPoseSource | HandPoseSource backed by a live WebXR session: each hand's pose is its input source's gripSpace (a controller's grip, or the tracked hand), falling back to the target ray space when a runtime gives a hand none. Returns undefined for a hand with no input source this frame, or with no frame at all (outside a session), so hand menus stay hidden there. |
| windowHostContractCases | The shared host conformance suite. An adapter's test file is a loop: |
| yawFromQuaternion | Extract the yaw (rotation about +Y) from a quaternion [x, y, z, w]. |
References
WindowHandle
Renames and re-exports XrBlocksWindowHandle