A library for building user interfaces inside a WebXR scene. It gives you movable, resizable windows that can be dragged, snapped into fixed regions of the scene, and filled with buttons, sliders and other controls.
The core logic is plain TypeScript with no dependency on any 3D engine. Small adapters connect it to Meta's Immersive Web SDK, to Google XR Blocks, or to plain three.js. The repository also holds developer tooling and demo clients you can deploy.
Panels are written in UIKitML, IWSDK's HTML/CSS-like markup for spatial panels.
Built to the Reality Collective service-framework package conventions.
git clone https://github.com/realitycollective/WebXR-UIExtensions.git
cd WebXR-UIExtensions
npm ciThe repository root is the npm workspace root - packages/* are the publishable libraries, demos/* the clients. Work branches off main and PRs target main.
WebXR-UIExtensions/
├── packages/
│ ├── webxr-uiextensions/ @realitycollective/webxr-uiextensions - the core
│ │ ├── src/core/ window/dock/region/drag logic + control models
│ │ │ (pure, no engine imports, 100% coverage gated)
│ │ ├── src/controls/ data-uix markup upgraders (UixElement interface)
│ │ ├── src/chrome/ window chrome conventions (contractual ids)
│ │ ├── src/adapter.ts what an engine adapter must implement (PanelHost, HeadPose…)
│ │ ├── src/scene.ts SceneDescriptor - the portable scene format
│ │ └── test/ the coverage-gated suites
│ ├── iwsdk-uiextensions/ Meta IWSDK adapter (reference implementation)
│ │ ├── src/systems/ ECS systems binding the core to @iwsdk/core
│ │ ├── src/factory.ts … factory, components, manager/region registries
│ │ └── Examples/ basic-window, controls, dock-regions
│ │ (shipped in the npm tarball)
│ ├── xrblocks-uiextensions/ Google XR Blocks / three.js adapter (EXPERIMENTAL)
│ │ └── src/ panel document, window host, follow + scale math,
│ │ desktop controls/locomotion, pointer forwarding
│ └── uix-devtools/ dev tooling (dev-only, NEVER ship)
│ ├── src/gate.ts edit-session launch gate (compiled out of prod)
│ ├── src/runtime-compile.ts live UIKitML → panel compilation
│ └── src/cli/ the `uix-dev` CLI (quick tunnel, QR, doctor)
├── demos/
│ ├── showcase/ the feature tour, on whichever runtime fits
│ │ ├── ui/*.uikitml the five windows, authored in UIKitML
│ │ ├── src/playground-scene.ts THE playground, as portable data
│ │ ├── src/playground-behaviour.ts its demo logic, engine-free
│ │ ├── src/platform-detect.ts UA → runtime, shared with the lab
│ │ ├── src/world.ts IWSDK bootstrap + stage dressing (Horizon OS)
│ │ └── src/desktop-world.ts plain three.js bootstrap (everywhere else)
│ ├── devtools-playground/ demo for uix-devtools - reuses the showcase
│ │ world, adds the edit gate + live UX editor
│ └── webxr-multiplatform/ "the lab" - the single encompassing demo:
│ └── src/pipelines/ iwsdk / xrblocks / desktop, offered on a launch
│ screen (same detection + ?uix-engine override);
│ the iwsdk and desktop pipelines wrap the
│ showcase's own bootstraps rather than copying
│ them; edit gate on IWSDK; own Pages projects
├── scripts/verify-pack.mjs consumer check - packs, installs, imports
├── docs/developer-cycle.md the four development loops, terminal to deploy
├── CHANGELOG.md shared across all four packages
├── .github/workflows/ CI, Cloudflare Pages deploy, GitHub publish
├── vitest.config.ts workspace test run + coverage gates
└── tsconfig.base.json shared strict compiler options
Every package ships a demo, and all three demos build the same playground scene. The scene is a portable SceneDescriptor and its behaviour is wired through the onPanelReady contract, neither of which depends on an engine. Because of that, the IWSDK, XR Blocks and plain three.js versions behave identically from identical data.
Each adapter re-exports the whole core, so an app installs one package:
| Install | For |
|---|---|
@realitycollective/iwsdk-uiextensions |
Meta IWSDK apps (core re-exported) |
@realitycollective/xrblocks-uiextensions |
XR Blocks / three.js apps (core re-exported, experimental) |
@realitycollective/webxr-uiextensions |
writing your own engine adapter |
@realitycollective/uix-devtools |
dev dependency only - never in a shipped bundle |
Working in this repo needs none of the above: npm ci is the whole setup, and the demos, tests and typecheck all resolve @realitycollective/* to packages/<name>/src rather than to anything installed. That stays true after the packages are published - see How the demos resolve the libraries
for the contributor-vs-consumer split and how to test the published path.
| Command | What |
|---|---|
npm ci |
install everything |
npm run build |
build all four packages (tsc → dist/, core first) |
npm run typecheck |
strict typecheck - all packages + all demos |
npm test |
vitest + v8 coverage (100% thresholds on the pure modules) |
npm run test:watch |
the same suites in watch mode |
npm run verify:pack |
consumer check: pack, install into a temp project, import |
npm run dev:showcase |
run the IWSDK showcase locally |
npm run dev:playground |
run the devtools playground locally (edit gate open in dev) |
npm run dev:multiplatform |
run the engine-picking demo (?uix-engine= to force) |
npm run dev:live |
one-command headset loop: playground + Cloudflare quick tunnel + QR codes |
npm run build:demos |
static bundles for all three demos (compiles UIKitML too) |
npm run build:showcase / build:playground |
one demo bundle at a time (what deploy uses) |
npx uix-dev doctor |
check node / cloudflared / adb before first use |
New here? Read docs/developer-cycle.md - the full developer cycle: desktop preview, testing on a Quest (USB and tunnel), live edit sessions, deployment and publishing. Released changes are tracked in CHANGELOG.md - all four packages version together.
Deployed from main by the Deploy workflow. Pull requests deploy to the isolated -test projects instead, so a PR can never touch these.
| Demo | Production | Staging (per PR) |
|---|---|---|
| Showcase | webxr-uiextensions.pages.dev |
webxr-uiextensions-test.pages.dev |
| Multiplatform lab | webxr-uix-lab.pages.dev |
webxr-uix-lab-test.pages.dev |
Open either on a headset - each production deploy also prints a short code and a QR code to the workflow's step summary. Both demos pick their runtime from the hardware, through the same detection (demos/showcase/src/platform-detect.ts): Quest → IWSDK, Android XR → XR Blocks, anything else → plain three.js. The lab offers all three on a launch screen; the showcase ships two of them and boots the choice directly, because IWSDK takes the view pose from the headset and has no desktop camera. Force either with ?uix-engine=iwsdk, ?uix-engine=xrblocks or ?uix-engine=desktop.
Two workflows ship in every Reality Collective TypeScript repository, with the same names everywhere. ci.yml both gates and deploys: the build job runs once and the deploy jobs consume its artifacts, so nothing is built or tested twice.
| Workflow | Trigger | Does |
|---|---|---|
ci.yml |
every PR + push to main / development |
Build, typecheck, test with coverage gates, verify:pack, and all three demo builds. On a PR it then deploys to the -test Pages projects; on a push to main, to production, with short codes and QR codes in the step summary. The deploy steps skip when the Cloudflare secrets are absent, leaving a pure build gate |
publish-npm.yml |
manual dispatch | packs all four packages and publishes to npmjs.com with provenance - preview dist-tag from development, latest from main. Defaults to a dry run |
A PR can never touch production - staging lives in its own isolated Pages projects.
@realitycollective/webxr-uiextensions depends on @realitycollective/webxr-input, which is published from the WebXR-Input repository. That package must be on npmjs.com first, or npm ci cannot resolve it and CI fails at install. After it publishes, run npm install once to regenerate package-lock.json against the registry version and commit the result.
To develop against an unreleased webxr-input, use npm link rather than editing package.json - a file: link committed to this repository breaks CI, which has no sibling checkout.
Five windows + two dock regions demonstrating the whole surface:
- Crew Registration - native UIKitML
<input>/<textarea>(system keyboard), horizon-kitSlider, submit validation - Event Log -
data-uix="log-view"fed by liveWindowManagerevents; spawns docked into the world-locked "console wall" region - Click Machine - a button with a "clicked" list display
- Player Status - movable info window: health stepper, shield toggle, expandable bio; follows the player until pinned or dragged
- Gallery - image rendering via the native
<img>element - Regions: a world-locked wall (column) and a body-locked belt (row) - drag any window into either
The devtools playground adds the UX Editor window on top: UIKitML source in a textarea, compiled at runtime to a live panel - see demos/devtools-playground. The multiplatform lab runs the same scene through whichever pipeline the hardware calls for - see demos/webxr-multiplatform.
Known verification gap: everything headless is CI-tested, but in-headset behaviours (title-bar HandleStore drag, system keyboard on Quest) still need a manual pass on device - see the PR/commit notes.
The Reality Collective WebXR packages aim at one outcome: an app's logic, input handling, interactions and UI should not care which engine hosts them. Each family ships an engine-free core and thin adapters for Meta IWSDK, plain three.js and WebXR, and Google XR Blocks. When an app still has to reach into the host, either a contract is missing, which is a bug to report, or the app is overreaching.
Portable world-building is not a current promise. Scene content (meshes, prefabs, placement) is built by the app, ideally behind a factory interface the app owns, so that a second host can implement the same factories. A shared content descriptor, following the shape of the UI family's SceneDescriptor, will be considered only when a second host is actually targeted. Meta's iwsdk.scene.v1 format is an acceptable authoring interchange in the meantime.
Position recorded on 2026-09-03 from the Pale Signal client's gaps report.