Skip to main content

@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

FeatureIWSDKXR 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/aDesktopControls
XR select-ray click forwarding✅ minimal (forwardClick)
Bare panels (createPanel)⬜ ECS owns the lifecyclesupportsStandalonePanels 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:

  • id is optional. Omit it and the window is named uix-window-<n>.
  • movable is 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/returnHome and close all take effect here, so a menu written against the manager needs no host-specific code.
  • handMenu and dockMode: 'hand-locked' make a hand menu. Pass xr: renderer.xr to connectUIExtensions (or handPose to 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

License

MIT © Reality Collective

Classes

ClassDescription
DesktopControls-
Emitter-
ExpandableLabelHandle-
ExpandableModel-
HoldToDrag-
LogModel-
LogViewHandle-
PanelControls-
RegionRegistry-
StepperHandle-
StepperModel-
ToggleHandle-
ToggleModelToggle model - pure boolean state for the data-uix="toggle" control.
TouchPress-
UixPanelDocumentA 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.
UixWindowHostThe 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

InterfaceDescription
CreateRegionOptions-
CreateWindowOptionsOptions for UixWindowHost.createWindow.
DesktopControlsOptions-
DockRecipeEngine-agnostic description of what a dock mode requires.
DockTransitionA transition plan: what to add and what to remove, in engine terms.
DragSession-
EngineContextThe slice of an XR Blocks Script / three.js app the host binds to.
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-
LocomotionOptions-
LocomotionStateMutable locomotion state. x/z are ground position; y is eye height.
LogEntryLog model - pure ring buffer + viewport for the data-uix="log-view" control (scrollable log/list windows).
LogModelOptions-
MoveInputWhich movement intents are active this frame.
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-
RegionHandle-
RegisteredRegion-
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.
UikitRendererThe slice of WebGLRenderer this needs - keeps the helper testable.
UixElementStructural view of a uikit element as the controls layer needs it.
UixWindowHostOptions-
WebXRFrameAccessThe slice of three's WebXRManager (renderer.xr) a hand pose source needs. Structural, so anything with the same three methods will do.
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.
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-
XrBlocksWindowHandleA window spawned by UixWindowHost.createWindow.

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_LOCOMOTION-
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.
NO_CHROME-
NO_INPUT-
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.
approachMove current toward target by alpha, returning the new position.
approachAlphaFrame-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-
beginDragBegin a drag: capture how far along the ray the grab landed and where the window origin sits relative to the grab point.
cameraHeadPoseSourceHeadPoseSource backed by a three.js camera.
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).
configureRendererForUikit-
connectUIExtensionsCreate a window host bound to an XR Blocks Script / three.js context.
createLocomotionState-
distanceSquaredSquared distance between two points (cheap tolerance checks).
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}>.
fitScaleUniform 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).
followTargetWorld-space target position for a following window.
forwardClickDispatch 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.
handMenuPoseThe menu's world pose for a hand: anchored in the hand frame, turned to face the viewer.
hasCapacity-
intentForKeyMap a KeyboardEvent.code to the movement intent it drives.
isDockMode-
isPalmFacing-
isTextEntryTargetTrue when a key event is being typed into a DOM text field - <input>, <textarea>, <select> or anything contenteditable.
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)
moveDirectionGround-plane movement direction in WORLD space for a given yaw.
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.
pickInteractiveWalk 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.
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-
resolveHandMenuFill a partial set of options from the defaults.
resolveTouchPress-
rotateRotate a vector by a unit quaternion [x, y, z, w].
rotateOffsetByYawRotate an offset by a yaw angle (about +Y).
sceneConfigPathsEvery distinct panel config path in a scene (for preloading).
slotOffsetLocal-space offset of slot index within a region.
stepLocomotionAdvance the locomotion state by delta seconds.
tagOfThe custom-element tag this element was declared with, lowercased, or undefined for a plain built-in element.
targetStanceHeightThe eye height being eased toward, ignoring any jump arc.
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.
webxrHandPoseSourceHandPoseSource 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.
windowHostContractCasesThe shared host conformance suite. An adapter's test file is a loop:
yawFromQuaternionExtract the yaw (rotation about +Y) from a quaternion [x, y, z, w].

References

WindowHandle

Renames and re-exports XrBlocksWindowHandle