@realitycollective/webxr-uiextensions
The core of the Reality Collective UI Extensions. It provides:
- Windows - movable, resizable panels with a title bar and pin, dock, minimise and close buttons.
- Docking - snapping a window into a named region of the scene, such as a row along a wall.
- Layout regions - the named areas that windows snap into.
- Drag maths - the pure calculations behind dragging, dropping and following the user.
- Controls - the state behind steppers, toggles, expandable sections and log views.
- Markup upgrading - code that turns a plain element carrying a
data-uixattribute into a working control, so you write markup rather than components. - Adapter interfaces - what an engine package must implement to host all of the above.
This package imports no 3D engine. A test, test/architecture.test.ts, fails the moment three, @iwsdk/*, @pmndrs/* or xrblocks appears anywhere in src/. That is what lets the same interface run unchanged on every three.js WebXR runtime.
It carries exactly one runtime dependency, @realitycollective/webxr-input, and the same test fails on any other. That package is the shared contracts vocabulary: plain tuples and records, no engine imports and no dependencies of its own. Vec3Tuple, QuatTuple, HeadPose, HeadPoseSource and PointerSample come from there rather than being redeclared here, so a pose or a ray means the same thing to the Interactions family and to this one, and one input stack drives both. All five are re-exported from this package, so importing them from here keeps working.
You probably want an adapter, not this package
| Your engine | Install | Status |
|---|---|---|
| Meta IWSDK (Quest / Horizon OS) | @realitycollective/iwsdk-uiextensions | reference adapter, full feature set |
| Google XR Blocks / plain three.js | @realitycollective/xrblocks-uiextensions | experimental, partial feature set |
Both adapters re-export everything here, so an app installs one package only. Depend on the core directly when writing headless logic, tests, tooling - or a new adapter.
What lives here
src/core/ window manager, dock state machine, region slot math,
drag math, hold-to-drag, control models (stepper/toggle/
expandable/log) - pure logic, 100% coverage gated
src/controls/ data-uix markup upgraders, driven through the structural
UixElement interface (works on ANY conforming element tree)
src/chrome/ window chrome conventions: contractual element ids
(uix-titlebar, uix-pin, ...) + reference UIKitML snippet
src/adapter.ts the platform-adapter contract: PanelHost, PanelHandle,
WindowHost, WindowHandle, WindowOptionsBase, HeadPoseSource,
PointerInputSource (plain tuples, no engine)
src/contract-cases.ts
windowHostContractCases() - the WindowHost conformance suite
as data, for an adapter to run in its own test runner
Writing an adapter
An adapter supplies three capabilities and drives the core from its frame loop:
- Panels - implement
PanelHost.createPanel(configJson): turn compiled UIKitML JSON into a live panel whose element tree satisfiesUixElement(uikit does out of the box; ids land inuserData). - Input - deliver press/move/release into the core's
HoldToDrag+ drag math, or wire chrome clicks straight toWindowManager. - Viewer pose - implement
HeadPoseSourcefor follow mode and body-locked regions.
The IWSDK adapter is the reference implementation; the XR Blocks adapter shows the same contract bound without an ECS. When yours runs, prove it with the shipped conformance suite below.
The window surface
WindowHost is what app code writes against once panels exist. Four members carry the whole contract, and both shipped adapters honour all four:
supportsStandalonePanels: boolean- whethercreatePanelworks here. IWSDK reportsfalsebecause the ECS owns panel lifecycles andcreatePanelthrows; the three.js host reportstrue. Check it rather than guessing, and spawn a window when it isfalse.onPanelReady(listener)- fires as each panel becomes wireable and replays the ones already live, so wiring order never matters. The event'skindsays what became ready:windowfor one created through the window factory,panelfor a bare panel the adapter noticed. A bare panel'sidis the adapter's best stable identifier for it, which on IWSDK is the config path. Only a host that discovers panels the app created outside the window factory ever reportspanel; the three.js and XR Blocks host reportswindowonly, because a standalonecreatePaneldocument is handed back to the caller and never announced.createWindow(options)- adapter-specific, because theconfigpayload differs per engine, but it always returns aWindowHandle.WindowHandle-id,panel(undefineduntil the document is attached) andonReady(listener), which runs once and fires immediately if the panel is already there. It is the per-window form ofonPanelReady, for when you hold a handle and want only that window.- Getting the panel later, when you did not keep the handle - on IWSDK call
getPanelHandle(entity)with the window's entity; on the three.js and XR Blocks host callhost.window(id)?.panel. Both return the samePanelHandle.
Options are shared even though createWindow is not: every adapter's option type extends WindowOptionsBase (id, title, dockMode, position, maxWidth/maxHeight, movable, closable, minimizable, pinnable, dockable, followOffset/followSpeed/followTolerance, region). An option means the same thing everywhere, so one SceneWindow maps onto every adapter with no translation table. The four chrome flags are all off unless set: a window shows only the title-bar buttons it asked for, and WindowManager.setChrome changes that later.
The WindowManager is the state API app code drives, and every adapter applies every one of its events. Beyond focus, minimize and dock mode it holds hidden (hide/show), region (dockTo/undock, plus returnHome), chrome (setChrome) and handMenu (setHandMenu), each with a typed event, and close is the one teardown call - an adapter must dispose on closed. A menu written against the manager therefore runs unchanged on every engine.
Near touch has a press / hold / release state machine, TouchPress in core/touch-press.ts: fed a signed distance (positive in front of the surface) and the target under the finger each frame, it presses only on entering from the front, holds until the finger comes back out past a release distance or contact is lost, and cannot press again before that release. It reports the target at the press and at the release separately, because a finger can enter one button and leave through another; what that means for a click is the adapter's rule. The IWSDK adapter drives IWSDK's touch pointers from it.
Hand menus are the fourth dock mode, hand-locked. The core owns all of it except the hand pose: hand-menu.ts turns the window's handMenu options (hand, anchor, palm gate) and this frame's hand and head poses into "visible, and where", in the WebXR grip frame (-Z toward the thumb, +Y up the arm, palm at -X on the right hand and +X on the left), which a controller's grip and a tracked hand's gripSpace share. An adapter supplies a HandPoseSource and applies the result; one that has no hands falls back to body-follow placement. HAND_MENU_SNIPPET is the reference markup: a title-bar-free vertical stack that sizes to its content.
Proving a new adapter conforms
windowHostContractCases() is the WindowHost conformance suite, shipped as data rather than as tests. Each case is a name plus a run(setup) that returns silently on success and throws an Error describing the failure otherwise, so an adapter runs them in whatever test runner it already has. It ships runner-free because an adapter written outside this repository cannot reach into this one's test/ folder, and because no adapter should have to install this repo's runner to prove itself.
An adapter's test file is a loop:
import { windowHostContractCases } from '@realitycollective/webxr-uiextensions';
import type { WindowHostContractSetup } from '@realitycollective/webxr-uiextensions';
function makeSetup(): WindowHostContractSetup {
const host = createMyHost();
return {
host,
manager: host.manager, // the WindowManager the host applies
createWindow: (id) => host.createWindow({ id, config: myConfig() }),
// Only where the panel arrives after the window does:
attach: (id) => deliverThePanelFor(id),
// Only where supportsStandalonePanels is true:
panelConfig: myConfig(),
};
}
for (const contractCase of windowHostContractCases()) {
it(contractCase.name, () => contractCase.run(makeSetup()));
}
makeSetup() runs once per case, because the cases spawn windows of their own and do not clean up after themselves. manager is required: one case closes a window through it and checks the host stops replaying it. attach and panelConfig are both optional: leave attach out when a window's panel exists as soon as the window does, and panelConfig out when the host reports supportsStandalonePanels: false. Both shipped adapters run this suite, so a case failing on yours is a real difference in behaviour, not a difference in test style.
Testing
npm test # from the workspace root - vitest, 100% thresholds on src/core
Live demos
- Showcase: webxr-uiextensions.pages.dev
- Multiplatform lab: webxr-uix-lab.pages.dev
License
MIT © Reality Collective
Classes
| Class | Description |
|---|---|
| Emitter | - |
| ExpandableLabelHandle | - |
| ExpandableModel | - |
| HoldToDrag | - |
| LogModel | - |
| LogViewHandle | - |
| PanelControls | - |
| RegionRegistry | - |
| StepperHandle | - |
| StepperModel | - |
| ToggleHandle | - |
| ToggleModel | Toggle model - pure boolean state for the data-uix="toggle" control. |
| TouchPress | - |
| WindowManager | - |
Interfaces
| Interface | Description |
|---|---|
| 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 | - |
| 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 | - |
| LogEntry | Log model - pure ring buffer + viewport for the data-uix="log-view" control (scrollable log/list windows). |
| LogModelOptions | - |
| 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 | - |
| 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. |
| UixElement | Structural view of a uikit element as the controls layer needs it. |
| 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. |
| WindowHandle | A window an adapter spawned, before its panel necessarily exists. |
| 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 | - |
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_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 | - |
| 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. |
| 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. |
| 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). |
| 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}>. |
| handMenuPose | The menu's world pose for a hand: anchored in the hand frame, turned to face the viewer. |
| hasCapacity | - |
| isDockMode | - |
| isPalmFacing | - |
| 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) |
| 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. |
| 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]. |
| sceneConfigPaths | Every distinct panel config path in a scene (for preloading). |
| slotOffset | Local-space offset of slot index within a region. |
| tagOf | The custom-element tag this element was declared with, lowercased, or undefined for a plain built-in element. |
| 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. |
| windowHostContractCases | The shared host conformance suite. An adapter's test file is a loop: |